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 Landlock
24//! ABI v1 does not mediate an abstract-namespace socket. `AF_NETLINK`/`AF_PACKET` are denied
25//! unconditionally; `AF_UNIX` is denied unless `capabilities.network.unix_sockets` says
26//! 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 workdir's own
36//! grant deliberately withholds `MakeChar`/`MakeBlock`/`MakeSock`, and withholds `Execute`
37//! unless `capabilities.filesystem.workdir_exec` is declared — see
38//! `linux_enforce::WORKDIR_ACCESS_RIGHTS_NO_EXEC` and `linux_enforce::workdir_access_rights`.
39//! Outside the workdir, alongside the derived
40//! read+execute grants, a fixed three-device set is granted unconditionally — see
41//! `CAPSULE_DEVICE_GRANTS`: `/dev/null` read+write (the only writable path outside the
42//! workdir), `/dev/zero` and `/dev/urandom` read-only, and no other device at all.
43//!
44//! Both Linux tiers additionally strip the forked child's **Linux capabilities** (bounding set,
45//! then permitted/effective/inheritable, then `no_new_privs`) before `execve()` — see
46//! `linux_enforce::drop_all_capabilities`. Landlock and the capability model are independent,
47//! both-must-allow gates: a root-uid capsule keeps `CAP_MKNOD` no matter what Landlock permits,
48//! so the Landlock narrowing above does not subsume this.
49//!
50//! Both are Linux-only. macOS (and any other non-Linux target) has no equivalent kernel
51//! primitive and permanently falls back to the existing, unmodified synthetic-HOME/env-
52//! stripping mechanism in `shell.rs` — see `EnforcementTier::EnvironmentOnly`.
53//!
54//! ## Four-tier model
55//!
56//! - `KernelSealed` (Linux, everything `KernelFull` has, plus a usable unprivileged user+mount
57//! namespace — AppArmor out of the way, `unshare` + `mount` verified by a real probe): the
58//! subprocess tree additionally runs inside a private mount namespace pivoted onto a composed
59//! root, so paths outside it are *absent* rather than denied. Landlock and seccomp still install,
60//! inside the new root, as defence in depth — see [`crate::sealed`]. Only capsules that declare
61//! `capabilities.containment: sealed` get this; see `applied_tier`.
62//! - `KernelFull` (Linux, Landlock ABI available — kernel 5.13+): seccomp syscall allowlist +
63//! socket-domain denial + Landlock filesystem scoping, which is also what makes
64//! `capabilities.shell.allow` enforceable (exec is a Landlock right, not a syscall filter).
65//! - `KernelSeccompOnly` (Linux, Landlock unavailable — kernel <5.13): seccomp syscall allowlist +
66//! socket-domain denial only. Filesystem scope stays convention-only (`current_dir`) — a
67//! documented gap, not a bug — and, since the exec supervisor was retired, so does
68//! `shell.allow`: with no Landlock domain there is no kernel-level exec mediation on this tier
69//! at all. `W-SEC-002` says so. The syscall allowlist and the socket-domain denial are identical
70//! on both tiers: they are seccomp rules, so they do not depend on Landlock in any way.
71//! - `EnvironmentOnly` (macOS / any non-Linux target): no kernel primitive attempted at all.
72//! Permanent, not a placeholder for a future slice.
73//!
74//! Tier detection is always a runtime capability probe (attempt Landlock ruleset
75//! construction, inspect the resulting `RulesetStatus`; fork a child and really call
76//! `unshare(CLONE_NEWUSER | CLONE_NEWNS)` followed by a `mount(2)`) — never a hardcoded
77//! kernel-version string parse, which is fragile against distro backports, and never a config
78//! file. Each reading is taken live, and a staged session takes exactly one for every consumer
79//! of that session to read; see [`HostProbe`].
80//!
81//! ## Fail-closed invariant
82//!
83//! If kernel enforcement setup fails unexpectedly on a Linux host (not the expected
84//! "Landlock unsupported, degrade to `KernelSeccompOnly`" signal, but something like seccomp
85//! filter install failing, or the namespace-socket handshake failing before spawn) —
86//! `prepare_enforcement` returns `Err` and `execute_shell` must not call `.spawn()` at all.
87//! There is no code path where a Linux host silently runs a shell subprocess with zero
88//! enforcement because setup failed.
89
90use std::net::IpAddr;
91use std::path::{Path, PathBuf};
92use std::sync::Arc;
93
94use murmur_artifact::security_warnings::{
95 security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
96};
97use murmur_artifact::InterpreterRuntimeGrant;
98
99use crate::types::CapabilityPolicy;
100
101/// Kernel-enforcement tier for the current host, in descending order of enforcement
102/// strength. Always host-probed at launch time — never sourced from the manifest.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub(crate) enum EnforcementTier {
105 /// Linux, and everything `KernelFull` has *plus* a private mount namespace pivoted onto a
106 /// composed root (see [`crate::sealed`]): the shipped AppArmor profile is loaded (or AppArmor
107 /// imposes no unprivileged-userns restriction), a real `unshare` + `mount` probe succeeded,
108 /// and the Landlock ABI is usable. Paths outside the composed root are *absent*, not merely
109 /// denied — and Landlock and seccomp still install inside it, as defence in depth.
110 ///
111 /// Ranked first because it is the strongest tier; the enum is documented weakest-last and
112 /// carries no `Ord`, so this position is documentation, not a comparison.
113 KernelSealed,
114 /// Linux, Landlock ABI available (kernel 5.13+): seccomp exec/network allowlisting
115 /// + Landlock filesystem scoping.
116 KernelFull,
117 /// Linux, Landlock unavailable (kernel <5.13): seccomp exec/network allowlisting
118 /// only. Filesystem scope stays convention-only (`current_dir`) — documented gap.
119 KernelSeccompOnly,
120 /// macOS (or any non-Linux target): no kernel sandboxing primitive exists. This is
121 /// PERMANENT, not a placeholder for a future slice.
122 EnvironmentOnly,
123}
124
125/// Pure decision: given whether the host is Linux, the outcome of a Landlock
126/// ruleset-restriction probe, and what [`crate::sealed::probe_sealed_support`] found, decide the
127/// tier. No syscalls here — fully unit-testable on any OS.
128///
129/// `KernelSealed` requires the whole conjunction — Linux, a usable Landlock ABI, AppArmor out of
130/// the way, and a namespace probe that really created one. Any missing element falls back to the
131/// tier that element still supports; nothing here ever reports a mechanism the host did not
132/// demonstrate.
133pub(crate) fn tier_from_probe(
134 is_linux: bool,
135 landlock_fully_enforced: Option<bool>,
136 sealed: crate::sealed::SealedProbe,
137) -> EnforcementTier {
138 if !is_linux {
139 return EnforcementTier::EnvironmentOnly;
140 }
141 match landlock_fully_enforced {
142 Some(true)
143 if sealed.userns_grant.permits_userns()
144 && sealed.namespace == crate::sealed::NamespaceProbe::Ok =>
145 {
146 EnforcementTier::KernelSealed
147 }
148 Some(true) => EnforcementTier::KernelFull,
149 Some(false) | None => EnforcementTier::KernelSeccompOnly,
150 }
151}
152
153/// The tier this *session* actually applies, which is never stronger than the host can back and
154/// never stronger than the capsule asked for.
155///
156/// A capsule declaring `scoped` on a sealed-capable host keeps running exactly as it does today:
157/// the composed root would otherwise silently delete host paths its `interpreter_runtime` grants
158/// legitimately point at — a narrowing of `scoped`'s behaviour, not added security. The
159/// declared floor is honoured, not merely met.
160///
161/// It is deliberately *not* symmetric with the achieved class recorded in the trace: `achieved`
162/// answers "what can this host back" (a host fact) and is probed independently, while this answers
163/// "what did this session install" (a session fact).
164pub(crate) fn applied_tier(
165 host_tier: EnforcementTier,
166 declared: murmur_artifact::ContainmentClass,
167) -> EnforcementTier {
168 match host_tier {
169 EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
170 EnforcementTier::KernelFull
171 }
172 other => other,
173 }
174}
175
176/// The host facts the tier decision is made from, taken together as one reading.
177///
178/// A staged session takes exactly one of these and every consumer of that session reads it — the
179/// containment-floor refusal, the `ScopeReport` written to the trace, and the tier
180/// [`ShellEnforcement::resolve`] installs. Two readings of a host that changed between them would
181/// let the trace describe a session that never ran under what it claims.
182#[derive(Debug, Clone, Copy)]
183pub struct HostProbe {
184 landlock_fully_enforced: Option<bool>,
185 sealed: crate::sealed::SealedProbe,
186}
187
188/// Probes taken since this process started. See [`HostProbe::probes_taken`].
189static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
190
191impl HostProbe {
192 /// Reads the host as it is right now: on Linux a real Landlock ruleset restriction attempt
193 /// (`RulesetStatus::FullyEnforced` → `Some(true)`, `PartiallyEnforced`/`NotEnforced` or any
194 /// construction error → `Some(false)`, no Landlock ABI → `None`) plus a real
195 /// `unshare(CLONE_NEWUSER | CLONE_NEWNS)` and `mount(2)`. Off Linux it probes nothing and
196 /// claims nothing.
197 ///
198 /// Taken once per staged session, not once per process. That is affordable because both
199 /// halves do their work in a forked child and leave the calling process exactly as they found
200 /// it — `linux_enforce::probe_landlock_full_access` forks so the runtime is never placed
201 /// into a Landlock domain by a capability check, and [`crate::sealed::probe_sealed_support`]
202 /// forks because `unshare(CLONE_NEWUSER)` is irreversible for the task that calls it. Neither
203 /// is idempotency-sensitive, so a reading costs no more than the syscalls it makes: two
204 /// `fork`/`waitpid` pairs and three small `/proc` reads.
205 ///
206 /// The window this leaves open is one session wide. Between `runtime::stage_session` and the
207 /// last subprocess that session spawns the host is not re-read; a change inside that window is
208 /// caught by the mechanism installation itself failing closed, not by a second probe. A
209 /// process that stages repeatedly — `mur eval`, which stages once per dataset case — judges
210 /// each session against the host as it was when that session was staged.
211 pub fn probe() -> HostProbe {
212 PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
213 #[cfg(target_os = "linux")]
214 let probe = HostProbe {
215 landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
216 sealed: crate::sealed::probe_sealed_support(),
217 };
218 #[cfg(not(target_os = "linux"))]
219 let probe = HostProbe {
220 landlock_fully_enforced: None,
221 sealed: crate::sealed::SealedProbe::default(),
222 };
223 probe
224 }
225
226 /// A probe value assembled from known halves, without touching the host. The seam the
227 /// fail-closed tests use to assert what an all-negative reading resolves to on any OS.
228 #[cfg(test)]
229 pub(crate) fn from_parts(
230 landlock_fully_enforced: Option<bool>,
231 sealed: crate::sealed::SealedProbe,
232 ) -> HostProbe {
233 HostProbe {
234 landlock_fully_enforced,
235 sealed,
236 }
237 }
238
239 /// The tier this reading supports. `pub(crate)` because [`EnforcementTier`] is.
240 pub(crate) fn tier(&self) -> EnforcementTier {
241 tier_from_probe(
242 cfg!(target_os = "linux"),
243 self.landlock_fully_enforced,
244 self.sealed,
245 )
246 }
247
248 /// Which single mechanism keeps this reading below `sealed`, or `None` when nothing does.
249 pub fn sealed_blocker(&self) -> Option<crate::sealed::SealedBlocker> {
250 crate::sealed::sealed_blocker(
251 cfg!(target_os = "linux"),
252 self.landlock_fully_enforced == Some(true),
253 self.sealed,
254 )
255 }
256
257 /// Where this host's permission to create an unprivileged user namespace comes from, or
258 /// `None` off Linux, where the question has no answer rather than a negative one.
259 pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
260 if cfg!(target_os = "linux") {
261 Some(self.sealed.userns_grant)
262 } else {
263 None
264 }
265 }
266
267 /// How many probes this process has taken, counting from zero at startup and never
268 /// decreasing.
269 ///
270 /// The observability hook the freshness tests assert against — that staging twice advances it
271 /// by two, and that one `stage_session` advances it by one — and nothing else reads it. No
272 /// runtime decision depends on the count.
273 pub fn probes_taken() -> u64 {
274 PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
275 }
276}
277
278/// The tier this host can back, from a reading taken here.
279///
280/// Answers a standalone host question — `mur doctor` and the escape-conformance harness ask it.
281/// A *session* must not: it reads its own [`HostProbe`] so its refusal, its trace and the
282/// mechanisms it installs describe one host at one moment.
283pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
284 HostProbe::probe().tier()
285}
286
287/// Which single mechanism keeps this host below `sealed`, or `None` when nothing does, from a
288/// reading taken here.
289///
290/// Takes its own probe, as its neighbours do. What a session needs — that the refusal an operator
291/// sees and the tier the runtime installs cannot disagree about the host — is upheld by
292/// `runtime::stage_session` passing one [`HostProbe`] to both, not by this function.
293pub fn detect_sealed_blocker() -> Option<crate::sealed::SealedBlocker> {
294 HostProbe::probe().sealed_blocker()
295}
296
297/// Where this host's unprivileged-user-namespace permission comes from, or `None` off Linux, where
298/// the question has no answer rather than a negative one.
299///
300/// Takes its own probe, as its neighbours do; a session reads the provenance off its own
301/// [`HostProbe`] instead, so what it is shown and the tier it installs describe one host at one
302/// moment.
303pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
304 HostProbe::probe().userns_grant()
305}
306
307/// Resolves every host in `network_allow` (via `crate::network_policy::parse_network_allow_rules`,
308/// the same parser already used for the WASI-HTTP-facing allowlist) to concrete IP addresses
309/// via DNS, once. Returns the deduplicated union of all resolved addresses across all allowed
310/// hosts.
311///
312/// Hosts that fail to resolve are silently skipped, matching `resolve_exec_allowlist`'s precedent
313/// below: the resolved set feeds *only* the kernel-level (Landlock/seccomp) allow set for
314/// `shell.allow` subprocesses, so a host contributing zero IPs merely *shrinks* that set (deny
315/// direction, fail-closed). A DNS miss must not be fatal — resolution happens unconditionally at
316/// staging time, before any WASM loads and regardless of whether the capsule declares
317/// `shell.allow` at all, so hard-failing here turned any transient resolver outage or
318/// non-resolving allowlist host into an E-IO-003 that blocked the whole run.
319///
320/// This does not widen what the capsule may reach: the WASM component's own outbound WASI-HTTP
321/// calls are gated separately by `network_policy::NetworkAllowRule::matches`, a host-pattern
322/// match that never consults DNS. Malformed host *syntax* is still a hard error — it is caught by
323/// `parse_network_allow_rules` above, before this loop.
324pub(crate) fn resolve_network_allowlist_ips(
325 network_allow: &[String],
326) -> Result<Vec<IpAddr>, String> {
327 let rules = crate::network_policy::parse_network_allow_rules(network_allow)
328 .map_err(|error| error.to_string())?;
329
330 let mut ips = std::collections::BTreeSet::new();
331 for rule in &rules {
332 let host = rule.host.as_str();
333 match crate::dns_resolver::resolve(host) {
334 crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
335 crate::dns_resolver::Resolution::DoesNotExist => {}
336 // Skipped like any other host that contributed no address, but said out loud: a
337 // resolver that did not answer is a transient condition an operator can act on,
338 // where a name that does not exist is a manifest to correct.
339 crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
340 "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
341 resolved at launch: {reason} (a subprocess reaching it by literal address is \
342 denied for this run)"
343 ),
344 }
345 }
346
347 Ok(ips.into_iter().collect())
348}
349
350/// Resolves each `capabilities.shell.allow` entry to the canonical filesystem path(s) of the
351/// binary it names, once, at launch time. Entries containing a path separator are
352/// canonicalized directly; bare names (the common case, per the manifest schema's
353/// "bare binary name" rule) are looked up in the host `PATH` the way `execvp` would, then
354/// canonicalized (resolving symlinks, `..`, etc.).
355///
356/// Entries that resolve to nothing (binary not installed) are silently skipped — that only
357/// *shrinks* the kernel-level allow set (deny direction, fail-closed) and matches reality:
358/// an exec of a binary that does not exist at launch fails anyway, and a binary *created
359/// after launch* under an allowlisted name (the attack this exists to stop) must not become
360/// allowlisted by virtue of its name.
361///
362/// This canonical set — not the raw name strings — is what `resolve_landlock_grants` derives the
363/// narrow read+execute Landlock grants from, so `cp /usr/bin/nc ./bash && ./bash` is denied: the
364/// copy lives under the workdir, which carries no `Execute` right by default, and the grant that
365/// does carry one names the launch-time `bash`'s own inode, not a basename.
366pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
367 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
368 .map(|path| std::env::split_paths(&path).collect())
369 .unwrap_or_default();
370 resolve_exec_allowlist_in(shell_allow, &path_dirs)
371}
372
373/// Testable core of `resolve_exec_allowlist`, with the `PATH` directory list injected. For a
374/// bare name, every executable `PATH` match is included (not just the first): all of them are
375/// binaries the operator's launch-time `PATH` exposes under that allowlisted name, and on
376/// merged-`/usr` distros `/bin/x` and `/usr/bin/x` canonicalize to the same entry anyway.
377fn resolve_exec_allowlist_in(shell_allow: &[String], path_dirs: &[PathBuf]) -> Vec<PathBuf> {
378 let mut resolved = std::collections::BTreeSet::new();
379 for entry in shell_allow {
380 if entry.is_empty() {
381 continue;
382 }
383 if entry.contains('/') {
384 if let Ok(canonical) = std::fs::canonicalize(entry) {
385 resolved.insert(canonical);
386 }
387 continue;
388 }
389 for dir in path_dirs {
390 let candidate = dir.join(entry);
391 if is_executable_file(&candidate) {
392 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
393 resolved.insert(canonical);
394 }
395 }
396 }
397 }
398 resolved.into_iter().collect()
399}
400
401/// Resolves the program name a shell tool invoked to the canonical filesystem path of the
402/// binary that `execvp` would actually run, for reporting purposes: `shell-event.binary`
403/// on the hook side and the `binary` key of `trace.jsonl`'s `shell` record.
404///
405/// Unlike [`resolve_exec_allowlist`] — which collects *every* `PATH` match because each one needs
406/// its own Landlock grant — this returns the **first** match, which is the one `execvp` picks and
407/// therefore the one that ran. Resolution rules are otherwise identical (same `PATH` source, same
408/// [`is_executable_file`] test, same `canonicalize`), so a reported path is always a member of the
409/// set the Landlock grants were derived from.
410///
411/// A name that resolves to nothing falls back to the bare invoked name, unchanged — never
412/// an error, never a panic, matching `resolve_exec_allowlist`'s "entries that resolve to
413/// nothing are silently skipped" precedent. Observability must not be able to fail a shell
414/// call that the OS is perfectly willing to run.
415pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
416 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
417 .map(|path| std::env::split_paths(&path).collect())
418 .unwrap_or_default();
419 resolve_invoked_binary_path_in(binary, &path_dirs)
420}
421
422/// Testable core of [`resolve_invoked_binary_path`], with the `PATH` directory list
423/// injected. `pub(crate)` because [`crate::reachability`] needs the same first-match resolution
424/// against an *injected* directory list — its own unit tests point `PATH` at a temp directory of
425/// synthetic script fixtures rather than at whatever the machine running the suite has installed.
426pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
427 let fallback = || binary.to_string();
428 if binary.is_empty() {
429 return fallback();
430 }
431 if binary.contains('/') {
432 return match std::fs::canonicalize(binary) {
433 Ok(canonical) => canonical.to_string_lossy().into_owned(),
434 Err(_) => fallback(),
435 };
436 }
437 for dir in path_dirs {
438 let candidate = dir.join(binary);
439 if is_executable_file(&candidate) {
440 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
441 return canonical.to_string_lossy().into_owned();
442 }
443 }
444 }
445 fallback()
446}
447
448/// The first executable named `binary` on this process's `PATH`, or `None` when there is none.
449///
450/// First match wins, which is the one `execvp` would run — the same rule
451/// [`resolve_invoked_binary_path_in`] reports under, so a caller naming a binary and a caller
452/// reporting one cannot disagree about which file `PATH` means. The path is returned as found,
453/// not canonicalized: callers here name a binary to a person rather than derive a grant from it.
454pub fn find_on_path(binary: &str) -> Option<PathBuf> {
455 std::env::split_paths(&std::env::var_os("PATH")?)
456 .map(|dir| dir.join(binary))
457 .find(|candidate| is_executable_file(candidate))
458}
459
460pub(crate) fn is_executable_file(path: &Path) -> bool {
461 use std::os::unix::fs::PermissionsExt;
462 std::fs::metadata(path)
463 .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
464 .unwrap_or(false)
465}
466
467// ---- derived Landlock read+execute grant set ----
468//
469// On `KernelFull`, Landlock scopes the shell subprocess tree to the workdir. The workdir rule
470// grants `linux_enforce::WORKDIR_ACCESS_RIGHTS` (everything in ABI v1 except device-node and
471// unix-socket creation), but — once `restrict_self()` lands — `Execute`/`ReadFile` are then denied
472// on every path *outside* the workdir, including the `shell.allow` binaries themselves, their ELF
473// interpreter (dynamic loader), and every shared library they pull in. That silently defeats the
474// seccomp exec-allowlist: each allowlisted `execve` fails with EACCES before it runs.
475//
476// The functions below derive the exact extra paths that must get a narrow read+execute grant
477// outside the workdir: the canonical `shell.allow` binaries (already resolved by
478// `resolve_exec_allowlist`), each one's `PT_INTERP`, and the transitive closure of the shared
479// libraries their ELF `DT_NEEDED` entries name (resolved via `DT_RPATH`/`DT_RUNPATH` first, then a
480// fixed set of standard library directories). Nothing broader — no directory is granted wholesale.
481//
482// All of this is *parsing and filesystem resolution*, so it runs in the parent at launch time
483// (inside `ShellEnforcement::resolve`) and the resulting `Vec<PathBuf>` is threaded into the
484// forked child's `pre_exec`, which only opens each path and adds a Landlock rule (no parsing in
485// the async-signal-safe window). Every resolution step is shrink-not-fail, matching
486// `resolve_exec_allowlist`: an entry that does not exist, does not parse as ELF, or names a
487// soname that does not resolve simply contributes nothing further — it never errors out.
488
489/// Standard Linux shared-library search directories used as the fallback resolution set for a
490/// `DT_NEEDED` soname when the binary declares no `DT_RPATH`/`DT_RUNPATH` (or it doesn't match).
491/// Covers both supported platform targets (`linux/x86_64` and `linux/aarch64`), including the
492/// Debian/Ubuntu multiarch-triplet subdirectories. Extend here for a platform this list does not
493/// cover — it is the single source of truth.
494const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
495 "/lib",
496 "/lib64",
497 "/usr/lib",
498 "/usr/lib64",
499 "/lib/x86_64-linux-gnu",
500 "/usr/lib/x86_64-linux-gnu",
501 "/lib/aarch64-linux-gnu",
502 "/usr/lib/aarch64-linux-gnu",
503];
504
505/// The subset of an ELF binary's dynamic-linking metadata that decides which extra files the
506/// dynamic loader will touch at load time: the interpreter path (`PT_INTERP`), the direct
507/// shared-library dependencies (`DT_NEEDED` sonames), and the runtime library search paths
508/// (`DT_RPATH`/`DT_RUNPATH`, already split on `:`, `$ORIGIN` left unexpanded here).
509#[derive(Debug, Default, PartialEq, Eq)]
510pub(crate) struct ElfDependencies {
511 pub(crate) interp: Option<String>,
512 pub(crate) needed: Vec<String>,
513 pub(crate) runpaths: Vec<String>,
514}
515
516fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
517 let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
518 Some(if le {
519 u16::from_le_bytes(raw)
520 } else {
521 u16::from_be_bytes(raw)
522 })
523}
524
525fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
526 let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
527 Some(if le {
528 u32::from_le_bytes(raw)
529 } else {
530 u32::from_be_bytes(raw)
531 })
532}
533
534fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
535 let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
536 Some(if le {
537 u64::from_le_bytes(raw)
538 } else {
539 u64::from_be_bytes(raw)
540 })
541}
542
543/// Reads a NUL-terminated string starting at the beginning of `region` (used for both the
544/// `PT_INTERP` segment and dynamic-string-table entries).
545fn cstr_at_start(region: &[u8]) -> String {
546 let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
547 String::from_utf8_lossy(®ion[..nul]).into_owned()
548}
549
550/// Maps a virtual address into a file offset using the ELF's `PT_LOAD` segments — required
551/// because `DT_STRTAB` records the string table's virtual address, not its file offset.
552fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
553 for &(seg_vaddr, seg_off, seg_filesz) in loads {
554 if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
555 return Some(seg_off + (vaddr - seg_vaddr));
556 }
557 }
558 None
559}
560
561/// Pure ELF64 parser: extracts the dynamic-linking metadata that determines which extra files the
562/// loader touches. Returns `None` for anything that is not a well-formed ELF64 image (bad magic,
563/// 32-bit, truncated headers) — a non-ELF or unparseable binary simply contributes nothing to the
564/// grant set (shrink-not-fail). Deliberately hand-rolled (no crate dependency) and byte-slice-in /
565/// struct-out so it is fully unit-testable off-Linux with synthetic fixtures; it makes no syscalls.
566///
567/// Only ELF64 is parsed: both supported Linux targets (`x86_64`, `aarch64`) are 64-bit, so a
568/// 32-bit image is not a binary this runtime would exec and is treated as unparseable.
569pub(crate) fn parse_elf_dependencies(bytes: &[u8]) -> Option<ElfDependencies> {
570 // e_ident (16) + fixed ELF64 header fields extend to offset 64.
571 if bytes.len() < 64 || &bytes[0..4] != b"\x7fELF" {
572 return None;
573 }
574 // EI_CLASS: 2 == ELFCLASS64. EI_DATA: 1 == little-endian, 2 == big-endian.
575 if bytes[4] != 2 {
576 return None;
577 }
578 let le = match bytes[5] {
579 1 => true,
580 2 => false,
581 _ => return None,
582 };
583
584 let e_phoff = read_u64(bytes, 32, le)? as usize;
585 let e_phentsize = read_u16(bytes, 54, le)? as usize;
586 let e_phnum = read_u16(bytes, 56, le)? as usize;
587 // Each Elf64_Phdr is 56 bytes; a smaller entsize is not this layout.
588 if e_phentsize < 56 {
589 return None;
590 }
591
592 let mut interp: Option<String> = None;
593 let mut dynamic_region: Option<(u64, u64)> = None; // (file offset, size)
594 let mut loads: Vec<(u64, u64, u64)> = Vec::new(); // (p_vaddr, p_offset, p_filesz)
595
596 for i in 0..e_phnum {
597 let base = e_phoff.checked_add(i.checked_mul(e_phentsize)?)?;
598 let p_type = read_u32(bytes, base, le)?;
599 let p_offset = read_u64(bytes, base + 8, le)?;
600 let p_vaddr = read_u64(bytes, base + 16, le)?;
601 let p_filesz = read_u64(bytes, base + 32, le)?;
602 match p_type {
603 1 => loads.push((p_vaddr, p_offset, p_filesz)), // PT_LOAD
604 2 => dynamic_region = Some((p_offset, p_filesz)), // PT_DYNAMIC
605 3 => {
606 // PT_INTERP: a NUL-terminated interpreter path in the file image.
607 let start = p_offset as usize;
608 let end = start.checked_add(p_filesz as usize)?;
609 interp = Some(cstr_at_start(bytes.get(start..end)?));
610 }
611 _ => {}
612 }
613 }
614
615 // A static binary (no PT_DYNAMIC) contributes only itself — no interp, no needed libs.
616 let Some((dyn_off, dyn_size)) = dynamic_region else {
617 return Some(ElfDependencies {
618 interp,
619 needed: Vec::new(),
620 runpaths: Vec::new(),
621 });
622 };
623
624 let mut needed_offsets: Vec<u64> = Vec::new();
625 let mut runpath_offsets: Vec<u64> = Vec::new();
626 let mut strtab_vaddr: Option<u64> = None;
627 let mut strsz: Option<u64> = None;
628
629 let mut off = dyn_off as usize;
630 let dyn_end = off.checked_add(dyn_size as usize)?;
631 while off + 16 <= dyn_end {
632 let d_tag = read_u64(bytes, off, le)?;
633 let d_val = read_u64(bytes, off + 8, le)?;
634 match d_tag {
635 0 => break, // DT_NULL — end of dynamic array
636 1 => needed_offsets.push(d_val), // DT_NEEDED
637 5 => strtab_vaddr = Some(d_val), // DT_STRTAB (virtual address)
638 10 => strsz = Some(d_val), // DT_STRSZ
639 15 | 29 => runpath_offsets.push(d_val), // DT_RPATH | DT_RUNPATH
640 _ => {}
641 }
642 off += 16;
643 }
644
645 let mut needed = Vec::new();
646 let mut runpaths = Vec::new();
647 if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
648 if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
649 let start = strtab_off as usize;
650 // Prefer the declared string-table bounds, but tolerate an over-long DT_STRSZ by
651 // falling back to the rest of the file rather than dropping every soname.
652 let strtab = start
653 .checked_add(strsz as usize)
654 .and_then(|end| bytes.get(start..end))
655 .or_else(|| bytes.get(start..));
656 if let Some(strtab) = strtab {
657 for offset in needed_offsets {
658 if let Some(region) = strtab.get(offset as usize..) {
659 needed.push(cstr_at_start(region));
660 }
661 }
662 for offset in runpath_offsets {
663 if let Some(region) = strtab.get(offset as usize..) {
664 for part in cstr_at_start(region).split(':') {
665 if !part.is_empty() {
666 runpaths.push(part.to_string());
667 }
668 }
669 }
670 }
671 }
672 }
673 }
674
675 Some(ElfDependencies {
676 interp,
677 needed,
678 runpaths,
679 })
680}
681
682/// Expands a leading/embedded `$ORIGIN` (or `${ORIGIN}`) in an rpath entry to the directory of the
683/// binary that declared it — the dynamic loader's own rule for `DT_RUNPATH`/`DT_RPATH`.
684fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
685 if rpath.contains("$ORIGIN") {
686 let origin = origin_dir.to_string_lossy();
687 PathBuf::from(
688 rpath
689 .replace("${ORIGIN}", &origin)
690 .replace("$ORIGIN", &origin),
691 )
692 } else {
693 PathBuf::from(rpath)
694 }
695}
696
697/// Resolves one `DT_NEEDED` soname to a canonical file path, mirroring the loader's search order:
698/// a soname containing a `/` is taken as a path (absolute, or relative to the binary's directory);
699/// otherwise the binary's own `DT_RUNPATH`/`DT_RPATH` entries (with `$ORIGIN` expanded) are tried
700/// first, then the standard `search_dirs`. Returns `None` (shrink-not-fail) if nothing matches.
701pub(crate) fn resolve_soname(
702 soname: &str,
703 origin_dir: &Path,
704 runpaths: &[String],
705 search_dirs: &[PathBuf],
706) -> Option<PathBuf> {
707 if soname.is_empty() {
708 return None;
709 }
710 if soname.contains('/') {
711 let candidate = if Path::new(soname).is_absolute() {
712 PathBuf::from(soname)
713 } else {
714 origin_dir.join(soname)
715 };
716 return std::fs::canonicalize(&candidate).ok();
717 }
718 for rpath in runpaths {
719 let candidate = expand_origin(rpath, origin_dir).join(soname);
720 if candidate.is_file() {
721 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
722 return Some(canonical);
723 }
724 }
725 }
726 for dir in search_dirs {
727 let candidate = dir.join(soname);
728 if candidate.is_file() {
729 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
730 return Some(canonical);
731 }
732 }
733 }
734 None
735}
736
737/// Derives the full set of canonical file paths that need a narrow read+execute Landlock grant
738/// *outside* the workdir so the `shell.allow` binaries can actually run: each allowlisted binary,
739/// its ELF interpreter, and the transitive closure of its `DT_NEEDED` shared libraries. Uses the
740/// standard [`DEFAULT_LIBRARY_SEARCH_DIRS`] as the soname fallback search set.
741///
742/// Every step is shrink-not-fail (a binary that can't be read/parsed, or a soname that doesn't
743/// resolve, contributes nothing further); the returned set is deduplicated and canonical. Runs in
744/// the parent at launch time — never in the forked child's `pre_exec`.
745pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
746 let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
747 .iter()
748 .map(PathBuf::from)
749 .collect();
750 resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
751}
752
753/// Testable core of [`resolve_landlock_grants`], with the library search directories injected so
754/// unit tests can point it at synthetic fixtures instead of the real system directories.
755fn resolve_landlock_grants_in(
756 exec_allow_paths: &[PathBuf],
757 search_dirs: &[PathBuf],
758) -> Vec<PathBuf> {
759 let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
760 let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
761 let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
762
763 for path in exec_allow_paths {
764 // The allowlist paths are already canonical/existing (from `resolve_exec_allowlist`), but
765 // canonicalize defensively — this both makes dedup against interp/library paths exact and
766 // drops any seed that no longer exists (shrink-not-fail: a vanished binary contributes
767 // nothing, and could not be exec'd anyway).
768 if let Ok(canonical) = std::fs::canonicalize(path) {
769 queue.push_back(canonical);
770 }
771 }
772
773 while let Some(path) = queue.pop_front() {
774 if !visited.insert(path.clone()) {
775 continue;
776 }
777 // The binary/library itself is granted. If it lives inside the workdir, the workdir's own
778 // full-access rule already covers it and this extra read+execute rule is simply redundant.
779 grants.insert(path.clone());
780
781 let Ok(bytes) = std::fs::read(&path) else {
782 continue;
783 };
784 let Some(deps) = parse_elf_dependencies(&bytes) else {
785 continue;
786 };
787 let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
788
789 if let Some(interp) = &deps.interp {
790 if let Ok(canonical) = std::fs::canonicalize(interp) {
791 queue.push_back(canonical);
792 }
793 }
794 for soname in &deps.needed {
795 if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
796 {
797 queue.push_back(resolved);
798 }
799 }
800 }
801
802 grants.into_iter().collect()
803}
804
805/// One resolved Landlock filesystem grant outside the workdir: a canonical path plus the two
806/// independent bits that decide the rule's access set.
807///
808/// `list_dir` decides enumerability (`ReadDir`):
809///
810/// - `false` → no `getdents64` on the granted directory itself. Landlock's read rights apply to
811/// the whole subtree beneath a granted directory, so a file *inside* it can still be opened by
812/// its exact name — the listing is what is denied. This is what every derived
813/// `DT_NEEDED`-closure grant gets — they are individual files, where `ReadDir` was always a
814/// no-op anyway (Landlock's `ReadDir` only has meaning on a directory inode).
815/// - `true` → adds enumerability, which a path-based interpreter's import machinery needs on
816/// each `sys.path` entry (CPython's `FileFinder` `listdir`-caches each one). Set by an author
817/// writing `list_dir: true` next to a specific `interpreter_runtime` directory, and by the
818/// whole-tree resolvers ([`resolve_staged_runtime_landlock_grants`],
819/// [`resolve_sealed_runtime_landlock_grants`] and, per entry, the directory half of
820/// [`resolve_sealed_etc_landlock_grants`]) where a tree is walked by definition — never
821/// inferred from anything else, and never applied to an ancestor or sibling of a granted
822/// directory.
823///
824/// `ReadDir` is meaningful only on a directory, and the kernel enforces that: `landlock_add_rule`
825/// returns `EINVAL` for a directory-only right on a non-directory. `open_landlock_fds` therefore
826/// narrows `list_dir` to `false` for any grant whose fd does not `fstat` as a directory, so this
827/// field is a request rather than a promise.
828///
829/// `executable` decides `Execute`, which is this runtime's exec allowlist: a path without an
830/// `Execute` rule cannot be `execve`d, and a path with one can. This is the canonical statement of
831/// that rule; the grant resolvers below cross-reference it rather than restating it.
832///
833/// - `true` → `Execute + ReadFile`, for a path the manifest asked to *run*: a `shell.allow`
834/// binary, its `DT_NEEDED` closure, and the interpreter/staged trees an author named.
835/// - `false` → `ReadFile` only (readable, and with `list_dir` enumerable, but not runnable).
836/// For a grant the *tier* issues rather than the manifest: see
837/// [`resolve_sealed_runtime_landlock_grants`], where granting `Execute` over `/usr`, `/bin`
838/// and `/sbin` wholesale would turn `shell.allow` into a no-op on `sealed`, and
839/// [`resolve_sealed_etc_landlock_grants`], where `/etc/alternatives` is a directory of
840/// symlinks into exactly those trees.
841///
842/// Affects `execve` only: a shared object mapped `PROT_EXEC` by `dlopen(3)` needs `ReadFile`, not
843/// `Execute`, so an interpreter still loads its C extensions out of an `executable: false` tree.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub(crate) struct LandlockGrant {
846 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
847 pub(crate) path: PathBuf,
848 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
849 pub(crate) list_dir: bool,
850 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
851 pub(crate) executable: bool,
852}
853
854impl LandlockGrant {
855 /// Wraps each derived `DT_NEEDED`-closure file path as a non-listable grant. A regular file
856 /// has no meaningful `ReadDir`, so `list_dir: false` costs nothing. `executable: true`: this
857 /// closure is exactly the set of paths `shell.allow` asked to run — see [`LandlockGrant`].
858 fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
859 paths
860 .into_iter()
861 .map(|path| LandlockGrant {
862 path,
863 list_dir: false,
864 executable: true,
865 })
866 .collect()
867 }
868}
869
870/// Turns each `capabilities.shell.interpreter_runtime` directory into a [`LandlockGrant`] carrying
871/// exactly the `list_dir` its author wrote. Pure and syscall-free (the declared paths need not
872/// exist here — `apply_landlock_scope` skips any that fail to open), so it is unit-testable on
873/// every platform, including non-Linux targets.
874///
875/// There is deliberately no path here that could widen a grant to a whole install prefix: it emits
876/// one grant per explicitly declared directory and copies the author's `list_dir` verbatim,
877/// inferring enumerability from nothing.
878pub(crate) fn resolve_interpreter_runtime_grants(
879 grants: &[InterpreterRuntimeGrant],
880) -> Vec<LandlockGrant> {
881 grants
882 .iter()
883 .flat_map(|grant| {
884 grant.dirs.iter().map(|dir| LandlockGrant {
885 path: PathBuf::from(&dir.path),
886 list_dir: dir.list_dir,
887 // Executable: an `interpreter_runtime` directory is named by an author precisely so
888 // the interpreter it belongs to can run out of it, helper binaries included.
889 executable: true,
890 })
891 })
892 .collect()
893}
894
895/// The host directories a composed root must carry *beyond* [`crate::sealed::SEALED_RUNTIME_PATHS`]
896/// for this capsule's `shell.allow` to keep working: the containing directory of each resolved
897/// allowlisted binary, and each declared `interpreter_runtime` directory.
898///
899/// Whole directories, deduplicated, and only the ones not already inside a fixed runtime path.
900/// This is the deliberate opposite of `resolve_landlock_grants`: no ELF parsing, no `DT_NEEDED`
901/// closure, no per-file grant — a bind mount carries a directory's whole contents, so the closure
902/// derivation `scoped` needs has nothing left to do here.
903///
904/// Every entry is **optional** by design — see `sealed::PlanBuilder::mirror`. A host missing one
905/// makes the composed root narrower rather than refusing the launch, which is why
906/// `capabilities.shell.staged_runtime` does *not* come through here: a declared runtime tree must
907/// fail closed, so it is resolved separately by [`resolve_staged_runtime_dirs`] and planned as a
908/// required bind. This function is additive to that, not replaced by it.
909///
910/// Pure and syscall-free, so it is unit-testable on any platform.
911pub(crate) fn resolve_sealed_bind_dirs(
912 exec_allow_paths: &[PathBuf],
913 policy: &CapabilityPolicy,
914) -> Vec<PathBuf> {
915 let fixed: Vec<&Path> = crate::sealed::SEALED_RUNTIME_PATHS
916 .iter()
917 .map(Path::new)
918 .collect();
919
920 let mut dirs: Vec<PathBuf> = Vec::new();
921 let candidates = exec_allow_paths
922 .iter()
923 .filter_map(|binary| binary.parent().map(Path::to_path_buf))
924 .chain(
925 policy
926 .shell_interpreter_runtime
927 .iter()
928 .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
929 );
930
931 for candidate in candidates {
932 if !candidate.is_absolute() {
933 continue;
934 }
935 if fixed.iter().any(|root| candidate.starts_with(root)) {
936 continue;
937 }
938 if dirs.contains(&candidate) {
939 continue;
940 }
941 dirs.push(candidate);
942 }
943 dirs
944}
945
946/// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, as the composed
947/// root's *required* read-only binds.
948///
949/// Deliberately not folded into [`resolve_sealed_bind_dirs`], and deliberately not filtered the
950/// way that function filters. Both differences follow from the same fact — these fail closed:
951///
952/// * A path already inside [`crate::sealed::SEALED_RUNTIME_PATHS`] is **not** dropped here.
953/// Dropping it would be sound for an optional bind (the fixed list already covers it) but it
954/// would quietly convert a required grant into a dependency on an unrelated list staying the
955/// way it is today. `plan_composed_root` runs the required loop first and deduplicates by
956/// target, so naming an already-covered path costs one registration and stays required.
957/// * A non-existent path is **not** filtered out, here or anywhere. The whole point is that a
958/// grant naming a tree this host does not have refuses the launch — see
959/// `sealed::PlanBuilder::require_bind`.
960///
961/// Relative paths are still skipped: a relative `source_path` cannot be re-based under the
962/// composed root's base at all, so it names nothing to fail *about*. The manifest parser is the
963/// layer that rejects one.
964///
965/// Pure and syscall-free, so it is unit-testable on any platform.
966pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
967 let mut dirs: Vec<PathBuf> = Vec::new();
968 for grant in &policy.shell_staged_runtime {
969 let path = PathBuf::from(&grant.source_path);
970 if !path.is_absolute() || dirs.contains(&path) {
971 continue;
972 }
973 dirs.push(path);
974 }
975 dirs
976}
977
978/// One listable [`LandlockGrant`] per staged runtime directory.
979///
980/// The bind mount alone does not make a staged tree usable, and the reason is easy to miss: the
981/// composed root is not the only thing standing between the subprocess and a path. `sealed` still
982/// installs Landlock *inside* the root as defence in depth (see `applied_tier` and the
983/// `KernelSealed` arm of `prepare_enforcement`), and Landlock denies any path with no matching
984/// rule. A staged tree that is bind-mounted but ungranted is therefore present, read-only, and
985/// unreadable — `open()` returns `EACCES` — which is indistinguishable from the capability not
986/// working at all.
987///
988/// So a grant is emitted here for exactly the same directories [`resolve_staged_runtime_dirs`]
989/// binds, and the two are resolved from the same list so they cannot drift apart. `list_dir: true`
990/// is not configurable, unlike `interpreter_runtime`'s per-directory flag: a staged tree is a whole
991/// runtime a program walks (an interpreter enumerating its stdlib, a toolchain resolving its
992/// libexec), and the author already made the enumerability decision by naming the tree. Landlock
993/// rules attach to the opened inode, so a rule taken on the host path in the parent covers the
994/// same inode reached through the bind inside the root.
995///
996/// Read and execute only — never write. The bind is `MS_RDONLY` regardless, so this is the second
997/// of two independent reasons the tree cannot be mutated from inside a session.
998///
999/// Pure and syscall-free, so it is unit-testable on any platform.
1000pub(crate) fn resolve_staged_runtime_landlock_grants(
1001 policy: &CapabilityPolicy,
1002) -> Vec<LandlockGrant> {
1003 resolve_staged_runtime_dirs(policy)
1004 .into_iter()
1005 .map(|path| LandlockGrant {
1006 path,
1007 list_dir: true,
1008 executable: true,
1009 })
1010 .collect()
1011}
1012
1013/// One listable [`LandlockGrant`] per [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, and only on
1014/// `KernelSealed`.
1015///
1016/// `plan_composed_root` bind-mounts `/usr`, `/bin`, `/sbin`, `/lib`… read-only into every composed
1017/// root, but the Landlock ruleset installed inside that root denies any path with no matching rule.
1018/// The `shell.allow` ELF closure grants those files one at a time and non-listably, so a binary
1019/// opens by exact name while the directory holding it cannot be enumerated — enough to start an
1020/// interpreter, not enough to run it: CPython dies in `init_fs_encoding` because it cannot
1021/// `getdents64` `/usr/lib/python3.N` to find `encodings`. This grant makes the bound tree
1022/// enumerable.
1023///
1024/// `list_dir: true` is not configurable: a whole runtime tree a program walks is not a
1025/// case-by-case author choice. Writes are moot — the binds are `MS_RDONLY`.
1026///
1027/// `executable: false` (see [`LandlockGrant`]): this is the only grant covering whole host trees
1028/// the manifest never named, so it must enumerate them without becoming permission to run them.
1029///
1030/// Sealed-tier only: on `KernelFull` (where `scoped` runs) Landlock applies over the real host
1031/// filesystem, so `ReadDir` on `/usr` would expose host directory shape; under `KernelSealed` the
1032/// tree is a private read-only bind holding only what was staged, so enumerating it reveals
1033/// nothing. Empty on every other tier.
1034///
1035/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail
1036/// to open), so unit-testable on every platform.
1037pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1038 if tier != EnforcementTier::KernelSealed {
1039 return Vec::new();
1040 }
1041 crate::sealed::SEALED_RUNTIME_PATHS
1042 .iter()
1043 .map(|path| LandlockGrant {
1044 path: PathBuf::from(path),
1045 list_dir: true,
1046 executable: false,
1047 })
1048 .collect()
1049}
1050
1051/// One read [`LandlockGrant`] per [`crate::sealed::SEALED_ETC_PATHS`] entry, and only on
1052/// `KernelSealed`.
1053///
1054/// Exactly the same shape of bug as [`resolve_sealed_runtime_landlock_grants`], one directory over:
1055/// `plan_composed_root` bind-mounts the fixed `/etc` allowlist read-only into every composed root,
1056/// and the Landlock ruleset installed inside that root then denies every one of them, because a
1057/// path with no matching rule is denied. The mount is present and unopenable — the worst of both
1058/// answers. Most visibly this is the TLS trust store: `load_verify_locations` on
1059/// `/etc/ssl/certs/ca-certificates.crt` returns `EACCES`, so `pip`, `curl`, `wget` and `git` all
1060/// fail certificate verification over an otherwise-working TCP connection.
1061///
1062/// `list_dir` is per entry, taken from the `directory` bit each [`crate::sealed::SEALED_EtcPath`]
1063/// carries (see [`crate::sealed::SealedEtcPath`]) — unlike the runtime-path list, this one mixes
1064/// directories, regular files and symlinks.
1065/// Directories get `ReadDir` because enumeration is how the trust store is actually consumed
1066/// (`c_rehash` symlink lookup, `os.listdir('/etc/ssl/certs')`, `SSL_CERT_DIR`); files and symlinks
1067/// get `ReadFile` only, the same convention as [`LandlockGrant::non_listable_files`].
1068///
1069/// `executable: false` throughout (see [`LandlockGrant`] for what `Execute` decides). Nothing in
1070/// `/etc` is a program a manifest asked to run, and `/etc/alternatives` in particular is a
1071/// directory of symlinks *into* `/usr/bin`, so granting `Execute` here would be a second,
1072/// undeclared route to the exec bypass `resolve_sealed_runtime_landlock_grants` withholds it to
1073/// prevent.
1074///
1075/// No write right, ever: the binds are `MS_RDONLY`, and `/etc/resolv.conf` or `/etc/hosts` becoming
1076/// writable inside a capsule would be a name-resolution hijack of the capsule's own egress.
1077///
1078/// Sealed-tier only, for the same reason as the runtime-path grant: on `KernelFull` — the tier a
1079/// `scoped` capsule runs on — Landlock applies over the real host `/etc`, where these rules would
1080/// hand out the host's own trust store, `resolv.conf` and account databases. Under `KernelSealed`
1081/// the paths resolve inside a private read-only bind of an allowlist that was curated precisely so
1082/// reading it reveals nothing sensitive. Empty on every other tier.
1083///
1084/// Pure and syscall-free (paths need not exist here — `apply_landlock_scope` skips any that fail to
1085/// open), so unit-testable on every platform.
1086pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1087 if tier != EnforcementTier::KernelSealed {
1088 return Vec::new();
1089 }
1090 crate::sealed::SEALED_ETC_PATHS
1091 .iter()
1092 .map(|entry| LandlockGrant {
1093 path: PathBuf::from(entry.path),
1094 list_dir: entry.directory,
1095 executable: false,
1096 })
1097 .collect()
1098}
1099
1100// ---- fixed capsule device set ------------------------------------------------------------
1101//
1102// Everything above derives its grants from the manifest: `shell.allow` binaries and their
1103// `DT_NEEDED` closure, `interpreter_runtime` directories. The device set below derives from
1104// nothing. It is a fixed property of the sandbox, like `linux_enforce::WORKDIR_ACCESS_RIGHTS`,
1105// present on every capsule that reaches `KernelFull` — there is no manifest key that adds a
1106// device, and none that takes one away.
1107//
1108// Why it has to exist at all: once `restrict_self()` lands, a path outside the workdir with no
1109// matching rule is denied, and `/dev/null` is outside every workdir. Programs treat `/dev/null`
1110// as infallible — `open("/dev/null")` failing is a case almost nothing handles — so without an
1111// explicit rule an ordinary tool dies in a way that reads as a runtime bug, not a policy denial.
1112
1113/// One fixed device path granted to every `KernelFull` capsule, with the access level it gets.
1114///
1115/// Deliberately *not* a [`LandlockGrant`]: that type means "an executable or library path the
1116/// manifest asked for, granted read(+list)+execute, never write". A device is a different intent
1117/// (I/O access to a character device, possibly writable) and a different lifetime (fixed at
1118/// compile time, not resolved per capsule), so it gets its own type rather than overloading
1119/// `list_dir` into a third meaning.
1120///
1121/// `writable` is the whole reason this type exists as more than a path list:
1122///
1123/// - `false` → `ReadFile` only. The device is readable and nothing more.
1124/// - `true` → `ReadFile | WriteFile`. Granted to exactly one path, `/dev/null`, and it is the
1125/// only writable path outside the workdir in the entire sandbox.
1126///
1127/// Only `mod linux_enforce` consumes this outside of tests, so on a non-Linux build the type and
1128/// the constant below are dead — the same deliberate cross-platform exception the rest of this
1129/// module's Linux-only surface carries.
1130#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132pub(crate) struct CapsuleDeviceGrant {
1133 pub(crate) path: &'static str,
1134 pub(crate) writable: bool,
1135}
1136
1137/// The complete device set a capsule's shell subprocess tree can reach outside its workdir.
1138/// Three entries, no more — `apply_landlock_scope` adds one `PathBeneath` rule per entry, and
1139/// every device path *not* listed here (`/dev/random`, `/dev/full`, `/dev/tty`, `/dev/sda`, …)
1140/// stays denied by the same mechanism that already denies every other unlisted path: with
1141/// `handle_access` declaring the full ABI v1 right-set, "no rule" means "denied", not "ungoverned".
1142///
1143/// - **`/dev/null` — read *and* write.** This is the one deliberate exception to "nothing
1144/// outside the workdir is writable". A read-only grant would be broken, not merely strict:
1145/// bare-host `strace` shows Python's `subprocess.DEVNULL` opening it `O_RDWR|O_CLOEXEC`, and a
1146/// shell `2>/dev/null` redirect opening it `O_WRONLY|O_CREAT`. Both fail without `WriteFile`,
1147/// and `subprocess.DEVNULL` is reachable from any Python tool a capsule might run. Granting
1148/// write on a character device whose write side is defined to discard everything gives up no
1149/// confidentiality and no integrity — there is no state behind it to reach.
1150/// (The `O_CREAT` in the redirect costs nothing extra: Landlock only checks `MakeReg` when a
1151/// file is actually created, and `/dev/null` already exists.)
1152/// - **`/dev/zero` — read only.** Zero-fill reads and `MAP_PRIVATE` anonymous-mapping fallbacks
1153/// in older allocators. Writes to it are discarded like `/dev/null`'s, but nothing needs to
1154/// write there, so it does not get `WriteFile`.
1155/// - **`/dev/urandom` — read only.** Not for `getrandom(2)`, which is a syscall needing no
1156/// filesystem grant at all, but because OpenSSL and older glibc paths still `open()` the
1157/// device outright and fall over if they cannot.
1158///
1159/// **Why `/dev/random` is excluded.** Not because writing to it is dangerous: a plain `write()` to
1160/// `/dev/random` mixes bytes into the pool but credits **zero** entropy — crediting requires the
1161/// `RNDADDENTROPY` ioctl, which requires `CAP_SYS_ADMIN`, which the shell child has already
1162/// dropped. It is excluded for a duller reason: since Linux 5.6 `/dev/random` blocks until the CRNG
1163/// is initialized and `/dev/urandom` does not, and no workload needs the blocking variant when the
1164/// non-blocking one is granted. It stays out because nothing has demonstrated a need for it.
1165///
1166/// Widening this list is allowed but must be evidence-driven: add a fourth entry only after a real
1167/// workload has been observed failing on the missing device, and record that failure in the same
1168/// place this list is documented (`docs/content/reference/capsule-device-set-manual-verification.md`).
1169///
1170/// **Future shape.** Enumerating devices path by path is the `scoped`-containment-class answer.
1171/// Once a `sealed` class exists, the capsule gets a private `/dev` tmpfs carrying the OCI default
1172/// device set, and device access stops being a Landlock rule list entirely. A `sealed`
1173/// implementation should *retire* this constant, not merge with it.
1174#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1175pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1176 CapsuleDeviceGrant {
1177 path: "/dev/null",
1178 writable: true,
1179 },
1180 CapsuleDeviceGrant {
1181 path: "/dev/zero",
1182 writable: false,
1183 },
1184 CapsuleDeviceGrant {
1185 path: "/dev/urandom",
1186 writable: false,
1187 },
1188];
1189
1190/// Network decision for one destination IP.
1191/// Empty allowlist denies everything (a capsule that declared no `network.allow` hosts has
1192/// no reason to open any subprocess socket).
1193#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1194pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1195 network_allow_ips.contains(&ip)
1196}
1197
1198// `socket(2)`'s `domain` argument values, as Linux's ABI defines them. Spelled out as literals
1199// rather than `libc::AF_*`: the filter these feed is
1200// always compiled *for a Linux child*, so the numbers must be Linux's regardless of what host the
1201// build runs on — and `denied_socket_domains` has to stay compilable and unit-testable on a macOS
1202// non-Linux targets, where `libc::AF_NETLINK` and `libc::AF_PACKET` do not exist at all (the `libc`
1203// crate defines them only under `linux_like`). `libc::AF_UNIX` does exist on macOS, but taking one
1204// of the three from `libc` and two from literals would be worse than taking all three the same
1205// way. Values are stable kernel ABI: `include/linux/socket.h`.
1206const LINUX_AF_UNIX: i32 = 1;
1207const LINUX_AF_INET: i32 = 2;
1208const LINUX_AF_INET6: i32 = 10;
1209const LINUX_AF_NETLINK: i32 = 16;
1210const LINUX_AF_PACKET: i32 = 17;
1211
1212/// The `socket(2)` domains the child's seccomp filter refuses outright, given the capsule's
1213/// `capabilities.network.unix_sockets` declaration.
1214///
1215/// This is the *whole* policy decision behind the `socket()` rule in `install_seccomp_filter` —
1216/// deliberately pure, `libseccomp`-free, and platform-independent so it can be tested on any dev
1217/// machine (same split as `resolve_landlock_grants` vs. `apply_landlock_scope`).
1218///
1219/// Why a domain check is a classic BPF rule and not a notify:
1220/// `socket()`'s `domain` is a plain integer in a register, so the kernel's own BPF can compare it
1221/// directly. That is exactly what `connect()`'s destination address is *not* — it sits behind a
1222/// pointer BPF cannot dereference, so deciding on it needs a userspace round-trip that reads the
1223/// calling task's memory (see this module's header). Denying the domain at *creation* time needs
1224/// no round-trip, and is structurally immune to the TOCTOU class of problem that reading a
1225/// pointed-to argument out of another task's memory invites.
1226///
1227/// The three families, and why they are not symmetric:
1228/// - `AF_UNIX` is gated, not banned. `/var/run/docker.sock` is host root, so it cannot be open
1229/// by default — but "talk to a local daemon socket" is a legitimate thing for an agent to
1230/// need, so a capsule can declare `capabilities.network.unix_sockets: true` and take it back.
1231/// Note this is coarse: the opt-in is per *capsule*, per *domain* — not per socket path.
1232/// - `AF_NETLINK` (routing tables, interface and firewall state) and `AF_PACKET` (raw frame
1233/// capture and injection) are denied unconditionally, with no manifest key to widen them.
1234/// Neither has a shell-tool use case comparable to a local daemon socket, and both are
1235/// strictly more dangerous than one. Widening them would be a deliberate future decision with
1236/// its own justification, not an oversight to be fixed by adding a flag here.
1237///
1238/// `AF_INET`/`AF_INET6` are never denied here: they stay governed by the capsule's own network
1239/// namespace and egress proxy against `capabilities.network.allow`. This function only ever
1240/// *adds* denials, and is paired with [`allowed_socket_domains`], which names the families that
1241/// get a positive rule — since the filter's default action is a deny, a family named by neither
1242/// function is refused with the filter default (`EPERM`) rather than this rule's `EACCES`.
1243#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1244pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1245 let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1246 if !unix_sockets_allowed {
1247 denied.push(LINUX_AF_UNIX);
1248 }
1249 denied
1250}
1251
1252/// The `socket(2)` domains the child's seccomp filter permits, given the capsule's
1253/// `capabilities.network.unix_sockets` declaration. The exact complement of
1254/// [`denied_socket_domains`] over the families anything in a shell toolchain actually opens.
1255///
1256/// This exists because the filter's default action is `Errno(EPERM)`, not `Allow`: `socket` needs
1257/// a positive rule or no socket of any family could be created at all. It is deliberately *not*
1258/// expressed as one unconditional `Allow` rule on `socket` — see `install_seccomp_filter` for why
1259/// that would silently delete the [`denied_socket_domains`] rules — and deliberately not as a
1260/// "not any of the denied families" rule either, so the two functions stay a pair of disjoint,
1261/// equality-matched sets that a test can check against each other.
1262///
1263/// The cost of naming families positively is that a family in neither set (`AF_ALG`, `AF_VSOCK`,
1264/// `AF_BLUETOOTH`, `AF_XDP`, ...) is now refused rather than allowed. That is stricter than
1265/// Docker's default profile, which allows every family except `AF_ALG`/`AF_VSOCK`; no shell,
1266/// coreutils, git, compiler or interpreter workload opens one, and the whole point of a
1267/// default-deny filter is that an unenumerated case fails closed. Add a family here (with a
1268/// reason) if a real workload needs it.
1269#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1270pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1271 let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1272 if unix_sockets_allowed {
1273 allowed.push(LINUX_AF_UNIX);
1274 }
1275 allowed
1276}
1277
1278/// `socket(2)` `domain` values for the two IP families, named apart from the bare `LINUX_AF_*`
1279/// numbers above so a rule's argument type reads as a domain rather than as a loose integer.
1280///
1281/// Literals from the same `include/linux/socket.h` list as their neighbours above, for the same
1282/// reason: the filter is always compiled for a Linux child.
1283#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1284const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1285#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1286const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1287
1288/// The syscalls the child's seccomp filter permits outright, modelled on the OCI/Docker default
1289/// seccomp profile (reconciled against `containerd`'s `contrib/seccomp/seccomp_default.go`, which
1290/// is the maintained source of the same list Docker ships).
1291///
1292/// The filter's **default action is a deny** (`Errno(EPERM)`), so this array is the whole of what
1293/// a shell subprocess may call, minus two carve-outs handled by rules rather than by name here:
1294///
1295/// - `socket` is absent: it carries argument-conditional rules (see [`denied_socket_domains`] /
1296/// [`allowed_socket_domains`]), and an unconditional `Allow` rule would discard them.
1297///
1298/// `execve`/`execveat` are ordinary allowed syscalls here: they permit only *reaching the kernel*,
1299/// where the Landlock domain decides the exec on the path it resolved itself (see this module's
1300/// header for why they no longer carry `Notify` rules). Permitting them is not optional — the
1301/// child's very first act after this filter loads is the `execve` that turns it into the tool
1302/// binary, so a denial here refuses every capsule outright.
1303///
1304/// Everything else is refused by the default action. Without this list `io_uring_setup`, `bpf`,
1305/// `userfaultfd`, `perf_event_open`, `open_by_handle_at`, `keyctl` and `ptrace` are all reachable
1306/// from a capsule shell on bare metal, while the same probe under Docker gets `EPERM` for each.
1307/// `io_uring` is the one that
1308/// matters most: it has historically bypassed LSM path hooks, so leaving it reachable undermines
1309/// the Landlock filesystem boundary rather than merely widening the syscall surface. See
1310/// [`SECCOMP_MUST_STAY_DENIED`] for the full set that must never be added back.
1311///
1312/// Names, not numbers: `libseccomp` resolves each one for the architecture the filter is being
1313/// built on. A name this host's `libseccomp` does not know (a syscall newer than the library) or
1314/// that does not exist on this architecture (`open`, `dup2`, `poll`, ... on aarch64) is skipped,
1315/// leaving that syscall denied by default — see `install_seccomp_filter`. That is why legacy
1316/// x86_64-only spellings sit next to their modern equivalents here: on x86_64 glibc uses `open`
1317/// and `stat`, on aarch64 it uses `openat` and `newfstatat`, and the same array serves both.
1318#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1319pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1320 // ---- process / thread lifecycle ----
1321 // `execve`/`execveat` reach the kernel; the Landlock domain decides them. See this array's
1322 // doc comment for the mechanism that replaced their former `Notify` rules.
1323 "execve",
1324 "execveat",
1325 // `clone`/`clone3` are allowed *unconditionally*, which is this filter's one significant
1326 // widening relative to Docker: Docker masks `clone`'s namespace-creation flags with an
1327 // argument comparison and forces `clone3` to `ENOSYS` so glibc falls back to the masked
1328 // `clone`. Reproducing that faithfully is possible for `clone` but pointless in isolation:
1329 // `unshare`/`setns`/`mount`/`pivot_root`/`chroot` all stay denied, so a new namespace stays
1330 // largely inert.
1331 "clone",
1332 "clone3",
1333 "fork",
1334 "vfork",
1335 "exit",
1336 "exit_group",
1337 "wait4",
1338 "waitid",
1339 "kill",
1340 "tkill",
1341 "tgkill",
1342 "rt_sigaction",
1343 "rt_sigprocmask",
1344 "rt_sigreturn",
1345 "rt_sigsuspend",
1346 "rt_sigpending",
1347 "rt_sigtimedwait",
1348 "rt_sigqueueinfo",
1349 "rt_tgsigqueueinfo",
1350 "sigaltstack",
1351 // The kernel re-issues an interrupted, restartable syscall as `restart_syscall`; denying it
1352 // would break every `sleep`/`read` that takes a signal.
1353 "restart_syscall",
1354 "pause",
1355 "set_tid_address",
1356 "set_robust_list",
1357 "get_robust_list",
1358 // glibc 2.35+ registers a restartable sequence on every thread start.
1359 "rseq",
1360 "prctl",
1361 "arch_prctl",
1362 "getpid",
1363 "getppid",
1364 "gettid",
1365 "getpgrp",
1366 "getpgid",
1367 "setpgid",
1368 "getsid",
1369 "setsid",
1370 "getpriority",
1371 "setpriority",
1372 "ioprio_get",
1373 "ioprio_set",
1374 "sched_yield",
1375 "sched_getaffinity",
1376 "sched_setaffinity",
1377 "sched_getparam",
1378 "sched_setparam",
1379 "sched_getscheduler",
1380 "sched_setscheduler",
1381 "sched_getattr",
1382 "sched_setattr",
1383 "sched_get_priority_max",
1384 "sched_get_priority_min",
1385 "sched_rr_get_interval",
1386 "getcpu",
1387 "capget",
1388 "capset",
1389 "prlimit64",
1390 "getrlimit",
1391 "setrlimit",
1392 "getrusage",
1393 "getitimer",
1394 "setitimer",
1395 "times",
1396 "uname",
1397 "sysinfo",
1398 // pidfd process handles: they only ever address a process the caller could already signal.
1399 "pidfd_open",
1400 "pidfd_send_signal",
1401 // ---- memory ----
1402 "mmap",
1403 "munmap",
1404 "mprotect",
1405 "mremap",
1406 "madvise",
1407 "brk",
1408 "mlock",
1409 "mlock2",
1410 "munlock",
1411 "mlockall",
1412 "munlockall",
1413 "membarrier",
1414 "msync",
1415 "mincore",
1416 "memfd_create",
1417 "pkey_alloc",
1418 "pkey_free",
1419 "pkey_mprotect",
1420 "shmget",
1421 "shmat",
1422 "shmdt",
1423 "shmctl",
1424 // ---- file I/O ----
1425 "open",
1426 "openat",
1427 "openat2",
1428 "creat",
1429 "close",
1430 "close_range",
1431 "read",
1432 "write",
1433 "pread64",
1434 "pwrite64",
1435 "readv",
1436 "writev",
1437 "preadv",
1438 "pwritev",
1439 "preadv2",
1440 "pwritev2",
1441 "lseek",
1442 "dup",
1443 "dup2",
1444 "dup3",
1445 "fcntl",
1446 "flock",
1447 "fsync",
1448 "fdatasync",
1449 "sync",
1450 "syncfs",
1451 "sync_file_range",
1452 "fadvise64",
1453 "readahead",
1454 "truncate",
1455 "ftruncate",
1456 "fallocate",
1457 "stat",
1458 "fstat",
1459 "lstat",
1460 "newfstatat",
1461 "statx",
1462 "access",
1463 "faccessat",
1464 "faccessat2",
1465 "getdents",
1466 "getdents64",
1467 "getcwd",
1468 "chdir",
1469 "fchdir",
1470 "mkdir",
1471 "mkdirat",
1472 "rmdir",
1473 "unlink",
1474 "unlinkat",
1475 "rename",
1476 "renameat",
1477 "renameat2",
1478 "link",
1479 "linkat",
1480 "symlink",
1481 "symlinkat",
1482 "readlink",
1483 "readlinkat",
1484 "chmod",
1485 "fchmod",
1486 "fchmodat",
1487 // glibc 2.39+ reaches for `fchmodat2` first; it has no `EPERM` fallback path, only an
1488 // `ENOSYS` one, so denying it would surface as a hard `chmod` failure.
1489 "fchmodat2",
1490 "chown",
1491 "fchown",
1492 "fchownat",
1493 "lchown",
1494 "umask",
1495 "utime",
1496 "utimes",
1497 "utimensat",
1498 "futimesat",
1499 // `mknod`/`mknodat` are allowed at the syscall layer and denied where the actual escape
1500 // lives: the Landlock workdir grant withholds `MakeChar`/`MakeBlock`/`MakeSock` (see
1501 // `linux_enforce::WORKDIR_ACCESS_RIGHTS`) and the child drops `CAP_MKNOD` before execve.
1502 "mknod",
1503 "mknodat",
1504 "sendfile",
1505 "copy_file_range",
1506 "splice",
1507 "tee",
1508 "vmsplice",
1509 "ioctl",
1510 "poll",
1511 "ppoll",
1512 "select",
1513 "pselect6",
1514 "epoll_create",
1515 "epoll_create1",
1516 "epoll_ctl",
1517 "epoll_wait",
1518 "epoll_pwait",
1519 "epoll_pwait2",
1520 "eventfd",
1521 "eventfd2",
1522 "pipe",
1523 "pipe2",
1524 "inotify_init",
1525 "inotify_init1",
1526 "inotify_add_watch",
1527 "inotify_rm_watch",
1528 "signalfd",
1529 "signalfd4",
1530 "timerfd_create",
1531 "timerfd_settime",
1532 "timerfd_gettime",
1533 "getxattr",
1534 "lgetxattr",
1535 "fgetxattr",
1536 "setxattr",
1537 "lsetxattr",
1538 "fsetxattr",
1539 "listxattr",
1540 "llistxattr",
1541 "flistxattr",
1542 "removexattr",
1543 "lremovexattr",
1544 "fremovexattr",
1545 "statfs",
1546 "fstatfs",
1547 // Harmless without its partner: `open_by_handle_at` (the syscall that turns a handle back
1548 // into an open file, ignoring the path it was reached by) stays denied.
1549 "name_to_handle_at",
1550 // Legacy POSIX AIO. Unlike `io_uring` it operates only on already-open fds and performs no
1551 // path resolution of its own, so it carries none of the LSM-bypass property that got the
1552 // `io_uring_*` family denied.
1553 "io_setup",
1554 "io_destroy",
1555 "io_submit",
1556 "io_cancel",
1557 "io_getevents",
1558 // ---- sockets ----
1559 // `socket` is NOT here (argument-conditional rules — see this array's doc comment).
1560 //
1561 // `connect`/`sendto` ARE here: allowing them permits only *reaching the kernel*, where the
1562 // capsule's own network namespace has no route to anything except the egress proxy. The
1563 // destination check lives there, not in this filter — see `crate::network_namespace`.
1564 "connect",
1565 "sendto",
1566 "socketpair",
1567 "bind",
1568 "listen",
1569 "accept",
1570 "accept4",
1571 "getsockname",
1572 "getpeername",
1573 "getsockopt",
1574 "setsockopt",
1575 "shutdown",
1576 "sendmsg",
1577 "sendmmsg",
1578 "recvfrom",
1579 "recvmsg",
1580 "recvmmsg",
1581 // ---- time ----
1582 "clock_gettime",
1583 "clock_getres",
1584 "clock_nanosleep",
1585 "nanosleep",
1586 "gettimeofday",
1587 "time",
1588 "timer_create",
1589 "timer_settime",
1590 "timer_gettime",
1591 "timer_getoverrun",
1592 "timer_delete",
1593 "alarm",
1594 // ---- System V / POSIX IPC ----
1595 "msgget",
1596 "msgsnd",
1597 "msgrcv",
1598 "msgctl",
1599 "semget",
1600 "semop",
1601 "semctl",
1602 "semtimedop",
1603 "mq_open",
1604 "mq_unlink",
1605 "mq_timedsend",
1606 "mq_timedreceive",
1607 "mq_notify",
1608 "mq_getsetattr",
1609 // ---- misc ----
1610 "getrandom",
1611 "futex",
1612 "futex_waitv",
1613 "getuid",
1614 "geteuid",
1615 "getgid",
1616 "getegid",
1617 "setuid",
1618 "setgid",
1619 "setreuid",
1620 "setregid",
1621 "setresuid",
1622 "setresgid",
1623 "getresuid",
1624 "getresgid",
1625 "getgroups",
1626 "setgroups",
1627 "setfsuid",
1628 "setfsgid",
1629];
1630
1631/// The syscalls that must **never** appear in [`SECCOMP_SYSCALL_ALLOWLIST`], with the reason each
1632/// one is out. A `#[test]` asserts the two lists stay disjoint, so re-permitting one of these can
1633/// only ever be a deliberate edit to *both* lists rather than an unnoticed line added to the
1634/// allowlist while reconciling it against a newer upstream profile.
1635///
1636/// Three groups, and they are not equally negotiable:
1637///
1638/// 1. **Demonstrated reachable** — a probe reached these on bare metal while Docker refused
1639/// them: `io_uring_setup`/`io_uring_enter`/
1640/// `io_uring_register` (historically bypasses LSM path hooks, so it is a candidate route
1641/// around Landlock itself), `bpf`, `userfaultfd`, `perf_event_open`, `open_by_handle_at`,
1642/// `keyctl`/`add_key`/`request_key`, `process_vm_readv`/`process_vm_writev`, and `ptrace`
1643/// (which also has an availability dimension: a probe left wedged in `ptrace_stop` blocked
1644/// `mur` in `futex_wait` for twenty minutes). Docker's default profile actually *allows*
1645/// `ptrace`/`process_vm_readv`/`process_vm_writev` on kernels ≥ 4.8; this filter is
1646/// deliberately stricter there.
1647/// 2. **Privileged / host-state operations** with no shell-toolchain use case: namespace and
1648/// mount manipulation, module loading, reboot, swap, accounting, quota, raw port I/O,
1649/// clock setting, NUMA policy, `fanotify`.
1650/// 3. **Denied instead of argument-conditioned.** Docker allows these only for specific
1651/// argument values, which a per-syscall-name allowlist cannot express and which would
1652/// otherwise have to be routed through a userspace decision this filter no longer makes.
1653/// Omitting them outright costs nothing, because nothing in bash, coreutils, git, or a
1654/// standard compiler/interpreter toolchain needs them: `personality`, `kcmp`, `seccomp`
1655/// itself (a nested filter cannot loosen this one, but it can slow it down for no benefit).
1656// The list is a specification, not an input to the filter build: its one consumer is the
1657// regression test asserting no entry ever reappears in the allowlist. Deleting it would delete
1658// the thing that test checks against.
1659#[allow(dead_code)]
1660pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
1661 // 1. demonstrated reachable on bare metal
1662 "io_uring_setup",
1663 "io_uring_enter",
1664 "io_uring_register",
1665 "bpf",
1666 "userfaultfd",
1667 "perf_event_open",
1668 "process_vm_readv",
1669 "process_vm_writev",
1670 "open_by_handle_at",
1671 "keyctl",
1672 "add_key",
1673 "request_key",
1674 "ptrace",
1675 // 2. privileged / host-state
1676 "mount",
1677 "umount2",
1678 "pivot_root",
1679 "chroot",
1680 "unshare",
1681 "setns",
1682 "init_module",
1683 "finit_module",
1684 "delete_module",
1685 "kexec_load",
1686 "kexec_file_load",
1687 "reboot",
1688 "swapon",
1689 "swapoff",
1690 "acct",
1691 "quotactl",
1692 "nfsservctl",
1693 "iopl",
1694 "ioperm",
1695 "vm86",
1696 "vm86old",
1697 "_sysctl",
1698 "sysfs",
1699 "uselib",
1700 "create_module",
1701 "get_kernel_syms",
1702 "query_module",
1703 "lookup_dcookie",
1704 "clock_adjtime",
1705 "clock_settime",
1706 "settimeofday",
1707 "stime",
1708 "adjtimex",
1709 "set_mempolicy",
1710 "get_mempolicy",
1711 "mbind",
1712 "migrate_pages",
1713 "move_pages",
1714 "fanotify_init",
1715 "fanotify_mark",
1716 // 3. denied instead of argument-conditioned
1717 "seccomp",
1718 "personality",
1719 "kcmp",
1720];
1721
1722/// The largest BPF program `linux_enforce::build_seccomp_filter` may compile to before this
1723/// project treats the filter as having outgrown its headroom. Held against the *measured* export
1724/// of the real filter by a test, not against an estimate: growing
1725/// [`SECCOMP_SYSCALL_ALLOWLIST`] past this fails `cargo test` with the measured count in the
1726/// message, instead of surfacing as a spawn that dies inside `pre_exec` on some host.
1727///
1728/// Set well below [`BPF_MAXINSNS_CEILING`] on purpose. It is a project budget, not a kernel one:
1729/// crossing it is a prompt to re-measure and decide, not evidence that the kernel would refuse.
1730/// `docs/content/reference/seccomp-filter-instruction-budget.md` records the measurement this
1731/// number was chosen against.
1732#[allow(dead_code)]
1733pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
1734
1735/// The kernel's `BPF_MAXINSNS` — the most instructions a *single* seccomp filter may contain.
1736/// A longer program is refused by `seccomp(SECCOMP_SET_MODE_FILTER)` with `EINVAL`, before the
1737/// filter is attached to anything. Measured rather than quoted: a test brackets it by loading
1738/// two filters that straddle it in forked children.
1739#[allow(dead_code)]
1740pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
1741
1742/// The kernel's `MAX_INSNS_PER_PATH` — the most instructions the *whole chain* of filters
1743/// attached to one task may total, counting every filter it already carries plus a small
1744/// per-filter accounting overhead. An attach that would cross it is refused with `ENOMEM`,
1745/// which is the only seccomp path in the kernel that returns that errno. Measured rather than
1746/// quoted: a test stacks filters in a forked child until the refusal arrives.
1747#[allow(dead_code)]
1748pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
1749
1750/// Bundles the resolved, host-independent enforcement inputs for one capsule session.
1751#[derive(Debug, Clone)]
1752pub(crate) struct ShellEnforcement {
1753 pub(crate) tier: EnforcementTier,
1754 /// Every `capabilities.network.allow` host resolved to concrete addresses once, at launch,
1755 /// by [`resolve_network_allowlist_ips`].
1756 ///
1757 /// The narrower of the egress proxy's two checks — the one applied to a destination the
1758 /// capsule reached without ever resolving a name for it (a hardcoded literal). See
1759 /// `egress_proxy::EgressPolicy::allows_connection`, which consults exactly this list through
1760 /// [`network_ip_allowed`].
1761 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1762 pub(crate) network_allow_ips: Vec<IpAddr>,
1763 /// The same allowlist in its *parsed*, name-keyed form — the ordinary path. The egress proxy
1764 /// checks a name when it resolves it and again when the connection to the address that name
1765 /// produced is accepted, which is what a resolved-IP set alone cannot express (one address,
1766 /// many tenants) in either direction.
1767 ///
1768 /// The same `NetworkAllowRule` type and the same parser the WASI-HTTP path uses, so a manifest
1769 /// entry cannot mean one thing to a WASM guest and another to a subprocess.
1770 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1771 pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
1772 /// The TCP ports the capsule's network namespace opens listeners on, derived from the
1773 /// allowlist by `egress_proxy::egress_listen_ports`. A port no allow entry implies gets no
1774 /// listener at all, so a connection to it is refused by the kernel with nothing in userspace
1775 /// consulted — the strongest form the refusal can take.
1776 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1777 pub(crate) egress_tcp_ports: Vec<u16>,
1778 /// `capabilities.network.unix_sockets`, threaded through to the seccomp `socket()` rule.
1779 /// `false` (the default) means the forked child cannot create an `AF_UNIX` socket at all, so
1780 /// a local daemon socket — `/var/run/docker.sock` above all — is unreachable regardless of
1781 /// what any Landlock ABI does or does not mediate for pathname sockets. Consumed only by
1782 /// `linux_enforce::install_seccomp_filter` (via `denied_socket_domains`); resolved on every
1783 /// platform for parity but never read off Linux.
1784 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1785 pub(crate) unix_sockets_allowed: bool,
1786 /// `capabilities.filesystem.workdir_exec`, threaded through to the workdir's own Landlock
1787 /// rule. `false` (the default) withholds the `Execute` right from it, which is what makes
1788 /// `shell_allow` enforceable at all now that the exec supervisor is gone: nothing the capsule
1789 /// writes into its workdir can run, under any name. Consumed only by
1790 /// `linux_enforce::workdir_access_rights` on the Landlock tiers; resolved on every platform
1791 /// for parity but never read off Linux.
1792 ///
1793 /// It has no seccomp counterpart on `KernelSeccompOnly` — without a Landlock domain there is
1794 /// nothing to withhold — which is part of why that tier cannot reach `scoped`.
1795 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1796 pub(crate) workdir_exec: bool,
1797 /// Narrow read (never write) Landlock grants *outside* the workdir so the allowlisted
1798 /// binaries can actually exec, dynamically link, and (for a path-based interpreter) reach their
1799 /// stdlib. Five origins, combined here:
1800 ///
1801 /// - the `DT_NEEDED`-closure files (`shell.allow` binaries, their ELF interpreter, their
1802 /// shared-library closure), from `resolve_landlock_grants` — each wrapped `list_dir: false`
1803 /// (they are individual files, where `ReadDir` was always a no-op);
1804 /// - one grant per `capabilities.shell.interpreter_runtime` directory, from
1805 /// `resolve_interpreter_runtime_grants` — each carrying exactly the `list_dir` its author
1806 /// declared;
1807 /// - one listable grant per `capabilities.shell.staged_runtime` tree, from
1808 /// `resolve_staged_runtime_landlock_grants`;
1809 /// - on `KernelSealed` **only**, one listable but *non-executable* grant per fixed
1810 /// [`crate::sealed::SEALED_RUNTIME_PATHS`] entry, from
1811 /// `resolve_sealed_runtime_landlock_grants` — the composed root's own bind-mounted runtime
1812 /// tree, which nothing else grants;
1813 /// - on `KernelSealed` **only**, one non-executable read grant per fixed
1814 /// [`crate::sealed::SEALED_ETC_PATHS`] entry, from `resolve_sealed_etc_landlock_grants`
1815 /// (`list_dir` per entry, since that list mixes directories and files) — the composed
1816 /// root's `/etc` allowlist, likewise granted by nothing else.
1817 ///
1818 /// The first three carry `executable: true`; the two tier-issued ones do not, and that
1819 /// asymmetry is load-bearing — see [`LandlockGrant`].
1820 ///
1821 /// Resolved once at launch (in the parent) and threaded into the forked child's `pre_exec`,
1822 /// where `apply_landlock_scope` turns each into a per-path `PathBeneath` rule with an access
1823 /// set that depends on `list_dir` and `executable`. Only consulted on `KernelFull` and
1824 /// `KernelSealed`; resolved on every platform for parity but never read off Linux.
1825 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1826 pub(crate) landlock_grants: Vec<LandlockGrant>,
1827 /// Host directories this capsule needs bind-mounted read-only into its composed root *beyond*
1828 /// the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`]: the containing directory of each
1829 /// resolved `shell.allow` binary, and each `capabilities.shell.interpreter_runtime` directory.
1830 ///
1831 /// Whole directories, never files: a bind carries a directory's whole contents, so this must
1832 /// not grow into a second ELF-closure derivation. Consumed only on `KernelSealed`; resolved on
1833 /// every platform for parity, exactly like `landlock_grants` above.
1834 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1835 pub(crate) sealed_bind_dirs: Vec<PathBuf>,
1836 /// The `source_path` of each declared `capabilities.shell.staged_runtime` grant, bind-mounted
1837 /// read-only into the composed root at its own absolute path.
1838 ///
1839 /// Separate from `sealed_bind_dirs` above because the failure semantics are opposite, and that
1840 /// is the capability: `sealed_bind_dirs` shrinks the root when the host is missing something,
1841 /// while a staged tree that cannot be mounted aborts the composed-root construction before
1842 /// `pivot_root`, which surfaces as `RuntimeError::SealedRootConstructionFailed` (`E-RUN-014`)
1843 /// and is session-fatal. A capsule never gets a shell tool call inside a root missing a
1844 /// runtime tree it declared. Consumed only on `KernelSealed`; resolved on every platform for
1845 /// parity, exactly like the two fields above.
1846 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1847 pub(crate) staged_runtime_dirs: Vec<PathBuf>,
1848 /// Fully-resolved OS-level bounds for every subprocess this session spawns — `rlimit(2)`
1849 /// ceilings applied before `execve`, plus the values the cgroup scope below was built from.
1850 /// Unlike everything above it, this is enforced on **every** platform: `setrlimit` is POSIX,
1851 /// so macOS gets the per-process bounds even though it can never get the aggregate (cgroup)
1852 /// half. See [`crate::resources`].
1853 pub(crate) resource_limits: crate::resources::HostResourceLimits,
1854 /// The runtime's own uid task count, measured once here in the parent by
1855 /// `crate::resources::uid_task_count`, in whichever unit this platform's `RLIMIT_NPROC` is
1856 /// enforced against. `RLIMIT_NPROC` is a per-uid limit, so `resource_limits.max_processes` is
1857 /// applied as headroom above this rather than as an absolute ceiling — see
1858 /// `crate::resources::apply_hard_rlimits`. `0` when the host cannot be asked, which makes the
1859 /// declared value apply literally (the tighter reading).
1860 pub(crate) nproc_baseline: u64,
1861 /// The session's cgroup v2 scope, when the host could delegate one (Linux only, and only
1862 /// for capsules that can actually spawn a native subprocess). `None` on macOS always, and on
1863 /// Linux only for capsules with no subprocess capability at all — a Linux capsule that *can*
1864 /// spawn one and could not be given a scope never reaches here, because the launch is
1865 /// refused first with `RuntimeError::CgroupDelegationUnavailable`.
1866 pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
1867 /// The session's periodic workdir-size check. Consulted before every subprocess spawn, so a
1868 /// disk filler stops writing at the first spawn after the ceiling is crossed rather than
1869 /// only at the next agent turn.
1870 pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
1871}
1872
1873impl ShellEnforcement {
1874 /// Resolves tier + network allowlist + canonical exec allowlist once, at launch time.
1875 ///
1876 /// The rlimit half of [`crate::resources`] is resolved here too (it needs nothing but the
1877 /// policy), while the cgroup scope and workdir guard are session-scoped live handles the
1878 /// caller creates and attaches with [`Self::with_host_bounding`].
1879 ///
1880 /// `declared` is the already-combined containment floor for the session. It is what decides
1881 /// whether a sealed-capable host actually installs a composed root — see [`applied_tier`].
1882 ///
1883 /// `probe` is the session's own [`HostProbe`], taken by `runtime::stage_session` and carried
1884 /// on the `StagedSession`. Passed in rather than taken here so the mechanisms this installs
1885 /// are the ones the session's trace already claims.
1886 pub(crate) fn resolve(
1887 policy: &CapabilityPolicy,
1888 declared: murmur_artifact::ContainmentClass,
1889 probe: HostProbe,
1890 ) -> Result<Self, String> {
1891 let tier = applied_tier(probe.tier(), declared);
1892 let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
1893 let network_allow_rules =
1894 crate::network_policy::parse_network_allow_rules(&policy.network_allow)
1895 .map_err(|error| error.to_string())?;
1896 let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
1897 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1898 // The `DT_NEEDED`-closure files (individual files → non-listable) plus one grant
1899 // per author-declared `interpreter_runtime` directory (each with its own `list_dir`). A
1900 // directory not named in the manifest never receives a rule, regardless of what it holds.
1901 let mut landlock_grants =
1902 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
1903 landlock_grants.extend(resolve_interpreter_runtime_grants(
1904 &policy.shell_interpreter_runtime,
1905 ));
1906 // Plus one per staged runtime tree. Without this the tree is bind-mounted into the
1907 // composed root and then denied by the Landlock ruleset installed inside it — see
1908 // `resolve_staged_runtime_landlock_grants`.
1909 landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
1910 // Plus, on `KernelSealed` only, one listable grant per fixed `SEALED_RUNTIME_PATHS`
1911 // directory — the composed root binds them but Landlock inside the root still denies
1912 // enumerating them. Gated on `tier`, not on the manifest: on `KernelFull` (the tier a
1913 // `scoped` capsule runs on) the same grant would expose real host directory shape. The
1914 // resolver applies that gate itself and returns nothing on every other tier.
1915 landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
1916 // And, on `KernelSealed` only, one read grant per fixed `SEALED_ETC_PATHS` entry — the same
1917 // mounted-but-denied bug one directory over, and the reason TLS certificate verification
1918 // failed inside a composed root. Tier-gated by the resolver itself, exactly as above: on
1919 // `KernelFull` these rules would apply to the *host's* /etc.
1920 landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
1921 let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
1922 let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
1923 Ok(Self {
1924 tier,
1925 network_allow_ips,
1926 network_allow_rules,
1927 egress_tcp_ports,
1928 unix_sockets_allowed: policy.unix_sockets_allowed,
1929 workdir_exec: policy.workdir_exec_allowed,
1930 landlock_grants,
1931 sealed_bind_dirs,
1932 staged_runtime_dirs,
1933 resource_limits: policy.resources,
1934 nproc_baseline: crate::resources::uid_task_count().unwrap_or(0),
1935 cgroup_scope: None,
1936 workdir_guard: None,
1937 })
1938 }
1939
1940 /// Attach the session-scoped host-bounding handles: the cgroup v2 scope (Linux, when the
1941 /// capsule can spawn a subprocess) and the workdir-size guard (every platform).
1942 ///
1943 /// Separate from [`Self::resolve`] because both are *live* per-session resources with
1944 /// lifetimes and side effects — a directory under `/sys/fs/cgroup` and a running thread —
1945 /// while `resolve` is a pure resolution of manifest and host facts. The caller owns their
1946 /// creation so the fail-closed launch refusal can happen with a typed error before this is
1947 /// ever reached.
1948 pub(crate) fn with_host_bounding(
1949 mut self,
1950 cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
1951 workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
1952 ) -> Self {
1953 self.cgroup_scope = cgroup_scope;
1954 self.workdir_guard = workdir_guard;
1955 self
1956 }
1957
1958 /// The latched workdir-size breach, if the guard has seen one.
1959 pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
1960 self.workdir_guard.as_ref().and_then(|guard| guard.breach())
1961 }
1962
1963 /// Refuse to spawn once the workdir ceiling has been crossed.
1964 ///
1965 /// Called by both subprocess spawn paths before `Command::spawn()`. Stopping at the spawn
1966 /// boundary is what keeps a disk filler from writing another byte while the session unwinds:
1967 /// the periodic check notices the breach, and the very next subprocess never starts.
1968 pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
1969 match self.workdir_breach() {
1970 Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
1971 None => Ok(()),
1972 }
1973 }
1974
1975 /// The permanent macOS/non-Linux value, and also what tests should construct directly to
1976 /// exercise `execute_shell` without depending on real kernel enforcement machinery. This
1977 /// is not a fake test-only bypass — it's the literal value real macOS hosts get. (Only
1978 /// test code calls this directly today — production always goes through `resolve()`,
1979 /// which independently arrives at the same tier value on non-Linux hosts.)
1980 #[cfg_attr(not(test), allow(dead_code))]
1981 pub(crate) fn environment_only() -> Self {
1982 Self {
1983 tier: EnforcementTier::EnvironmentOnly,
1984 network_allow_ips: Vec::new(),
1985 network_allow_rules: Vec::new(),
1986 egress_tcp_ports: Vec::new(),
1987 // Denied, matching every other grant this constructor zeroes out. Inert on this tier
1988 // (no seccomp filter is installed at all), but it must not read as "allowed".
1989 unix_sockets_allowed: false,
1990 // Denied, like every other grant this constructor zeroes out. Inert on this tier (no
1991 // Landlock domain exists to withhold anything from), but it must not read as "allowed".
1992 workdir_exec: false,
1993 landlock_grants: Vec::new(),
1994 sealed_bind_dirs: Vec::new(),
1995 staged_runtime_dirs: Vec::new(),
1996 // Defaults, not "no limits": the rlimit ceilings apply unchanged on this tier, so
1997 // zeroing them out here would misrepresent what a real macOS host does.
1998 resource_limits: crate::resources::HostResourceLimits::default(),
1999 nproc_baseline: crate::resources::uid_task_count().unwrap_or(0),
2000 cgroup_scope: None,
2001 workdir_guard: None,
2002 }
2003 }
2004}
2005
2006/// Core messages shared between the stderr line and `logs/bootstrap.log` line for each tier
2007/// warning, matching the shared-const convention used by `runtime::BASH_NETWORK_BYPASS_WARNING`.
2008/// Full detail lives on the diagnostics doc page (`security_warning_link`); keep each to
2009/// one or two concise sentences.
2010///
2011/// The Landlock filesystem scope now derives a narrow read+execute grant (the `shell.allow`
2012/// binaries, their ELF interpreter, and their shared-library closure) *outside* the workdir, in
2013/// addition to the workdir's own grant (which withholds device-node and unix-socket creation, see
2014/// `linux_enforce::WORKDIR_ACCESS_RIGHTS`) — so allowlisted programs can actually exec and
2015/// dynamically link, and the only writable path outside the workdir is `/dev/null`, one of the
2016/// three fixed devices in `CAPSULE_DEVICE_GRANTS`. Seccomp additionally refuses the
2017/// `socket(2)` domains in `denied_socket_domains` — closing the `/var/run/docker.sock` path, which
2018/// no Landlock ABI mediates for pathname sockets. The forked shell child also
2019/// drops every Linux capability before `execve` (see `linux_enforce::drop_all_capabilities`).
2020/// `KernelFull` warns (`W_SEC_005`) rather than staying silent: the tier is a filesystem and
2021/// syscall scope, not a namespace, so what it does and does not cover has to reach the operator.
2022const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2023resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2024derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2025shared libraries — nothing writable, no directory granted wholesale), plus a fixed device set \
2026every capsule gets unconditionally: /dev/null read+write (the only writable path outside the \
2027workdir), \
2028/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2029raw block devices stay denied. The workdir's own grant also \
2030withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2031capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2032capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2033defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2034profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2035and the forked shell child \
2036drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2037denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2038
2039/// `KernelSealed`'s counterpart to [`KERNEL_ENFORCEMENT_NOTICE`], carrying the same `W_SEC_005`
2040/// code: a silent strongest tier would imply the boundary has no documented exceptions, and it
2041/// has one. Names what `sealed` adds over `scoped` so the warning is not a louder copy of it.
2042const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2043host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2044namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir \
2045the only writable path, a private /dev tmpfs carrying the OCI default device set), with Landlock \
2046and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2047rather than denied — with one documented exception: /proc is a bind of the host's, not a masked \
2048private procfs, because mounting one unprivileged needs a PID namespace this tier does not create, \
2049so host process metadata stays visible there exactly as it is under scoped.";
2050
2051const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2052lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2053kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2054verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2055read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2056mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2057exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2058capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2059rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2060either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2061profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2062tier too. The forked shell child still drops every Linux capability and \
2063sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2064verified either. Treat shell subprocess isolation as experimental on this host.";
2065
2066const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2067platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2068enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2069permanent on this platform.";
2070
2071/// Tier-aware replacement for calling `runtime::warn_if_bash_network_bypass` unconditionally.
2072///
2073/// - `KernelFull` → warns (if `shell_allow` is non-empty) that the Linux kernel enforcement
2074/// is unverified and must not be trusted yet — NOT silent. See the const doc above for why.
2075/// - `KernelSeccompOnly` → warns (if `shell_allow` is non-empty) that Landlock is unavailable
2076/// (filesystem scope unenforced) AND that the remaining seccomp enforcement is unverified.
2077/// - `EnvironmentOnly` → warns (if `shell_allow` is non-empty) that there is no kernel
2078/// sandboxing primitive at all on this platform, and this is permanent. Also still calls
2079/// `runtime::warn_if_bash_network_bypass`, which remains accurate on this tier since
2080/// nothing is kernel-enforced.
2081///
2082/// Pure tier→warning-text decision, split out of `warn_for_enforcement_tier` so tests can
2083/// assert what each tier warns without capturing stderr.
2084pub(crate) fn tier_warning(
2085 tier: EnforcementTier,
2086 shell_allow_is_empty: bool,
2087) -> Option<(&'static str, &'static str)> {
2088 if shell_allow_is_empty {
2089 return None;
2090 }
2091 match tier {
2092 EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2093 EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2094 EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2095 EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2096 }
2097}
2098
2099const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
2100this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2101tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2102capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2103does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2104This is permanent on this platform, not a placeholder for a future slice.";
2105
2106/// Pure decision for the aggregate-bounding warning, split out of
2107/// [`warn_for_missing_aggregate_bounding`] the same way [`tier_warning`] is split out of
2108/// [`warn_for_enforcement_tier`], so tests can assert it without capturing stderr.
2109///
2110/// Fires only where the gap is both real and unclosable: a non-Linux host running a capsule that
2111/// can spawn a subprocess. On Linux the same condition is not a warning at all — a capsule that
2112/// can spawn a subprocess and has no scope never launches (`CgroupDelegationUnavailable`), so
2113/// there is no running session left to warn about.
2114pub(crate) fn aggregate_bounding_warning(
2115 is_linux: bool,
2116 requires_bounding: bool,
2117 has_scope: bool,
2118) -> Option<(&'static str, &'static str)> {
2119 if is_linux || !requires_bounding || has_scope {
2120 return None;
2121 }
2122 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2123}
2124
2125/// Fires at every launch, not just once.
2126pub(crate) fn warn_for_missing_aggregate_bounding(
2127 workdir: &Path,
2128 requires_bounding: bool,
2129 has_scope: bool,
2130) {
2131 let is_linux = cfg!(target_os = "linux");
2132 if let Some((code, message)) =
2133 aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2134 {
2135 let link = security_warning_link(code);
2136 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2137 crate::agent::append_bootstrap_log(
2138 workdir,
2139 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2140 );
2141 }
2142}
2143
2144/// Fires at every launch, not just once.
2145pub(crate) fn warn_for_enforcement_tier(
2146 tier: EnforcementTier,
2147 workdir: &Path,
2148 policy: &CapabilityPolicy,
2149) {
2150 if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2151 let link = security_warning_link(code);
2152 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2153 crate::agent::append_bootstrap_log(
2154 workdir,
2155 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2156 );
2157 }
2158 if tier == EnforcementTier::EnvironmentOnly {
2159 crate::runtime::warn_if_bash_network_bypass(workdir, policy);
2160 }
2161}
2162
2163/// Handle to the (possibly no-op) side-channel machinery attached to one subprocess spawn. On
2164/// Linux tiers, the background thread is already running by the time this is returned by
2165/// `prepare_enforcement` — started BEFORE the caller calls `.spawn()`, not after.
2166///
2167/// This ordering is required, not incidental. The child builds its network namespace inside
2168/// `pre_exec` and hands the namespace's listening sockets back over a `SOCK_DGRAM` socketpair;
2169/// `std::process::Command::spawn()`'s parent side then blocks internally (via a close-on-exec
2170/// error pipe) until the child's `execve` resolves. If the receiving thread only started after
2171/// `.spawn()` returned, the capsule's very first outbound connection could beat the egress proxy
2172/// to its own socket. Starting the thread first — racing concurrently with the fork/exec inside
2173/// `.spawn()`, not gated behind it — closes that window.
2174///
2175/// The name is historical: nothing is "supervised" here any more (the seccomp-notify loops it once
2176/// ran are retired — see this module's header). What is left is the fd hand-off, the egress
2177/// proxy's lifetime, and the child's diagnostic pipe.
2178#[derive(Debug)]
2179pub(crate) enum SupervisorHandle {
2180 Noop,
2181 #[cfg(target_os = "linux")]
2182 Linux {
2183 /// Carries the started egress proxy back from the receiving thread, so the proxy's
2184 /// lifetime ends with the subprocess tree it serves rather than outliving it. `None`
2185 /// travels through here too — the hand-off can fail, and that is reported (and fatal to
2186 /// the spawn) on its own path.
2187 proxy_rx: std::sync::mpsc::Receiver<Option<crate::egress_proxy::EgressProxyHandle>>,
2188 /// Read end of the `CLOEXEC` diagnostic pipe whose write end was moved into the forked
2189 /// child's `pre_exec` closure. On a successful `execve` the write end closes
2190 /// automatically with nothing written (so a read yields immediate EOF); on a `pre_exec`
2191 /// setup failure the child writes the real error message here before returning `Err`,
2192 /// letting `execute_shell` fold that legible detail into the error it returns instead of
2193 /// the bare, undifferentiated `EINVAL` that `Command::spawn()` surfaces.
2194 diag_read: std::os::fd::OwnedFd,
2195 },
2196}
2197
2198impl SupervisorHandle {
2199 /// Shuts the session's egress proxy down, with a short bound so the shell call never hangs.
2200 /// In practice the receive returns immediately: the thread's only blocking step is the
2201 /// namespace-socket hand-off, which completed before the child could `execve` at all — long
2202 /// before the caller finished waiting on the child.
2203 ///
2204 /// A proxy outliving its namespace would be serving sockets whose peers no longer exist, so
2205 /// this is the teardown, not merely a thread join.
2206 pub(crate) fn join_best_effort(self) {
2207 match self {
2208 SupervisorHandle::Noop => {}
2209 #[cfg(target_os = "linux")]
2210 SupervisorHandle::Linux { proxy_rx, .. } => {
2211 if let Ok(Some(proxy)) = proxy_rx.recv_timeout(std::time::Duration::from_secs(5)) {
2212 proxy.shutdown();
2213 }
2214 // Deliberately not joining the underlying `JoinHandle`: dropping it without
2215 // joining leaves the thread detached (it keeps running to completion in the
2216 // background, reclaimed by the OS on exit), which is fine here since the value
2217 // received above is the only thing it produces.
2218 }
2219 }
2220 }
2221
2222 /// Best-effort read of any failure detail the forked child wrote to the diagnostic pipe
2223 /// before its `pre_exec` closure returned `Err`. Only consulted by `execute_shell` when
2224 /// `Command::spawn()` itself returns `Err` — on the success path this is never called, so
2225 /// there is zero cost there.
2226 ///
2227 /// The caller must have already released every *parent-side* copy of the pipe's write end
2228 /// (the one captured by the `pre_exec` closure stored in the `Command`) — otherwise the
2229 /// read below never sees EOF. `execute_shell` guarantees this by dropping the `Command`
2230 /// before calling this. Returns `None` when there is no pipe (the `Noop` handle used for
2231 /// `EnvironmentOnly`/non-Linux) or when the child wrote nothing (e.g. a failure with no
2232 /// message, or a `spawn` failure that never ran `pre_exec` at all, like `fork()` itself
2233 /// failing).
2234 pub(crate) fn read_diagnostic(&self) -> Option<String> {
2235 match self {
2236 SupervisorHandle::Noop => None,
2237 #[cfg(target_os = "linux")]
2238 SupervisorHandle::Linux { diag_read, .. } => {
2239 use std::os::fd::AsRawFd;
2240 linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
2241 }
2242 }
2243 }
2244}
2245
2246// Forced-failure seam for the fail-closed contract: inducing a real kernel-level setup failure
2247// requires already-hostile conditions, so tests simulate one here instead. Only compiled into
2248// test builds; production `prepare_enforcement` has no bypass or injection point.
2249#[cfg(test)]
2250thread_local! {
2251 pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
2252 const { std::cell::Cell::new(false) };
2253}
2254
2255#[cfg(test)]
2256fn forced_prepare_failure() -> Result<(), String> {
2257 if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
2258 return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
2259 }
2260 Ok(())
2261}
2262
2263// Child-side (`pre_exec`) forced-failure seams, one per distinct setup step that can fail
2264// independently: the explicit `no_new_privs` `prctl` and the Landlock ruleset construction.
2265// Unlike `FORCE_PREPARE_FAILURE` (which fails the *outer*, pre-fork `prepare_enforcement` path),
2266// these are read from inside the forked child's `pre_exec` closure — `fork()` copy-on-write
2267// duplicates the calling thread's TLS block, so a flag set `true` in the parent thread before
2268// `command.spawn()` remains readable as `true` inside `pre_exec` with no cross-process IPC.
2269// Only compiled into Linux test builds (their only readers — `set_no_new_privs` and
2270// `apply_landlock_scope` — are Linux-only); production has no bypass or injection point.
2271#[cfg(all(test, target_os = "linux"))]
2272thread_local! {
2273 pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
2274 const { std::cell::Cell::new(false) };
2275 pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
2276 const { std::cell::Cell::new(false) };
2277 /// Fails the composed-root step of a `KernelSealed` launch. Exists because the real step can
2278 /// only fail on a host that can attempt it at all, and the thing under test — that a
2279 /// composed-root failure keeps its identity from the child's diagnostic pipe all the way to
2280 /// the CLI's `E-RUN-014` — must be verifiable on any Linux host, including the CI containers
2281 /// that can never reach `KernelSealed`. Short-circuits before any real namespace syscall.
2282 pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
2283 const { std::cell::Cell::new(false) };
2284}
2285
2286/// RAII arm for [`FORCE_SEALED_ROOT_FAILURE`]. Lives beside the flag rather than in one test
2287/// module because two of them need it: `sandbox`'s (which checks what `execute_shell` returns)
2288/// and `runtime`'s (which checks what the dispatch layer does with it).
2289#[cfg(all(test, target_os = "linux"))]
2290pub(crate) struct ForceSealedRootFailureGuard;
2291
2292#[cfg(all(test, target_os = "linux"))]
2293impl ForceSealedRootFailureGuard {
2294 pub(crate) fn new() -> Self {
2295 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
2296 Self
2297 }
2298}
2299
2300#[cfg(all(test, target_os = "linux"))]
2301impl Drop for ForceSealedRootFailureGuard {
2302 fn drop(&mut self) {
2303 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
2304 }
2305}
2306
2307/// A `KernelSealed` enforcement with no grants, for the forced-failure tests. The parent-side
2308/// half of the sealed mechanism still runs for real against it (the composed root is planned and
2309/// its spec built); only the child's execution of that spec is short-circuited by the seam above,
2310/// which is what makes these tests run identically on a sealed-capable host and on one that could
2311/// never reach `KernelSealed` at all.
2312#[cfg(all(test, target_os = "linux"))]
2313pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
2314 ShellEnforcement {
2315 tier: EnforcementTier::KernelSealed,
2316 ..ShellEnforcement::environment_only()
2317 }
2318}
2319
2320/// Attach the OS-level *process* bounds — hard rlimits, and cgroup membership where a scope
2321/// exists — to `command`, so they apply to the spawned process and everything it forks.
2322///
2323/// Deliberately independent of the tier machinery above. Landlock and seccomp are Linux kernel
2324/// primitives that exist on some hosts; `setrlimit` is POSIX and exists on all of them, and the
2325/// cgroup self-move is a single `write(2)` to a descriptor the parent already opened. Splitting
2326/// this out is what lets the two subprocess spawn paths converge: `execute_shell` gets it as
2327/// part of `prepare_enforcement`, and `dispatch_native_tool` — which installs no seccomp or
2328/// Landlock at all, a standing gap — gets it on its own.
2329///
2330/// Everything the returned closure does runs in the forked child between `fork` and `execve`,
2331/// so it is restricted to syscalls: `getrlimit`/`setrlimit` pairs and one `write`. No
2332/// allocation, no locking, no path resolution.
2333#[cfg(unix)]
2334pub(crate) fn attach_process_limits(
2335 command: &mut std::process::Command,
2336 enforcement: &ShellEnforcement,
2337) {
2338 use std::os::unix::process::CommandExt;
2339
2340 let limits = enforcement.resource_limits;
2341 let nproc_baseline = enforcement.nproc_baseline;
2342 let cgroup_scope = enforcement.cgroup_scope.clone();
2343
2344 // SAFETY: the closure runs in the forked child before `execve`, where only async-signal-safe
2345 // operations are permitted. It performs `getrlimit`/`setrlimit` pairs and, when a cgroup
2346 // scope exists, a single `write` to a descriptor opened by the parent before the fork —
2347 // no allocation, no locks, no path lookups.
2348 #[allow(unsafe_code)]
2349 unsafe {
2350 command.pre_exec(move || {
2351 crate::resources::apply_hard_rlimits(&limits, nproc_baseline)?;
2352 if let Some(scope) = cgroup_scope.as_ref() {
2353 scope.join_current_process()?;
2354 }
2355 Ok(())
2356 });
2357 }
2358}
2359
2360/// Lowest file descriptor the fd-hygiene step touches. Everything from here upward is marked
2361/// close-on-exec in the forked child; fds 0, 1 and 2 are deliberately left alone.
2362///
2363/// **Stdio is NOT marked close-on-exec, and is NOT reopened.** The `Stdio::piped()` handles both
2364/// spawn paths install *before* `pre_exec` runs exist so the exec'd program inherits them across
2365/// its own `execve` and talks to the parent over them — `execute_shell` reads the subprocess's
2366/// stdout/stderr, and `dispatch_native_tool` additionally writes its `ToolInput` JSON to the
2367/// subprocess's stdin and parses its `ToolResult` JSON back off stdout. Marking 0/1/2
2368/// close-on-exec would leave the exec'd program with those descriptors closed, breaking every
2369/// invocation on both paths, and there is nothing meaningful to reopen them onto: the pipes
2370/// themselves are the intended destination. The gap this constant closes is everything *above*
2371/// stdio, where inheritance was never intended and was only ever prevented by the empirical
2372/// accident of nothing else happening to be open at spawn time.
2373///
2374/// Its only non-test reader is Linux-only (`linux_enforce::mark_inherited_fds_cloexec`), so this
2375/// carries the same cross-platform `dead_code` exception the other Linux-consumed items in this
2376/// module carry.
2377#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2378pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
2379
2380/// Non-Linux counterpart of [`apply_fd_hygiene`]: a documented no-op.
2381///
2382/// `close_range(2)` is Linux-only, and this mirrors the shape `prepare_enforcement` already
2383/// uses on non-Linux targets — no `pre_exec` closure is installed at all, rather than one that
2384/// does nothing. macOS is `EnforcementTier::EnvironmentOnly` by construction; there is no
2385/// kernel primitive here to call.
2386#[cfg(not(target_os = "linux"))]
2387pub(crate) fn apply_fd_hygiene(_command: &mut std::process::Command) {}
2388
2389/// Attaches the fd-hygiene step — mark every fd >= [`FD_HYGIENE_FIRST_FD`] close-on-exec — to a
2390/// `Command` that is about to be spawned, for spawn paths that do **not** go through
2391/// `prepare_enforcement`.
2392///
2393/// This exists so `runtime::dispatch_native_tool` (which has no `ShellEnforcement`, no tier and
2394/// no kernel sandboxing of its own) gets exactly the same fd-inheritance guarantee
2395/// `execute_shell` gets, without pulling the seccomp/Landlock apparatus into it. It is
2396/// deliberately a free function over `&mut Command` taking no policy input: fd hygiene is
2397/// unconditional — it is not derived from `CapabilityPolicy`, the manifest, or the enforcement
2398/// tier, and there is no configuration under which a subprocess should inherit an fd the
2399/// runtime did not deliberately hand it.
2400///
2401/// Do **not** call this on a `Command` that also goes through `prepare_enforcement`:
2402/// `linux_enforce::child_install_enforcement` already performs the same step as its first
2403/// statement, and `pre_exec` closures run in registration order, so a second one would be a
2404/// redundant syscall rather than an additional guarantee.
2405///
2406/// On a kernel older than 5.11 this makes `.spawn()` itself fail — see
2407/// [`linux_enforce::mark_inherited_fds_cloexec`] for that fail-closed consequence, which
2408/// applies identically here.
2409#[cfg(target_os = "linux")]
2410#[allow(unsafe_code)]
2411pub(crate) fn apply_fd_hygiene(command: &mut std::process::Command) {
2412 use std::os::unix::process::CommandExt;
2413
2414 // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
2415 // narrow pre_exec window where only async-signal-safe operations are permitted. Its body is
2416 // a single `close_range(2)` syscall plus, on failure only, one `io::Error` construction: no
2417 // allocation on the success path, no locks, no captured state at all.
2418 unsafe {
2419 command.pre_exec(linux_enforce::mark_inherited_fds_cloexec);
2420 }
2421}
2422
2423/// Installs kernel-level exec/network/filesystem enforcement into `command` so it applies to
2424/// the spawned process and everything it forks/execs. The Landlock/seccomp half is a no-op when
2425/// `enforcement.tier == EnvironmentOnly` — it does not even attempt those calls in that case —
2426/// but the rlimit half below is not: `setrlimit` is POSIX and applies on every platform this
2427/// runtime targets, so a macOS capsule still gets per-process ceilings even though this tier has
2428/// no kernel sandbox at all.
2429#[cfg(not(target_os = "linux"))]
2430pub(crate) fn prepare_enforcement(
2431 command: &mut std::process::Command,
2432 enforcement: &ShellEnforcement,
2433 _accessible_workdir: &Path,
2434 _session_workdir: &Path,
2435) -> Result<SupervisorHandle, String> {
2436 #[cfg(test)]
2437 forced_prepare_failure()?;
2438
2439 debug_assert_eq!(
2440 enforcement.tier,
2441 EnforcementTier::EnvironmentOnly,
2442 "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly"
2443 );
2444 attach_process_limits(command, enforcement);
2445 Ok(SupervisorHandle::Noop)
2446}
2447
2448/// Linux implementation of `prepare_enforcement`. See the module-level docs and the `linux_enforce`
2449/// submodule for the mechanics (socketpair fd-passing side channel + `pre_exec` seccomp/Landlock
2450/// installation + background namespace-socket receiving thread).
2451#[cfg(target_os = "linux")]
2452#[allow(unsafe_code)]
2453pub(crate) fn prepare_enforcement(
2454 command: &mut std::process::Command,
2455 enforcement: &ShellEnforcement,
2456 accessible_workdir: &Path,
2457 session_workdir: &Path,
2458) -> Result<SupervisorHandle, String> {
2459 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
2460 use std::os::unix::process::CommandExt;
2461
2462 #[cfg(test)]
2463 forced_prepare_failure()?;
2464
2465 if enforcement.tier == EnforcementTier::EnvironmentOnly {
2466 // No kernel sandbox to install, but the POSIX rlimit ceilings still apply — this tier is
2467 // only reachable on Linux from tests, and even there it must not read as "unbounded".
2468 attach_process_limits(command, enforcement);
2469 return Ok(SupervisorHandle::Noop);
2470 }
2471
2472 // Resolve/open every Landlock path in the PARENT, before fork(). Failing to open the workdir
2473 // is a normal synchronous error here — it names the path and returns before any subprocess is
2474 // spawned — rather than an `open()` that, buried inside `pre_exec`, would collapse to a bare
2475 // EINVAL at the `.spawn()` call site. Grant paths that fail to open are silently dropped
2476 // (shrink-not-fail), exactly as before; only the *where* of the open moved. Landlock rules
2477 // only apply on `KernelFull`, so `KernelSeccompOnly` opens nothing.
2478 let landlock_fds = if matches!(
2479 enforcement.tier,
2480 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
2481 ) {
2482 Some(linux_enforce::open_landlock_fds(
2483 accessible_workdir,
2484 session_workdir,
2485 &enforcement.landlock_grants,
2486 &landlock_device_grants(enforcement.tier),
2487 enforcement.workdir_exec,
2488 enforcement.tier,
2489 )?)
2490 } else {
2491 None
2492 };
2493
2494 // Resolve the whole composed root in the PARENT for the same reason the Landlock fds are
2495 // opened here: every path lookup, every `PathBuf::join` and every allocation the child would
2496 // otherwise perform inside `pre_exec` — the window where the allocator lock may be held by a
2497 // thread that no longer exists — happens now instead, synchronously, where a failure names
2498 // itself. See `crate::sealed` for the plan/execute split.
2499 let sealed_spec = if enforcement.tier == EnforcementTier::KernelSealed {
2500 Some(build_sealed_root(
2501 accessible_workdir,
2502 session_workdir,
2503 &enforcement.sealed_bind_dirs,
2504 &enforcement.staged_runtime_dirs,
2505 )?)
2506 } else {
2507 None
2508 };
2509
2510 let mut fds = [0i32; 2];
2511 // SAFETY: `fds` is a 2-element stack array, exactly the size `socketpair` writes into;
2512 // no other precondition applies.
2513 let rc = unsafe {
2514 libc::socketpair(
2515 libc::AF_UNIX,
2516 libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
2517 0,
2518 fds.as_mut_ptr(),
2519 )
2520 };
2521 if rc != 0 {
2522 return Err(format!(
2523 "sandbox: socketpair() failed: {}",
2524 std::io::Error::last_os_error()
2525 ));
2526 }
2527 // SAFETY: both fds were just returned by the successful socketpair() call above; nothing
2528 // else has taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
2529 let (parent_sock, child_sock) =
2530 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
2531
2532 // Dedicated CLOEXEC pipe carrying failure detail out of the child. The write end is moved
2533 // into the `pre_exec` closure; because it is CLOEXEC, a successful `execve` closes it with
2534 // nothing written (the parent's read then returns immediate EOF — zero cost on the success
2535 // path), while any `pre_exec` setup failure writes its real message here before the closure
2536 // returns `Err`. Raw `pipe2` mirrors the raw `socketpair` convention used just above.
2537 let mut diag_fds = [0i32; 2];
2538 // SAFETY: `diag_fds` is a 2-element stack array, exactly the size `pipe2` writes into; no
2539 // other precondition applies.
2540 let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
2541 if rc != 0 {
2542 return Err(format!(
2543 "sandbox: pipe2() for enforcement diagnostics failed: {}",
2544 std::io::Error::last_os_error()
2545 ));
2546 }
2547 // SAFETY: both fds were just returned by the successful pipe2() call above; nothing else has
2548 // taken ownership of them yet, so wrapping them in OwnedFd is exclusive/sound.
2549 let (diag_read, diag_write) = unsafe {
2550 (
2551 OwnedFd::from_raw_fd(diag_fds[0]),
2552 OwnedFd::from_raw_fd(diag_fds[1]),
2553 )
2554 };
2555
2556 // Everything the child's namespace construction needs, resolved here in the parent: the
2557 // `pre_exec` window permits no allocation, and `getuid()` in particular has to be read
2558 // *before* the `unshare` — inside a fresh user namespace with no map written yet it reports
2559 // the overflow id, and writing that back is refused on every host.
2560 let netns_plan =
2561 crate::network_namespace::CapsuleNetnsPlan::resolve(enforcement.egress_tcp_ports.clone());
2562 let expected_namespace_sockets = netns_plan.socket_count();
2563 let egress_policy = crate::egress_proxy::EgressPolicy::new(
2564 enforcement.network_allow_rules.clone(),
2565 enforcement.network_allow_ips.clone(),
2566 );
2567
2568 let tier = enforcement.tier;
2569 // Copied out before the `move` closure below takes ownership of everything it touches, the
2570 // same clone-before-move shape `tier` and the Landlock fds already use. Note the fd-passing
2571 // socketpair above is created in the *parent*, before fork, so the child's own `sendmsg` over
2572 // it is unaffected by a filter that denies `socket(AF_UNIX, ...)`.
2573 let unix_sockets_allowed = enforcement.unix_sockets_allowed;
2574 // Same clone-before-move shape. `HostResourceLimits` is `Copy`; the cgroup scope is an `Arc`
2575 // whose `cgroup.procs` descriptor was opened in the parent at scope-creation time, so the
2576 // child performs no path lookup to join it.
2577 let resource_limits = enforcement.resource_limits;
2578 let nproc_baseline = enforcement.nproc_baseline;
2579 let cgroup_scope = enforcement.cgroup_scope.clone();
2580
2581 // SAFETY: this closure runs in the forked child, after fork() but before execve() — the
2582 // narrow pre_exec window where only async-signal-safe operations are permitted. It performs
2583 // the capability-dropping `prctl`/`capset` sequence (bounding-set drop, capset clear, and the
2584 // explicit `no_new_privs` `prctl`) plus one allocation-free `open`/`read`/`close` of
2585 // `/proc/sys/kernel/cap_last_cap`, libseccomp filter construction/load (kernel syscalls), one
2586 // `sendmsg` batch handing the network namespace's sockets to the parent over `child_sock`, and
2587 // (KernelFull only)
2588 // Landlock ruleset construction/`restrict_self` against already-open fds (also kernel
2589 // syscalls) — no `open()`/`canonicalize()` beyond that one `cap_last_cap` read, no locks
2590 // beyond what those syscalls need. On any failure it writes the real error message to the
2591 // CLOEXEC diagnostic pipe (best-effort, bounded, raw `write` loop) before returning `Err`.
2592 // `child_sock`, `diag_write`, and the Landlock fds are moved in and close automatically when
2593 // the closure body finishes.
2594 unsafe {
2595 command.pre_exec(move || {
2596 // Ordered first, before any seccomp filter is installed: these are the bounds that
2597 // must hold even if a later step in this closure fails, and `prlimit64`/`setrlimit`
2598 // plus `write` are all already in `SECCOMP_SYSCALL_ALLOWLIST` either way.
2599 if let Err(error) =
2600 crate::resources::apply_hard_rlimits(&resource_limits, nproc_baseline)
2601 {
2602 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2603 return Err(error);
2604 }
2605 if let Some(scope) = cgroup_scope.as_ref() {
2606 if let Err(error) = scope.join_current_process() {
2607 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2608 return Err(error);
2609 }
2610 }
2611
2612 let sock_fd = child_sock.as_raw_fd();
2613 match linux_enforce::child_install_enforcement(
2614 tier,
2615 &netns_plan,
2616 sealed_spec.as_ref(),
2617 landlock_fds.as_ref(),
2618 sock_fd,
2619 unix_sockets_allowed,
2620 ) {
2621 Ok(()) => Ok(()),
2622 Err(error) => {
2623 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
2624 Err(error)
2625 }
2626 }
2627 });
2628 }
2629
2630 // Start the namespace-socket receiving thread now — BEFORE the caller calls `.spawn()`. See
2631 // `SupervisorHandle`'s doc comment for why this ordering (not "after spawn") is required.
2632 Ok(linux_enforce::start_supervisor(
2633 parent_sock,
2634 egress_policy,
2635 expected_namespace_sockets,
2636 diag_read,
2637 ))
2638}
2639
2640/// Which fixed device set Landlock grants, keyed on the tier.
2641///
2642/// Landlock keeps mediating inside a composed root, so the two device *lists* have to agree: a
2643/// device present in the sealed `/dev` but absent from the Landlock rules would exist and be
2644/// unopenable, which reads as a runtime bug rather than as policy. `KernelFull` keeps
2645/// [`CAPSULE_DEVICE_GRANTS`], which services `scoped`, while `KernelSealed` derives its own from
2646/// [`crate::sealed::SEALED_DEVICE_NODES`], the same list the private `/dev` tmpfs is built from.
2647///
2648/// The parent opens these paths on the *host*, before the namespace exists; because the composed
2649/// root bind-mounts the host's own nodes rather than creating new ones, the inodes the rules name
2650/// are the inodes the capsule reaches.
2651#[cfg(target_os = "linux")]
2652fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
2653 match tier {
2654 EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
2655 .iter()
2656 .map(|device| CapsuleDeviceGrant {
2657 path: device.path,
2658 writable: device.writable,
2659 })
2660 .collect(),
2661 _ => CAPSULE_DEVICE_GRANTS.to_vec(),
2662 }
2663}
2664
2665/// Creates (idempotently) the directory inside the session workdir that backs `/tmp` in the
2666/// composed root, returning its host path.
2667///
2668/// Two callers need it and neither may assume the other ran first: `build_sealed_root` binds it at
2669/// `/tmp`, and `open_landlock_fds` opens it to give that bind a Landlock rule. They are called in
2670/// that reverse order by `prepare_enforcement` today, so the creation lives here rather than in
2671/// either of them — one `create_dir_all`, one message, no ordering dependency between the two.
2672/// See [`crate::sealed::SEALED_TMP_DIR_NAME`] for why `/tmp` is workdir-backed rather than a second
2673/// tmpfs.
2674#[cfg(target_os = "linux")]
2675fn ensure_sealed_tmp_store(session_workdir: &Path) -> Result<PathBuf, String> {
2676 let tmp_store = session_workdir.join(crate::sealed::SEALED_TMP_DIR_NAME);
2677 std::fs::create_dir_all(&tmp_store).map_err(|error| {
2678 format!(
2679 "sealed: failed to create the workdir-backed /tmp store at {}: {error}",
2680 tmp_store.display()
2681 )
2682 })?;
2683 Ok(tmp_store)
2684}
2685
2686/// Writes (idempotently) the synthetic `/etc/passwd`/`/etc/group` the composed root binds in place
2687/// of the host's, returning their host paths in [`crate::sealed::SEALED_ETC_PATHS`] order.
2688///
2689/// Sibling of [`ensure_sealed_tmp_store`] in every respect that matters: plain `std::fs`, no
2690/// privilege, and two callers in different phases that must not depend on each other's ordering —
2691/// `build_sealed_root` binds these files, `open_landlock_fds` opens them to grant them. Content is
2692/// a pure function of the workdir, so both calls write the same bytes.
2693///
2694/// Runs in the parent, never in `pre_exec`: composing a root is the child's job, but *producing
2695/// content* is work the parent can do, and this module's rule is that the `pre_exec` window does
2696/// nothing the parent could have done for it.
2697///
2698/// The rewrite is deliberately in place (`fs::write` truncates; it does not replace). The Landlock
2699/// rule the earlier caller took is bound to the file's inode, so writing a fresh file over the path
2700/// would leave that rule pointing at an inode the composed root no longer binds — `/etc/passwd`
2701/// mounted and `EACCES`.
2702#[cfg(target_os = "linux")]
2703fn ensure_sealed_identity_files(session_workdir: &Path) -> Result<Vec<PathBuf>, String> {
2704 use std::os::unix::fs::MetadataExt;
2705
2706 let staging = session_workdir.join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME);
2707 std::fs::create_dir_all(&staging).map_err(|error| {
2708 format!(
2709 "sealed: failed to create the synthetic /etc staging directory at {}: {error}",
2710 staging.display()
2711 )
2712 })?;
2713
2714 // The workdir was created by this process, so its owner is the identity the capsule's
2715 // subprocesses run as — no `getuid(2)` FFI needed under `#![deny(unsafe_code)]`.
2716 let owner = std::fs::metadata(session_workdir).map_err(|error| {
2717 format!(
2718 "sealed: failed to stat the session workdir {} for the capsule's uid/gid: {error}",
2719 session_workdir.display()
2720 )
2721 })?;
2722 // The same string `build_shell_env` puts in `$HOME`, from the same constant, resolved against
2723 // the same directory: the synthetic home lives in the session workdir, so a `pw_dir` composed
2724 // against the accessible one would name a directory that does not exist.
2725 let home = session_workdir.join(crate::shell::SYNTHETIC_HOME_DIR_NAME);
2726 let home = home.to_string_lossy().into_owned();
2727 let identity = crate::sealed::SealedAccountIdentity {
2728 uid: owner.uid(),
2729 gid: owner.gid(),
2730 home: &home,
2731 };
2732
2733 let mut written = Vec::new();
2734 for entry in crate::sealed::SEALED_ETC_PATHS {
2735 let Some(file) = entry.synthetic else {
2736 continue;
2737 };
2738 let path = crate::sealed::synthetic_etc_source(session_workdir, file);
2739 std::fs::write(&path, file.render(&identity)?).map_err(|error| {
2740 format!(
2741 "sealed: failed to write the synthetic {} at {}: {error}",
2742 entry.path,
2743 path.display()
2744 )
2745 })?;
2746 written.push(path);
2747 }
2748 Ok(written)
2749}
2750
2751/// Parent-side half of the sealed mechanism: pick a base, plan the composed root against the real
2752/// host layout, create the workdir-backed `/tmp` store, and lower the plan into the C-string form
2753/// the forked child executes.
2754///
2755/// Every failure here is synchronous and named, before `.spawn()` is called at all — the same
2756/// reason `open_landlock_fds` moved its `open()` calls out of `pre_exec`. A capsule that reaches
2757/// this point has already cleared `check_containment_floor`, so a failure here is a genuine
2758/// host-state surprise (an unwritable workdir, a host with none of the base candidates), not a
2759/// declared-vs-achieved mismatch.
2760///
2761/// `staged_runtime_read_only` is passed straight through to the planner rather than validated
2762/// here. A missing `source_path` is *not* diagnosed in this function on purpose: a parent-side
2763/// error would return `Err(String)`, which converts to the ordinary, retryable
2764/// `ShellExecError::Failed`. Letting the required bind fail at `mount(2)` in the child instead
2765/// routes it to the session-fatal `SealedRootConstructionFailed` (`E-RUN-014`) — the correct
2766/// classification for a capsule that did not get a runtime tree it declared.
2767#[cfg(target_os = "linux")]
2768fn build_sealed_root(
2769 accessible_workdir: &Path,
2770 session_workdir: &Path,
2771 extra_read_only: &[PathBuf],
2772 staged_runtime_read_only: &[PathBuf],
2773) -> Result<crate::sealed::SealedRootSpec, String> {
2774 use crate::sealed;
2775
2776 // The workdir must be absolute for its path to mean the same thing inside the composed root,
2777 // and `launch_session` always creates it as one — but the composed root's whole design rests
2778 // on it, so it is checked rather than assumed.
2779 if !accessible_workdir.is_absolute() {
2780 return Err(format!(
2781 "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
2782 its own absolute path",
2783 accessible_workdir.display()
2784 ));
2785 }
2786
2787 let base = sealed::choose_root_base(
2788 accessible_workdir,
2789 sealed::SEALED_ROOT_BASE_CANDIDATES,
2790 |path| path.is_dir(),
2791 )
2792 .ok_or_else(|| {
2793 format!(
2794 "sealed: no usable base directory for the composed root; none of {:?} exists outside \
2795 the session workdir's own path",
2796 sealed::SEALED_ROOT_BASE_CANDIDATES
2797 )
2798 })?;
2799
2800 // `/tmp` inside the composed root is backed by this directory, so it is created here rather
2801 // than from inside `pre_exec`. `open_landlock_fds` — which runs earlier and must open the same
2802 // directory to grant it — creates it through the same idempotent helper.
2803 ensure_sealed_tmp_store(session_workdir)?;
2804
2805 // `/etc/passwd` and `/etc/group` inside the composed root are binds of these files, so they
2806 // exist on disk before the plan that names them is built — and long before the child executes
2807 // it. Same idempotent helper `open_landlock_fds` called earlier to grant them.
2808 ensure_sealed_identity_files(session_workdir)?;
2809
2810 let plan = sealed::plan_composed_root(
2811 accessible_workdir,
2812 session_workdir,
2813 &base,
2814 extra_read_only,
2815 staged_runtime_read_only,
2816 &sealed::RealHostLayout,
2817 );
2818 sealed::build_sealed_root_spec(&plan)
2819}
2820
2821/// Linux-only mechanics: fd-passing side channel, seccomp filter construction, Landlock
2822/// application, and the background thread that receives the capsule's network-namespace sockets.
2823///
2824/// Nothing here uses `libseccomp`'s notify facility. `connect`/`sendto` are enforced by the
2825/// network namespace and `execve`/`execveat` by Landlock `Execute` rights, both of which decide
2826/// from kernel-held state. A `Notify` rule would reintroduce a `/proc/<pid>/mem` read on the hot
2827/// path, where the value read can change between the decision and the kernel's own dereference.
2828#[cfg_attr(target_os = "linux", allow(unsafe_code))]
2829#[cfg(target_os = "linux")]
2830mod linux_enforce {
2831 use std::io;
2832 use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd};
2833 use std::path::Path;
2834
2835 use landlock::{make_bitflags, AccessFs, BitFlags};
2836
2837 use super::{EnforcementTier, LandlockGrant, SupervisorHandle};
2838
2839 /// Longest diagnostic message written to (or read from) the child-failure pipe. A message
2840 /// naming the failed step is far shorter than this; the bound just keeps the best-effort
2841 /// `write`/`read` loops trivially terminating.
2842 const MAX_DIAG_LEN: usize = 1024;
2843
2844 /// One Landlock grant path, opened (`O_PATH | O_CLOEXEC`) in the PARENT before fork(), paired
2845 /// with the two bits that decide its rule's access set: `list_dir` (`ReadDir`) and
2846 /// `executable` (`Execute`). Only the already-open fd crosses into the child's `pre_exec` —
2847 /// never a path to re-open there.
2848 pub(super) struct OpenLandlockGrant {
2849 fd: OwnedFd,
2850 list_dir: bool,
2851 executable: bool,
2852 }
2853
2854 /// One entry of [`CAPSULE_DEVICE_GRANTS`], opened (`O_PATH | O_CLOEXEC`) in the PARENT before
2855 /// fork(), paired with the `writable` bit that decides whether its rule carries `WriteFile` on
2856 /// top of `ReadFile`. Structurally a sibling of [`OpenLandlockGrant`] rather than the same
2857 /// type, mirroring the split between [`CapsuleDeviceGrant`](super::CapsuleDeviceGrant) and
2858 /// [`LandlockGrant`]: read+execute-a-binary and read/write-a-device are different intents and
2859 /// their bits must not be confusable.
2860 pub(super) struct OpenDeviceGrant {
2861 fd: OwnedFd,
2862 writable: bool,
2863 }
2864
2865 /// All Landlock file descriptors resolved in the parent for one shell subprocess: the
2866 /// workdir's fd (scoped by [`workdir_access_rights`]), the workdir-backed `/tmp` store's fd on
2867 /// `KernelSealed`, each successfully-opened grant fd, and each successfully-opened fixed-device
2868 /// fd.
2869 /// Handed by reference into the child's `pre_exec`, where `apply_landlock_scope` builds rules
2870 /// against these fds without performing a single `open()`.
2871 pub(super) struct LandlockChildFds {
2872 workdir_fd: OwnedFd,
2873 /// The capsule's `capabilities.filesystem.workdir_exec`, carried alongside the fd it
2874 /// applies to so `apply_landlock_scope` — which runs in `pre_exec` and must read nothing
2875 /// but its argument — has the whole workdir rule decided for it before fork.
2876 workdir_exec: bool,
2877 /// `<workdir>/.mur-tmp` — the directory `plan_composed_root` bind-mounts at `/tmp` inside
2878 /// the composed root — opened on `KernelSealed` and `None` on every other tier, which has
2879 /// no composed root and therefore no such bind.
2880 ///
2881 /// It needs an fd of its own even though it lives *inside* the workdir: Landlock resolves a
2882 /// path by walking dentries up to each mount root, so an access to `/tmp/x` inside the root
2883 /// is matched against the `.mur-tmp` inode (the bind's root) and then against the composed
2884 /// root's own `/tmp` mountpoint — never against the workdir inode. The workdir's rule
2885 /// therefore does not reach the bind, and without this fd every `/tmp` access is `EACCES`
2886 /// on a mount that is genuinely writable.
2887 sealed_tmp_fd: Option<OwnedFd>,
2888 /// The parent-written synthetic `/etc/passwd`/`/etc/group`
2889 /// ([`crate::sealed::SEALED_ETC_STAGING_DIR_NAME`]), opened on `KernelSealed` and empty on
2890 /// every other tier.
2891 ///
2892 /// Exactly the [`Self::sealed_tmp_fd`] problem, one directory over. The grant list already
2893 /// carries `/etc/passwd`, but that rule is taken on the fd opened at the *host's*
2894 /// `/etc/passwd` — a different inode from the file the composed root actually binds there.
2895 /// Landlock matches inodes, not path strings, and the walk from a bind-mounted file starts
2896 /// at the bound file's own inode, so without these fds both account databases would be
2897 /// mounted and `EACCES`: `getpwuid(3)` fails, and a shell cannot expand `~`.
2898 ///
2899 /// Read-only, like every other `SEALED_ETC_PATHS` rule — the mount is `MS_RDONLY` too, and
2900 /// nothing inside a capsule has any business editing an account database.
2901 sealed_identity_fds: Vec<OwnedFd>,
2902 grants: Vec<OpenLandlockGrant>,
2903 devices: Vec<OpenDeviceGrant>,
2904 }
2905
2906 impl LandlockChildFds {
2907 /// The workdir-backed `/tmp` fd, for the tier-gate test. Keeps the struct's fields private
2908 /// like every other one: nothing outside `apply_landlock_scope` needs the fd itself.
2909 #[cfg(test)]
2910 pub(super) fn sealed_tmp_fd(&self) -> Option<&OwnedFd> {
2911 self.sealed_tmp_fd.as_ref()
2912 }
2913
2914 /// How many synthetic-`/etc` fds were opened, for the tier-gate test. A count, not the fds:
2915 /// the same "nothing outside `apply_landlock_scope` needs the fd itself" rule as above.
2916 #[cfg(test)]
2917 pub(super) fn sealed_identity_fd_count(&self) -> usize {
2918 self.sealed_identity_fds.len()
2919 }
2920 }
2921
2922 /// The Landlock ABI v1 rights granted on the capsule **workdir's own** `PathBeneath` rule,
2923 /// *excluding* `Execute` — see [`workdir_access_rights`], which adds it back for a capsule that
2924 /// declared `capabilities.filesystem.workdir_exec: true`.
2925 ///
2926 /// Spelled out variant by variant rather than `AccessFs::from_all(ABI::V1)` for two reasons:
2927 /// `from_all` hides what is actually handed to the capsule, and four of ABI v1's thirteen
2928 /// rights are withheld from this constant.
2929 ///
2930 /// - `Execute` — the one this constant withholds *conditionally*. Withholding it is what
2931 /// makes `capabilities.shell.allow` a complete, sound statement: Landlock refuses the exec
2932 /// on the path the kernel itself resolved, so a binary the capsule wrote into its workdir
2933 /// cannot run under any name — including an allowlisted basename, the exact bypass a prior
2934 /// release shipped. The cost: a binary the capsule legitimately compiled in its workdir
2935 /// cannot run either, which is what `workdir_exec: true` buys back.
2936 /// - `MakeChar` / `MakeBlock` — creating a device node inside the workdir escapes this
2937 /// whole scope. A capsule running as root could `mknod` a node for the host's own disk
2938 /// (e.g. major/minor `8:0` = `sda`) *inside* the granted directory — which Landlock
2939 /// permits, because the new inode lives beneath a granted path — then `open()` it and
2940 /// read the raw host filesystem underneath every other restriction. No known workload
2941 /// creates device nodes in its working tree.
2942 /// - `MakeSock` — binding an `AF_UNIX` socket file. Withheld by the same rule (no
2943 /// filesystem object with a kernel-side identity beyond a regular file), but unlike the
2944 /// two device rights this one is a genuine open question: some build tooling and some
2945 /// language-toolchain daemons `bind()` a unix socket in their working tree, and a fix
2946 /// that breaks those is not a fix. The manual acceptance procedure in
2947 /// `docs/content/reference/workdir-device-node-manual-verification.md` carries an
2948 /// explicit unix-socket scenario for exactly this; if the team's
2949 /// real-hardware run finds a workload that needs it, add `MakeSock` back to this list.
2950 ///
2951 /// `MakeFifo` stays granted: real build tooling does create named pipes in its working tree,
2952 /// and a FIFO carries none of the raw-device risk above.
2953 ///
2954 /// Because `apply_landlock_scope`'s `handle_access` still declares the *full*
2955 /// `from_all(ABI::V1)` set, a right this rule withholds is not merely "not extra-granted" — it
2956 /// is **denied on every path this rule covers**, workdir included, once `restrict_self()` takes
2957 /// effect — so withholding `Execute` here is enforcement, not a mere omission. The three
2958 /// `Make*` rights are denied domain-wide because no other rule grants them either; `Execute`
2959 /// is denied *on the workdir specifically*, while the narrow read+execute grants below still
2960 /// carry it for the allowlisted binaries outside it: exec where the operator named a binary,
2961 /// nowhere the capsule writes.
2962 pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
2963 WriteFile
2964 | ReadFile
2965 | ReadDir
2966 | RemoveDir
2967 | RemoveFile
2968 | MakeDir
2969 | MakeReg
2970 | MakeFifo
2971 | MakeSym
2972 });
2973
2974 /// The workdir's `PathBeneath` rights for one capsule: [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`], plus
2975 /// `Execute` only when the manifest declared `capabilities.filesystem.workdir_exec: true`.
2976 ///
2977 /// A function rather than a second constant so the two cases cannot drift: adding a right to
2978 /// the workdir grant is one edit, and the `workdir_exec` axis stays exactly one bit wide.
2979 /// Note what this does *not* consult — `shell.allow`, the exec allowlist, any resolved path.
2980 /// The whole point of the default is that the workdir grant is no longer name-based at all.
2981 pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
2982 if workdir_exec {
2983 WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
2984 } else {
2985 WORKDIR_ACCESS_RIGHTS_NO_EXEC
2986 }
2987 }
2988
2989 /// Probes real Landlock support by building a ruleset granting full access to `/` and
2990 /// calling `restrict_self()` **in a forked child**, so the host process is never placed
2991 /// into a Landlock domain by a mere capability check.
2992 ///
2993 /// Forking is required, not defensive. The rights the ruleset grants are beside the point:
2994 /// entering *any* Landlock domain — even one permitting everything — permanently forbids the
2995 /// mount family to the task and every descendant it will ever have, which is how Landlock
2996 /// stops a sandbox being escaped through a nested namespace. [`HostProbe::probe`] runs this
2997 /// before the sealed probe, and a session's probe is taken before any fork that would
2998 /// build a composed root, so restricting in-process poisons `sealed` on every host and every
2999 /// invocation: the namespace is created and then `mount` is refused, with nothing in the error
3000 /// to suggest the runtime did it to itself.
3001 ///
3002 /// The ruleset is built in the parent because creating one and adding rules restricts
3003 /// nothing; only `restrict_self` applies it. That keeps everything the child does after
3004 /// the fork down to a `prctl` and one syscall.
3005 pub(super) fn probe_landlock_full_access() -> Option<bool> {
3006 use landlock::{
3007 Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
3008 RulesetCreatedAttr, RulesetStatus, ABI,
3009 };
3010
3011 // The child reports which of the three outcomes it reached through its exit status;
3012 // nothing else crosses the fork.
3013 const PROBE_FULLY_ENFORCED: i32 = 0;
3014 const PROBE_PARTIAL: i32 = 1;
3015 const PROBE_FAILED: i32 = 2;
3016
3017 let abi = ABI::V1;
3018 let access_all = AccessFs::from_all(abi);
3019
3020 let root_fd = PathFd::new("/").ok()?;
3021
3022 let ruleset = Ruleset::default()
3023 .set_compatibility(CompatLevel::BestEffort)
3024 .handle_access(access_all)
3025 .ok()?
3026 .create()
3027 .ok()?
3028 .add_rule(PathBeneath::new(root_fd, access_all))
3029 .ok()?;
3030
3031 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the child
3032 // confines itself to async-signal-safe work. Everything this child does is the
3033 // `restrict_self` syscall pair and `_exit`; the allocating part of building the
3034 // ruleset already happened above, in the parent.
3035 let pid = unsafe { libc::fork() };
3036 if pid < 0 {
3037 return None;
3038 }
3039 if pid == 0 {
3040 let code = match ruleset.restrict_self() {
3041 Ok(status) => {
3042 if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
3043 PROBE_FULLY_ENFORCED
3044 } else {
3045 PROBE_PARTIAL
3046 }
3047 }
3048 Err(_) => PROBE_FAILED,
3049 };
3050 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
3051 // which is what keeps the parent's state untouched.
3052 unsafe { libc::_exit(code) }
3053 }
3054
3055 let mut wait_status: libc::c_int = 0;
3056 // SAFETY: `pid` is the child just forked; `wait_status` is a live local.
3057 let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
3058 if waited < 0 || !libc::WIFEXITED(wait_status) {
3059 return None;
3060 }
3061 match libc::WEXITSTATUS(wait_status) {
3062 PROBE_FULLY_ENFORCED => Some(true),
3063 PROBE_PARTIAL => Some(false),
3064 // A `restrict_self` that errored tells us nothing either way, which is the same
3065 // answer the pre-fork version gave by propagating `None` out of `.ok()?`.
3066 _ => None,
3067 }
3068 }
3069
3070 /// Runs inside the forked child, pre-exec: builds the capsule's network namespace and hands
3071 /// its listening sockets to the parent over `child_sock_fd`, strips the child's Linux
3072 /// capabilities, installs the seccomp filter (the `socket()` domain denials keyed on
3073 /// `unix_sockets_allowed`, over a default-deny syscall allowlist), and (on
3074 /// `KernelFull`/`KernelSealed`) applies the Landlock filesystem scope — which is also what
3075 /// enforces `capabilities.shell.allow`. Returning `Err` here aborts the exec (std's `Command`
3076 /// machinery propagates it back to the parent's `.spawn()` call as an `io::Error`) — the
3077 /// fail-closed path for setup failures that happen after fork.
3078 ///
3079 /// `child_sock_fd` carries exactly one hand-off: the namespace sockets.
3080 pub(super) fn child_install_enforcement(
3081 tier: EnforcementTier,
3082 netns_plan: &crate::network_namespace::CapsuleNetnsPlan,
3083 sealed_spec: Option<&crate::sealed::SealedRootSpec>,
3084 landlock_fds: Option<&LandlockChildFds>,
3085 child_sock_fd: RawFd,
3086 unix_sockets_allowed: bool,
3087 ) -> io::Result<()> {
3088 // FIRST, on every kernel tier: the capsule's own network namespace. It has to precede
3089 // everything below it for three separate reasons:
3090 //
3091 // * `SECCOMP_MUST_STAY_DENIED` denies `unshare` and the filter installed further down
3092 // denies `socket(AF_NETLINK)`, so this is the only window in which the namespace can
3093 // be built at all — the same argument the composed root makes.
3094 // * `drop_all_capabilities` (below) removes the `CAP_NET_ADMIN` that bringing `lo` up
3095 // and installing the local route require. The namespace is configured while the child
3096 // still holds them *inside its own new user namespace*, and never afterwards.
3097 // * on `KernelSealed`, the composed root's own `unshare(CLONE_NEWUSER)` nests a second
3098 // user namespace inside this one. A task in a descendant user namespace holds no
3099 // capability in the ancestor, so once that has happened the capsule cannot reconfigure
3100 // the network namespace it is confined to — which only holds if this runs first.
3101 //
3102 // This is what replaced the `connect`/`sendto` seccomp-notify interception. There is no
3103 // path that skips it and falls back: a host that cannot do this refused the launch back in
3104 // `stage_session` (`RuntimeError::EgressNamespaceUnavailable`, `E-CAP-005`).
3105 //
3106 // SAFETY: post-`fork()`, pre-exec child context — exactly the window this function
3107 // documents itself as running in, and the one `create_capsule_netns` requires.
3108 unsafe { crate::network_namespace::create_capsule_netns(netns_plan, child_sock_fd)? };
3109
3110 // NEXT, and only on `KernelSealed`: build the private mount namespace and pivot onto the
3111 // composed root. This has to precede every *remaining* step in this closure, because
3112 // `SECCOMP_MUST_STAY_DENIED` denies `unshare`, `mount` and `pivot_root` to every process
3113 // the filter below covers, permanently and deliberately.
3114 // The composed root is therefore built by this process while it still has its pre-filter
3115 // credentials, never by a syscall the sandboxed subprocess is later permitted to make.
3116 // The three steps that follow then run *inside* the new root: `drop_all_capabilities`
3117 // strips the (namespace-local) capabilities `unshare` handed out, seccomp installs, and
3118 // Landlock's rules — built from fds the parent opened, so unaffected by the pivot —
3119 // re-scope the same inodes under their new pathnames. Defence in depth, in that order.
3120 if tier == EnforcementTier::KernelSealed {
3121 if let Some(spec) = sealed_spec {
3122 #[cfg(test)]
3123 if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
3124 return Err(io::Error::other(format!(
3125 "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
3126 crate::sealed::SEALED_ROOT_FAILURE_PREFIX
3127 )));
3128 }
3129 crate::sealed::construct_composed_root(spec)
3130 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
3131 }
3132 }
3133
3134 // Before anything else in this window, on every kernel tier: shut the fd-inheritance
3135 // door. Landlock (KernelFull only) mediates *new* filesystem operations against the
3136 // ruleset installed further down this function — it can do nothing about a descriptor
3137 // the child already holds open at that point, because that fd was opened before the
3138 // ruleset existed and there is no operation left for the ruleset to intercept. Until
3139 // this call existed, the only thing stopping a spawned shell from inheriting an
3140 // arbitrary open fd was the empirical accident that nothing else happened to be open in
3141 // the runtime process at spawn time; one `open()` added anywhere before a spawn — a
3142 // config read, a lock file, a log handle — would have leaked silently into every
3143 // subsequent subprocess, on every platform and every tier, with no test to catch it.
3144 //
3145 // Running it first is also what makes it cover this function's *own* descriptors: the
3146 // namespace-socket socketpair end, the diagnostic pipe and the Landlock grant fds are all used
3147 // below but must never survive the `execve`.
3148 mark_inherited_fds_cloexec()?;
3149
3150 // Next, and on every kernel tier (not just `KernelFull`): Landlock and the Linux
3151 // capability model are independent, both-must-allow gates, so narrowing the Landlock
3152 // workdir grant does not by itself take `CAP_MKNOD` away from a root-uid capsule. This
3153 // must also run *before* `install_seccomp_filter`, because it is what sets
3154 // `no_new_privs` — which `seccomp(2)` requires once `CAP_SYS_ADMIN` is gone. Setting it
3155 // here (rather than depending on libseccomp's `SCMP_FLTATR_CTL_NNP` default) also means a
3156 // failure is its own distinct, fail-closed error path before any filter is installed.
3157 drop_all_capabilities()?;
3158
3159 // The child stays *non-dumpable*, inherited from the runtime process
3160 // (`security::harden_process_dumpable`). Nothing reads the child's memory, so nothing
3161 // needs it dumpable. Marking it dumpable opens same-uid `ptrace`, `environ` and core-dump
3162 // access for the whole life of every shell subprocess: do not do so without a reader that
3163 // requires it.
3164 install_seccomp_filter(unix_sockets_allowed)?;
3165
3166 if matches!(
3167 tier,
3168 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3169 ) {
3170 if let Some(fds) = landlock_fds {
3171 apply_landlock_scope(fds).map_err(io::Error::other)?;
3172 }
3173 }
3174
3175 Ok(())
3176 }
3177
3178 /// Marks every file descriptor at or above [`super::FD_HYGIENE_FIRST_FD`] close-on-exec, so
3179 /// the only descriptors that survive into the exec'd program are the ones the runtime
3180 /// deliberately set up (stdio) rather than whatever happened to be open.
3181 ///
3182 /// `CLOSE_RANGE_CLOEXEC` sets the flag rather than closing the descriptors outright. That is
3183 /// the required semantics here, not a softer variant of it: several fds in this range are
3184 /// still needed *inside* this `pre_exec` window — the socketpair end `create_capsule_netns`
3185 /// sends the namespace's listening sockets over, the diagnostic pipe a failure further down
3186 /// writes to, and the Landlock grant fds `apply_landlock_scope` builds rules
3187 /// from. Closing them here would break the very enforcement setup that follows; flagging
3188 /// them means they keep working until `execve`, which then closes them for us.
3189 ///
3190 /// The range starts at 3, never 0 — see [`super::FD_HYGIENE_FIRST_FD`] for the recorded
3191 /// stdio decision and its reasoning.
3192 ///
3193 /// **Kernel range narrowing:** `close_range(2)` landed in Linux 5.9 and
3194 /// `CLOSE_RANGE_CLOEXEC` in Linux 5.11. On an older kernel this call fails (`ENOSYS`, or
3195 /// `EINVAL` for the flag on 5.9/5.10), the error propagates, and `Command::spawn()` fails —
3196 /// so every shell spawn on a `KernelSeccompOnly` *or* `KernelFull` host with such a kernel
3197 /// now fails rather than silently running without fd hygiene. That is deliberate and it is
3198 /// this module's established fail-closed discipline (`drop_all_capabilities`,
3199 /// `install_seccomp_filter` and `apply_landlock_scope` all abort the spawn on an unexpected
3200 /// error rather than degrade), but it is a real, user-visible narrowing of the supported
3201 /// kernel range for kernel-enforcement tiers.
3202 pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
3203 // SAFETY: this issues the `close_range` syscall directly through `libc::syscall`. The
3204 // three variadic arguments are all `c_uint` scalars and the kernel dereferences none of
3205 // them. It is a single syscall, so it is safe to make in the post-fork/pre-exec window.
3206 // `c_uint::MAX` as the `last` argument is the documented "to the end of the table"
3207 // spelling from `close_range(2)`.
3208 let rc = unsafe {
3209 libc::syscall(
3210 libc::SYS_close_range,
3211 super::FD_HYGIENE_FIRST_FD,
3212 libc::c_uint::MAX,
3213 libc::CLOSE_RANGE_CLOEXEC,
3214 )
3215 };
3216 if rc != 0 {
3217 return Err(io::Error::last_os_error());
3218 }
3219 Ok(())
3220 }
3221
3222 /// Capability-version word for the 64-bit, two-`__u32`-word capability ABI
3223 /// (`_LINUX_CAPABILITY_VERSION_3`, Linux 2.6.26+). Passing it tells the kernel the
3224 /// `cap_user_data` argument is a **two element** array. The older version-1 word would cover
3225 /// only capabilities 0–31 and make the kernel log a deprecation warning.
3226 const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
3227
3228 /// Upper bound for the bounding-set loop when `/proc/sys/kernel/cap_last_cap` cannot be read.
3229 /// 63 is the architectural ceiling — `capset`'s two 32-bit words address exactly 64
3230 /// capabilities — so this can never under-cover. Numbers the running kernel does not define
3231 /// return `EINVAL`, which the loop skips.
3232 const CAP_LAST_CAP_FALLBACK: u32 = 63;
3233
3234 /// `cap_user_header_t` from `<linux/capability.h>`. `libc` 0.2 exposes `SYS_capset` but
3235 /// neither this struct, its data counterpart, nor a safe `capset()` wrapper — so both are
3236 /// hand-rolled here, for the same reason `send_fd_over_socket` hand-rolls its
3237 /// `msghdr`/`cmsghdr` handling.
3238 ///
3239 /// `dead_code` is allowed because the kernel reads these fields through the raw pointer
3240 /// handed to `capset(2)`; Rust itself only ever writes them.
3241 #[repr(C)]
3242 #[allow(dead_code)]
3243 struct CapUserHeader {
3244 version: u32,
3245 pid: libc::c_int,
3246 }
3247
3248 /// One word of `cap_user_data_t` from `<linux/capability.h>`. Under
3249 /// [`LINUX_CAPABILITY_VERSION_3`] the kernel expects an array of exactly two of these: word 0
3250 /// carries capabilities 0–31, word 1 carries 32–63. See [`CapUserHeader`] for the `dead_code`
3251 /// note.
3252 #[repr(C)]
3253 #[derive(Clone, Copy)]
3254 #[allow(dead_code)]
3255 struct CapUserData {
3256 effective: u32,
3257 permitted: u32,
3258 inheritable: u32,
3259 }
3260
3261 /// Strips every Linux capability from the forked child before `execve()`, in three steps
3262 /// whose **order matters**:
3263 ///
3264 /// 1. drop the whole capability **bounding set** (`PR_CAPBSET_DROP`) — while this process
3265 /// still holds `CAP_SETPCAP` in its *effective* set, because that is exactly what
3266 /// `PR_CAPBSET_DROP` checks (`capabilities(7)`, `prctl(2)`).
3267 /// 2. clear this process's own permitted/effective/inheritable sets via `capset(2)`.
3268 /// Shrinking your own permitted set to any subset — including empty — never requires
3269 /// `CAP_SETPCAP`, so running it after step 1 costs nothing. Clearing permitted and
3270 /// inheritable also empties the *ambient* set, which the kernel maintains as a subset of
3271 /// both — that is what covers a non-root capsule handed an unexpected ambient
3272 /// `CAP_MKNOD` (e.g. a systemd unit with `AmbientCapabilities=`).
3273 /// 3. set `no_new_privs`, so `execve()` cannot regain privilege through a set-user-ID or
3274 /// file-capability binary, and so the seccomp filter installed next can load without
3275 /// `CAP_SYS_ADMIN` (which step 2 just removed). Setting it here explicitly means this no
3276 /// longer depends on libseccomp's default `SCMP_FLTATR_CTL_NNP` attribute having set it
3277 /// as a side effect.
3278 ///
3279 /// **Why steps 1 and 2 are in this order** (it is the reverse of the obvious "clear my sets,
3280 /// then clear the bounding set"): `PR_CAPBSET_DROP` requires `CAP_SETPCAP` in the *effective*
3281 /// set of the caller. Clearing effective first would strip `CAP_SETPCAP`, after which every
3282 /// `PR_CAPBSET_DROP` returns `EPERM` — and because `EPERM` is (correctly) non-fatal for a
3283 /// genuinely unprivileged caller, the bounding set would silently survive while this function
3284 /// still reported success.
3285 ///
3286 /// **Why the bounding set is the load-bearing step for a root-uid capsule:** `execve(2)`'s
3287 /// capability transition treats a file's permitted set as all-ones when the process's real
3288 /// uid is 0, so the new program's permitted set comes out as `P(bounding)`. Step 2 alone
3289 /// would be undone by the very next `execve`; emptying the bounding set is what makes the
3290 /// post-exec permitted (and hence effective) set empty, which is what actually takes
3291 /// `CAP_MKNOD` away from the shell.
3292 ///
3293 /// Note this also removes `CAP_DAC_OVERRIDE` from a root-run capsule's shell subprocess, so
3294 /// it no longer bypasses ordinary file-permission checks. That is intended — least privilege
3295 /// — and strictly narrows what the subprocess can reach.
3296 fn drop_all_capabilities() -> io::Result<()> {
3297 drop_capability_bounding_set()?;
3298 clear_capability_sets()?;
3299 set_no_new_privs()
3300 }
3301
3302 /// Step 1 of [`drop_all_capabilities`]. Two errnos are expected and skipped:
3303 ///
3304 /// - `EINVAL` — `cap` is not a capability this kernel defines, so there is nothing to drop.
3305 /// - `EPERM` — the caller holds no `CAP_SETPCAP`, i.e. it is a genuinely unprivileged
3306 /// process that never held the capability being dropped either. Hard-failing here would
3307 /// break every non-root `mur run`.
3308 ///
3309 /// Any other errno is unexpected and propagates, aborting the exec — this module's
3310 /// fail-closed invariant: no silently-unenforced spawn.
3311 #[allow(unsafe_code)]
3312 fn drop_capability_bounding_set() -> io::Result<()> {
3313 let zero: libc::c_ulong = 0;
3314
3315 for cap in 0..=kernel_last_cap() {
3316 let cap_arg = libc::c_ulong::from(cap);
3317 // SAFETY: `prctl(PR_CAPBSET_DROP, ...)` takes an integer capability number and three
3318 // ignored arguments — no pointers and no borrowed memory are involved.
3319 let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
3320 if rc == 0 {
3321 continue;
3322 }
3323
3324 let error = io::Error::last_os_error();
3325 match error.raw_os_error() {
3326 Some(libc::EINVAL) | Some(libc::EPERM) => continue,
3327 _ => return Err(error),
3328 }
3329 }
3330
3331 Ok(())
3332 }
3333
3334 /// Step 2 of [`drop_all_capabilities`]. Always expected to succeed: the kernel's `capset`
3335 /// checks are all "is the new set a subset of the old one", and the empty set is a subset of
3336 /// everything. Any error is therefore unexpected and fails closed.
3337 #[allow(unsafe_code)]
3338 fn clear_capability_sets() -> io::Result<()> {
3339 let header = CapUserHeader {
3340 version: LINUX_CAPABILITY_VERSION_3,
3341 // 0 means "the calling thread" — the only pid `capset` accepts for a capability
3342 // change since Linux 2.6.25.
3343 pid: 0,
3344 };
3345 let data = [CapUserData {
3346 effective: 0,
3347 permitted: 0,
3348 inheritable: 0,
3349 }; 2];
3350
3351 // SAFETY: both pointers refer to stack locals that outlive this single `capset` call, and
3352 // `data` is exactly the two-element array `LINUX_CAPABILITY_VERSION_3` makes the kernel
3353 // expect. The kernel only reads through them.
3354 let rc = unsafe {
3355 libc::syscall(
3356 libc::SYS_capset,
3357 &header as *const CapUserHeader,
3358 data.as_ptr(),
3359 )
3360 };
3361 if rc != 0 {
3362 return Err(io::Error::last_os_error());
3363 }
3364
3365 Ok(())
3366 }
3367
3368 /// Step 3 of [`drop_all_capabilities`]. Setting `no_new_privs` can only ever *reduce* a
3369 /// process's privilege and is never refused for a well-formed call, so any error is
3370 /// unexpected and fails closed. Same call convention and fail-closed style as
3371 /// `security::harden_process_dumpable`'s `PR_SET_DUMPABLE` `prctl`, but a different call site
3372 /// and lifetime: this is per-shell-subprocess, inside `pre_exec`, not the once-at-`main()`
3373 /// whole-process hardening.
3374 #[allow(unsafe_code)]
3375 fn set_no_new_privs() -> io::Result<()> {
3376 #[cfg(test)]
3377 if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
3378 return Err(io::Error::other(
3379 "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
3380 ));
3381 }
3382 // The kernel rejects `PR_SET_NO_NEW_PRIVS` unless arg2 is exactly 1 and arg3/4/5 are
3383 // exactly 0, so these are typed as `c_ulong` rather than left as untyped literals whose
3384 // upper 32 bits would be unspecified in a variadic call on aarch64.
3385 let enable: libc::c_ulong = 1;
3386 let zero: libc::c_ulong = 0;
3387
3388 // SAFETY: `prctl(PR_SET_NO_NEW_PRIVS, ...)` takes four integer arguments — no pointers
3389 // and no borrowed memory are involved.
3390 let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
3391 if rc != 0 {
3392 return Err(io::Error::last_os_error());
3393 }
3394
3395 Ok(())
3396 }
3397
3398 /// Highest capability number this kernel defines, read from `/proc/sys/kernel/cap_last_cap`.
3399 /// Uses raw `open`/`read` into a stack buffer so it stays allocation-free in the post-`fork()`
3400 /// window. Any failure — file absent on a pre-2.6.25 kernel, unreadable, contents that do not
3401 /// parse — falls back to [`CAP_LAST_CAP_FALLBACK`], which over-covers rather than
3402 /// under-covers, since undefined capability numbers just return the `EINVAL` the caller skips.
3403 #[allow(unsafe_code)]
3404 fn kernel_last_cap() -> u32 {
3405 const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
3406
3407 // SAFETY: `PATH` is a NUL-terminated byte literal with `'static` lifetime, and `open`
3408 // only reads through the pointer.
3409 let fd = unsafe {
3410 libc::open(
3411 PATH.as_ptr() as *const libc::c_char,
3412 libc::O_RDONLY | libc::O_CLOEXEC,
3413 )
3414 };
3415 if fd < 0 {
3416 return CAP_LAST_CAP_FALLBACK;
3417 }
3418
3419 let mut buf = [0u8; 16];
3420 // SAFETY: `fd` was just opened successfully; `buf` is a stack array and its exact length
3421 // is passed, so `read` cannot write past it.
3422 let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
3423 // SAFETY: `fd` is open and exclusively owned here; nothing else holds or will reuse it.
3424 unsafe { libc::close(fd) };
3425
3426 if read <= 0 {
3427 return CAP_LAST_CAP_FALLBACK;
3428 }
3429
3430 let parsed = std::str::from_utf8(&buf[..read as usize])
3431 .ok()
3432 .and_then(|text| text.trim().parse::<u32>().ok());
3433
3434 match parsed {
3435 Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
3436 _ => CAP_LAST_CAP_FALLBACK,
3437 }
3438 }
3439
3440 /// The Landlock syscalls the child still has to make after its seccomp filter is loaded, each
3441 /// with the syscall number to fall back on when this host's libseccomp is too old to resolve
3442 /// the name (they were added in libseccomp 2.5.4; Ubuntu 22.04 ships 2.5.3 on a kernel that
3443 /// does support Landlock, so this is a real combination, not a hypothetical one).
3444 ///
3445 /// Linux assigned these numbers uniformly across architectures in 5.13 — 444/445/446 on
3446 /// x86_64, aarch64 and every other arch with Landlock — so a single number per name is
3447 /// correct for all of them, unlike the legacy syscalls where numbers diverge per arch.
3448 const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
3449 ("landlock_create_ruleset", 444),
3450 ("landlock_add_rule", 445),
3451 ("landlock_restrict_self", 446),
3452 ];
3453
3454 /// Builds the child's seccomp filter and returns it unloaded, so that the same construction
3455 /// the spawn path uses can also be measured. [`install_seccomp_filter`] is the only caller
3456 /// that loads it; the measurement of what it compiles to, the kernel ceilings that number is
3457 /// held against and the headroom between them are recorded in
3458 /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
3459 ///
3460 /// **This filter raises no notifications and needs no supervisor.** Network enforcement lives
3461 /// in [`crate::network_namespace`] plus [`crate::egress_proxy`], and exec enforcement in the
3462 /// Landlock domain `apply_landlock_scope` installs — see this module's own header for why no
3463 /// `Notify` rule may come back.
3464 ///
3465 /// One mechanism plus a default:
3466 ///
3467 /// - `socket` gets classic, register-value `Errno` rules, one per denied domain. `domain` is
3468 /// a plain integer argument, so the comparison compiles straight into the loaded BPF
3469 /// program and is evaluated in-kernel at syscall time: no notification is raised and no
3470 /// memory of another task is read. That is what makes it structurally immune to the
3471 /// pointer-read TOCTOU class of problem in the first place.
3472 /// - the filter's **default action**, which is a deny (`Errno(EPERM)`): a syscall named by
3473 /// neither [`super::SECCOMP_SYSCALL_ALLOWLIST`] nor a rule here is refused, without any
3474 /// argument being inspected. That is what makes `io_uring_setup`, `bpf`, `userfaultfd`,
3475 /// `perf_event_open`, `ptrace` and the rest of [`super::SECCOMP_MUST_STAY_DENIED`]
3476 /// unreachable — they are simply absent, not argument-matched.
3477 fn build_seccomp_filter(
3478 unix_sockets_allowed: bool,
3479 ) -> io::Result<libseccomp::ScmpFilterContext> {
3480 // Default-deny. `EPERM` matches what the OCI/Docker default profile returns for a syscall
3481 // outside its allowlist, and is deliberately a *different* errno from the `EACCES` that the
3482 // `socket()` domain rules (and Landlock) return: `EACCES` means "the sandbox looked at this
3483 // call's arguments and refused it", `EPERM` means "this syscall is not part of the
3484 // capsule's syscall surface at all". Keeping them distinct is what lets an operator tell an
3485 // unmediated-surface problem from a policy decision.
3486 let mut filter =
3487 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
3488 .map_err(to_io_err)?;
3489
3490 // Ask the kernel to record every action this filter takes that is not `Allow` in the
3491 // audit trail, so a denial is attributable after the fact to a syscall number, pid and
3492 // comm. The denied process itself still observes nothing but `EPERM` — `SECCOMP_RET_ERRNO`
3493 // has no channel for anything richer — so this is the only way an operator debugging a
3494 // workload that dies on an unexpected denial gets a syscall name to look at.
3495 //
3496 // Best-effort on purpose, and the one place in this function where an error is swallowed:
3497 // `SCMP_FLTATR_CTL_LOG` needs libseccomp API level 3 (libseccomp 2.4.0+), and the crate
3498 // reports an older runtime library as an error here. That is a *diagnosability* shortfall,
3499 // not an enforcement one — the default-deny action and every rule below are unaffected —
3500 // and turning it into a hard failure would take a host with an old libseccomp from
3501 // "denials are logged less legibly" to "no capsule can spawn a shell at all".
3502 //
3503 // Setting the attribute is necessary but not sufficient: the kernel only logs an action
3504 // whose type also appears in `/proc/sys/kernel/seccomp/actions_logged`, which is host
3505 // configuration this process does not control.
3506 // `ESCAPE_CONFORMANCE_HARNESS.md` at the repository root carries the hand-run procedure
3507 // these denials are verified by.
3508 let _ = filter.set_ctl_log(true);
3509
3510 // Deny the dangerous `socket(2)` domains at creation time. `EACCES` (not `EPERM`) is the
3511 // errno this sandbox uses for "the sandbox looked at this call's arguments and refused it",
3512 // as opposed to the default action's `EPERM` for "this syscall is not part of the capsule's
3513 // surface at all". It also matches what Landlock returns for a denied exec, so a capsule
3514 // sees one consistent errno across both argument-level mechanisms.
3515 //
3516 // Untouched by the network-namespace work, and deliberately so: a network namespace does
3517 // not mediate `AF_UNIX` at all — pathname or abstract — so this register-level rule is
3518 // still the only thing standing between a capsule and `/var/run/docker.sock`.
3519 //
3520 // `add_rule_conditional` (not `..._exact`) on purpose: the non-exact form lets libseccomp
3521 // adapt the rule to the architectures in the filter — on an arch where `socket` is
3522 // reached through `socketcall(2)` rather than as its own syscall, the exact form would
3523 // fail the whole install instead. Fail-closed is preserved either way, since any error
3524 // here propagates out of `pre_exec` and aborts the spawn.
3525 //
3526 // One rule per domain rather than one rule with three comparators: comparators within a
3527 // single rule are AND-ed, and no `socket()` call has a `domain` that is simultaneously
3528 // `AF_UNIX` and `AF_NETLINK`, so a combined rule would match nothing.
3529 let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
3530 for domain in super::denied_socket_domains(unix_sockets_allowed) {
3531 filter
3532 .add_rule_conditional(
3533 libseccomp::ScmpAction::Errno(libc::EACCES),
3534 socket_syscall,
3535 &[libseccomp::ScmpArgCompare::new(
3536 0,
3537 libseccomp::ScmpCompareOp::Equal,
3538 domain as u64,
3539 )],
3540 )
3541 .map_err(to_io_err)?;
3542 }
3543
3544 // The permitted domains need positive rules of their own now that the filter's default is
3545 // a deny — without them `socket()` would be refused for every family, IP included.
3546 //
3547 // Equality rules per allowed domain, and NOT one unconditional `Allow` rule for `socket`:
3548 // libseccomp resolves an unconditional rule for a syscall by *discarding* every
3549 // argument-conditional chain already recorded for it (`db.c`: "syscall exists with chains
3550 // but the new filter has no chains so the existing chains must be cleared"), so a broad
3551 // `Allow` here would silently delete the AF_UNIX/AF_NETLINK/AF_PACKET denials directly
3552 // above and reopen the `/var/run/docker.sock` path they exist to close. containerd's
3553 // default profile carries the same warning against combining a broad `socket` allow with
3554 // explicit errno rules, and works around it the same way — with argument-matched rules
3555 // only. The two domain sets are disjoint by construction (a test pins that), so no
3556 // `socket()` call can match both an allow and a deny rule.
3557 for domain in super::allowed_socket_domains(unix_sockets_allowed) {
3558 filter
3559 .add_rule_conditional(
3560 libseccomp::ScmpAction::Allow,
3561 socket_syscall,
3562 &[libseccomp::ScmpArgCompare::new(
3563 0,
3564 libseccomp::ScmpCompareOp::Equal,
3565 domain as u64,
3566 )],
3567 )
3568 .map_err(to_io_err)?;
3569 }
3570
3571 // The allowlist proper. Two failure modes are both handled by skipping the name:
3572 //
3573 // - `from_name` fails when this host's libseccomp does not know the syscall (a name
3574 // newer than the installed library);
3575 // - `add_rule` fails with `EDOM` when the name resolves to a pseudo-syscall that does
3576 // not exist on this architecture — every legacy x86_64 spelling in the array
3577 // (`open`, `dup2`, `poll`, `stat`, ...) hits this on aarch64.
3578 //
3579 // Skipping is fail-closed: a name that gets no rule stays denied by the default action,
3580 // so the filter can only ever come out *narrower* than intended, never wider. Propagating
3581 // instead would abort every single spawn on the first arch-specific name, which is why
3582 // this is the deliberate exception to the module's otherwise-strict error handling.
3583 for name in super::SECCOMP_SYSCALL_ALLOWLIST {
3584 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
3585 let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
3586 }
3587 }
3588
3589 // Landlock's three syscalls, which `child_install_enforcement` calls *after* this filter
3590 // is loaded (`apply_landlock_scope`, on `KernelFull`). Without these rules the default
3591 // deny would refuse the child's own filesystem scoping and abort every spawn on that
3592 // tier. Allowing them costs nothing: a Landlock domain can only ever narrow the process
3593 // that installs it, never widen it, and Docker's default profile allows them too.
3594 //
3595 // Resolved by name where possible, with the syscall number as a fallback, because these
3596 // names only became known to libseccomp in 2.5.4 — the "skip what does not resolve"
3597 // policy above would silently turn an older libseccomp into "no capsule can run a shell
3598 // on a Landlock-capable host". The numbers are the same on every architecture Linux has
3599 // added Landlock to (they were assigned after the syscall-number unification), so the
3600 // fallback is not architecture-specific.
3601 for (name, fallback_nr) in LANDLOCK_SYSCALLS {
3602 let syscall = libseccomp::ScmpSyscall::from_name(name)
3603 .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
3604 filter
3605 .add_rule(libseccomp::ScmpAction::Allow, syscall)
3606 .map_err(to_io_err)?;
3607 }
3608
3609 Ok(filter)
3610 }
3611
3612 /// Loads the filter [`build_seccomp_filter`] produces into the calling task. Irreversible:
3613 /// the filter stays in force for the rest of this task's life and is inherited across the
3614 /// `execve` that follows, which is why nothing outside a forked child ever calls this.
3615 fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
3616 // Dropping the context after `load()` is safe: libseccomp's `seccomp_release()` (invoked
3617 // on Drop) only frees the userspace filter-building context, while the filter itself is
3618 // already installed in the kernel and is inherited across the `execve` that follows.
3619 build_seccomp_filter(unix_sockets_allowed)?
3620 .load()
3621 .map_err(to_io_err)
3622 }
3623
3624 fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
3625 io::Error::other(error.to_string())
3626 }
3627
3628 /// Size of one `struct sock_filter`, the record `seccomp_export_bpf` writes per BPF
3629 /// instruction. Dividing an export's length by it is what turns bytes into an instruction
3630 /// count.
3631 #[cfg(test)]
3632 const SOCK_FILTER_BYTES: usize = 8;
3633
3634 /// Writes `export` — one of libseccomp's two exporters — into an anonymous in-memory file and
3635 /// returns what it wrote.
3636 ///
3637 /// Both exporters take a descriptor and nothing else, so the buffer has to come from
3638 /// somewhere. A `memfd` rather than a temporary file because it touches no filesystem the
3639 /// sandbox might already have scoped away, and because `tempfile` is a dev-dependency of this
3640 /// crate and so is unavailable to anything outside a test build.
3641 #[cfg(test)]
3642 fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
3643 where
3644 F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
3645 {
3646 use std::io::{Read, Seek, SeekFrom};
3647 use std::os::fd::FromRawFd;
3648
3649 // SAFETY: `memfd_create` reads a NUL-terminated name (a `c"..."` literal, static
3650 // lifetime) and returns a fresh descriptor or -1. No flags, so no cloexec and no sealing:
3651 // the descriptor lives and dies inside this function.
3652 let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
3653 if raw < 0 {
3654 return Err(io::Error::last_os_error());
3655 }
3656 // SAFETY: `raw` is a descriptor `memfd_create` just returned and nothing else owns, which
3657 // is exactly the precondition for handing it to `OwnedFd`.
3658 let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
3659
3660 export(&memfd).map_err(to_io_err)?;
3661
3662 let mut file = std::fs::File::from(memfd);
3663 file.seek(SeekFrom::Start(0))?;
3664 let mut exported = Vec::new();
3665 file.read_to_end(&mut exported)?;
3666 Ok(exported)
3667 }
3668
3669 /// The number of BPF instructions the filter [`build_seccomp_filter`] produces compiles to on
3670 /// this host, for the given `unix_sockets_allowed`.
3671 ///
3672 /// Measured, never assumed: `seccomp_export_bpf` writes the generated program as a sequence of
3673 /// 8-byte `struct sock_filter` records, so the instruction count is the exported length
3674 /// divided by that record size. The number depends on the host — how many
3675 /// [`super::SECCOMP_SYSCALL_ALLOWLIST`] names this architecture and this libseccomp resolve —
3676 /// which is why it is measured here rather than written down.
3677 ///
3678 /// `export_bpf` and not `export_bpf_mem`: the latter binds `seccomp_export_bpf_mem`, which
3679 /// first appeared in libseccomp 2.6.0, and a host on 2.5.x would fail to resolve the symbol.
3680 #[cfg(test)]
3681 fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
3682 let filter = build_seccomp_filter(unix_sockets_allowed)?;
3683 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
3684 Ok(exported.len() / SOCK_FILTER_BYTES)
3685 }
3686
3687 /// The filter [`build_seccomp_filter`] produces, rendered as libseccomp's pseudo filter code:
3688 /// one block per syscall carrying a rule, each block naming the argument comparisons and the
3689 /// action. It is the only view of a built context that shows the rule *shape* rather than the
3690 /// compiled program's size, which is what a test pinning the rule sequence needs.
3691 #[cfg(test)]
3692 fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
3693 let filter = build_seccomp_filter(unix_sockets_allowed)?;
3694 let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
3695 String::from_utf8(exported).map_err(to_io_err)
3696 }
3697
3698 /// Scopes the shell subprocess tree's filesystem access with Landlock. Four kinds of rule:
3699 ///
3700 /// - the workdir gets [`workdir_access_rights`] — read/write plus directory, regular-file,
3701 /// FIFO and symlink creation, but **not** `MakeChar`/`MakeBlock`/`MakeSock`, and **not**
3702 /// `Execute` unless the capsule declared `capabilities.filesystem.workdir_exec: true` (see
3703 /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] for why each right is withheld). Withholding `Execute`
3704 /// is what enforces `capabilities.shell.allow` completely: the kernel refuses the exec on
3705 /// the path it resolved itself, so nothing the capsule writes into its workdir can run;
3706 /// - on `KernelSealed` only, the workdir-backed `/tmp` store gets that **same** right-set
3707 /// from the same local binding. `plan_composed_root` binds `<workdir>/.mur-tmp` at `/tmp`
3708 /// read-write, but Landlock matches an access to `/tmp/x` against the bind's own root inode
3709 /// rather than the workdir's, so without this rule the mount is writable and every access
3710 /// to it is nevertheless `EACCES` — bash heredocs, `mktemp`, a compiler's intermediate
3711 /// objects and a package manager's build scratch all fail. Identical rights are the honest
3712 /// ones: it is the same storage, counted by the same `workdir_max_bytes` guard, so it must
3713 /// be neither more nor less runnable than the workdir itself;
3714 /// - each [`LandlockGrant`] (the `shell.allow` binaries, their ELF interpreter, their
3715 /// shared-library closure, any `interpreter_runtime` or `staged_runtime` directory, and on
3716 /// `KernelSealed` the fixed [`crate::sealed::SEALED_RUNTIME_PATHS`] and
3717 /// [`crate::sealed::SEALED_ETC_PATHS`], all *outside* the workdir) gets a **narrow read**
3718 /// grant — never write — with two bits added on top.
3719 /// Whether it carries `ReadDir` (i.e. the directory's own entries are enumerable) is
3720 /// exactly the grant's `list_dir`: the derived closure files are all `false` (a regular
3721 /// file has no meaningful `ReadDir`), while an `interpreter_runtime` directory carries
3722 /// whatever its author wrote. `ReadDir` is granted only on the specific inode a rule names
3723 /// — never on an ancestor or sibling — so naming one subdirectory never makes `/usr/lib`
3724 /// (or any parent) enumerable. Whether it carries `Execute` is the grant's `executable`,
3725 /// `true` everywhere except the two tier-issued sealed grants, which are deliberately
3726 /// readable (and, on a directory, enumerable) but not runnable;
3727 /// - each entry of the fixed [`CAPSULE_DEVICE_GRANTS`] set gets `ReadFile`, plus `WriteFile`
3728 /// if its `writable` bit is set. That bit is set for exactly one path — `/dev/null` — which
3729 /// is therefore the *only* writable path outside the workdir in the whole sandbox. This
3730 /// list is a compile-time constant, not manifest-derived: no capability declaration widens
3731 /// or narrows it. Every device path it does not name stays denied by the ordinary
3732 /// no-matching-rule path below.
3733 ///
3734 /// Without the second kind of rule, `restrict_self()` denies `Execute`/`ReadFile` on every
3735 /// path outside the workdir, so every allowlisted binary's `execve` fails with EACCES before
3736 /// it runs (and even a binary placed *inside* the workdir can't be loaded, because its dynamic
3737 /// loader must still read `ld-linux`/libc outside it). The narrow grants fix that while keeping
3738 /// the security story honest: apart from `/dev/null`, nothing outside the workdir is writable,
3739 /// so a write to any other outside path — including one of these read+execute grant paths — is
3740 /// still denied.
3741 ///
3742 /// `handle_access` must declare the union of every access bit any rule uses, and it stays the
3743 /// **full** ABI v1 set deliberately: a bit declared there but granted by no rule is denied for
3744 /// the whole domain, which is exactly how the rights missing from
3745 /// [`WORKDIR_ACCESS_RIGHTS_NO_EXEC`] become denied rather than merely un-granted, and it is also what
3746 /// makes every device outside [`CAPSULE_DEVICE_GRANTS`] denied. A grant path — or a device
3747 /// path, on a host where one of the three is missing — that
3748 /// fails to *open* is skipped
3749 /// (shrink-not-fail, matching `resolve_exec_allowlist`) rather than failing the whole scope —
3750 /// it only narrows what is allowed. A genuine ruleset-construction failure still returns `Err`,
3751 /// preserving the module's fail-closed invariant.
3752 fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
3753 #[cfg(test)]
3754 if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
3755 return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
3756 }
3757
3758 use landlock::{
3759 Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
3760 RulesetCreatedAttr, ABI,
3761 };
3762
3763 let abi = ABI::V1;
3764 let access_all = AccessFs::from_all(abi);
3765 // Enough for the loader to open, read, and map-execute a binary or shared library, and to
3766 // open a file inside a granted directory by exact name — but no write access, and (without
3767 // `ReadDir`) no enumeration of the directory's own entries.
3768 let read_execute = AccessFs::Execute | AccessFs::ReadFile;
3769 // Adds enumerability (`getdents64`) — only for a grant carrying `list_dir: true`.
3770 let read_execute_list = read_execute | AccessFs::ReadDir;
3771 // `ReadFile + ReadDir`, no `Execute` — readable and enumerable but not runnable. See
3772 // `resolve_sealed_runtime_landlock_grants` and `resolve_sealed_etc_landlock_grants` for why
3773 // `Execute` is withheld from a tier-issued grant over trees the manifest never named.
3774 let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
3775 // `ReadFile` alone: the same tier-issued grant on an entry that is a *file* rather than a
3776 // directory (most of `SEALED_ETC_PATHS`). Not cosmetic — `landlock_append_fs_rule` rejects
3777 // `ReadDir` on a non-directory with `EINVAL`, and a rejected rule fails the launch below.
3778 let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
3779 // Device rights. No `Execute` (a character device is never exec'd) and no `ReadDir` (it is
3780 // not a directory) — only the two data bits, with `WriteFile` reserved for `/dev/null`.
3781 let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
3782 let device_read_write = device_read | AccessFs::WriteFile;
3783
3784 // The one writable right-set in this function, resolved once and used by both rules that
3785 // carry it. The composed root's `/tmp` is a bind of a directory inside the workdir — the
3786 // same storage, under the same size guard — so it gets the same rights, `Execute` axis
3787 // included: `workdir_exec: false` must no more permit running a binary from `/tmp` than
3788 // from the workdir it is stored in. Sharing the binding is what keeps that true; two
3789 // separate calls could drift apart.
3790 let writable_rights = workdir_access_rights(fds.workdir_exec);
3791
3792 // Every fd here was already opened in the parent (before fork). This function performs no
3793 // `open()`/`canonicalize()` — only the Landlock ruleset syscalls against already-open fds,
3794 // so a failure here can only be the kernel call itself, not an unrelated path resolution.
3795 let mut ruleset = Ruleset::default()
3796 .set_compatibility(CompatLevel::HardRequirement)
3797 .handle_access(access_all)
3798 .map_err(|error| format!("landlock: handle_access failed: {error}"))?
3799 .create()
3800 .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
3801 .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
3802 .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
3803
3804 // `KernelSealed` only — on every other tier there is no composed root and this is `None`.
3805 // A rule on the workdir alone does not reach the bind mounted over `/tmp`; see
3806 // [`LandlockChildFds::sealed_tmp_fd`].
3807 if let Some(sealed_tmp_fd) = &fds.sealed_tmp_fd {
3808 ruleset = ruleset
3809 .add_rule(PathBeneath::new(sealed_tmp_fd.as_fd(), writable_rights))
3810 .map_err(|error| {
3811 format!("landlock: add_rule for the workdir-backed /tmp failed: {error}")
3812 })?;
3813 }
3814
3815 // `KernelSealed` only — empty on every other tier. `ReadFile` alone: these are the two
3816 // synthetic account databases the root binds read-only at `/etc/passwd`/`/etc/group`, and
3817 // the grant loop's rules for those two paths are taken on the host's files, not on these.
3818 for identity_fd in &fds.sealed_identity_fds {
3819 ruleset = ruleset
3820 .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only))
3821 .map_err(|error| {
3822 format!("landlock: add_rule for a synthetic /etc file failed: {error}")
3823 })?;
3824 }
3825
3826 for grant in &fds.grants {
3827 // Grant paths that failed to open in the parent were already dropped (shrink-not-fail),
3828 // so every fd reaching here is valid. A rule that still fails to add is a genuine
3829 // ruleset-construction failure and propagates as `Err` (fail-closed).
3830 let access = match (grant.executable, grant.list_dir) {
3831 (true, true) => read_execute_list,
3832 (true, false) => read_execute,
3833 (false, true) => read_list,
3834 (false, false) => read_only,
3835 };
3836 ruleset = ruleset
3837 .add_rule(PathBeneath::new(grant.fd.as_fd(), access))
3838 .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
3839 }
3840
3841 for device in &fds.devices {
3842 // Same convention as the grant loop above: a device that failed to open in the parent
3843 // was already dropped, so a failure here is a genuine ruleset-construction failure and
3844 // propagates as `Err` (fail-closed).
3845 let access = if device.writable {
3846 device_read_write
3847 } else {
3848 device_read
3849 };
3850 ruleset = ruleset
3851 .add_rule(PathBeneath::new(device.fd.as_fd(), access))
3852 .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
3853 }
3854
3855 ruleset
3856 .restrict_self()
3857 .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
3858
3859 Ok(())
3860 }
3861
3862 /// Opens (in the PARENT, before fork) the workdir fd and every grant fd Landlock needs, using
3863 /// `O_PATH | O_CLOEXEC` — the exact flags `landlock::PathFd` uses. Moving these `open()` calls
3864 /// out of the child's `pre_exec` window is the whole point: a workdir that cannot be resolved
3865 /// now fails here, synchronously, with a message naming the path — before any subprocess is
3866 /// spawned — instead of collapsing to a bare EINVAL inside `pre_exec`. A grant path that fails
3867 /// to open is dropped (shrink-not-fail), matching the previous per-grant `continue`.
3868 ///
3869 /// It is also where each grant's `list_dir` is reconciled against the fd that was actually
3870 /// opened — see the comment on the grant loop. Same discipline as the drop above: narrowing
3871 /// only, never widening.
3872 ///
3873 /// `device_grants` — the tier's fixed device set, [`super::CAPSULE_DEVICE_GRANTS`] on
3874 /// `KernelFull` and the composed root's own OCI default set on `KernelSealed`, chosen by
3875 /// `super::landlock_device_grants` — is opened here too, by the same rules: the list is a
3876 /// compile-time constant rather than manifest-derived, and a host where one entry cannot be
3877 /// opened — unusual, but a broken or minimal `/dev` is possible — loses that one device rather
3878 /// than failing the launch. Losing `/dev/null` here degrades to exactly the behavior that
3879 /// existed before this mechanism, which is a strictly narrower scope, never a wider one.
3880 ///
3881 /// `tier` decides one thing only: whether the workdir-backed `/tmp` store
3882 /// ([`crate::sealed::SEALED_TMP_DIR_NAME`]) is created and opened. It is on `KernelSealed`,
3883 /// where `plan_composed_root` unconditionally binds it at `/tmp`, and on no other tier — none
3884 /// of which composes a root at all. Both the creation and the open are fail-closed — an `Err`
3885 /// naming the path, exactly like the workdir's own open above, never the grant loops'
3886 /// shrink-not-fail `continue`. That bind is not manifest-optional, and silently dropping its fd
3887 /// would leave `/tmp` mounted-but-denied, which is precisely the defect the rule exists to fix.
3888 pub(super) fn open_landlock_fds(
3889 accessible_workdir: &Path,
3890 session_workdir: &Path,
3891 landlock_grants: &[LandlockGrant],
3892 device_grants: &[super::CapsuleDeviceGrant],
3893 workdir_exec: bool,
3894 tier: EnforcementTier,
3895 ) -> Result<LandlockChildFds, String> {
3896 let workdir_fd = open_o_path(accessible_workdir)
3897 .map(OwnedFd::from)
3898 .map_err(|error| {
3899 format!(
3900 "sandbox: failed to open workdir {} for Landlock scoping: {error}",
3901 accessible_workdir.display()
3902 )
3903 })?;
3904
3905 // `prepare_enforcement` calls this function *before* `build_sealed_root`, so `.mur-tmp`
3906 // need not exist yet; the shared helper creates it (idempotently — `build_sealed_root`
3907 // calls the same one) so there is something here to open.
3908 let sealed_tmp_fd = if tier == EnforcementTier::KernelSealed {
3909 let tmp_store = super::ensure_sealed_tmp_store(session_workdir)?;
3910 let tmp_store_file = open_o_path(&tmp_store).map_err(|error| {
3911 format!(
3912 "sandbox: failed to open the workdir-backed /tmp store {} for Landlock \
3913 scoping: {error}",
3914 tmp_store.display()
3915 )
3916 })?;
3917 Some(OwnedFd::from(tmp_store_file))
3918 } else {
3919 None
3920 };
3921
3922 // Same story, same tier gate, same fail-closed handling: the composed root binds these two
3923 // files at `/etc/passwd`/`/etc/group`, and the grant loop below opens the *host's* files of
3924 // those names, whose inodes the capsule never sees. See `LandlockChildFds`.
3925 let sealed_identity_fds = if tier == EnforcementTier::KernelSealed {
3926 let mut fds = Vec::new();
3927 for path in super::ensure_sealed_identity_files(session_workdir)? {
3928 let file = open_o_path(&path).map_err(|error| {
3929 format!(
3930 "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
3931 {error}",
3932 path.display()
3933 )
3934 })?;
3935 fds.push(OwnedFd::from(file));
3936 }
3937 fds
3938 } else {
3939 Vec::new()
3940 };
3941
3942 let mut grants = Vec::with_capacity(landlock_grants.len());
3943 for grant in landlock_grants {
3944 match open_o_path(&grant.path) {
3945 Ok(file) => grants.push(OpenLandlockGrant {
3946 // `ReadDir` is meaningful only on a directory, and the kernel does not merely
3947 // ignore it elsewhere: `landlock_append_fs_rule` returns `EINVAL` for a
3948 // directory-only right on a non-directory, and the rule loop in
3949 // `apply_landlock_scope` is fail-closed, so one such rule would refuse the
3950 // whole launch. Every `list_dir: true` reaching here is a *claim* — an author's
3951 // `interpreter_runtime` entry, or the static `directory` bit on a
3952 // `SEALED_ETC_PATHS` entry — so it is checked against the fd actually opened
3953 // and narrowed rather than trusted. Narrowing only: this can drop `ReadDir`,
3954 // never add it.
3955 list_dir: grant.list_dir
3956 && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
3957 fd: OwnedFd::from(file),
3958 executable: grant.executable,
3959 }),
3960 Err(_) => continue,
3961 }
3962 }
3963
3964 let mut devices = Vec::with_capacity(device_grants.len());
3965 for device in device_grants {
3966 match open_o_path(Path::new(device.path)) {
3967 Ok(file) => devices.push(OpenDeviceGrant {
3968 fd: OwnedFd::from(file),
3969 writable: device.writable,
3970 }),
3971 Err(_) => continue,
3972 }
3973 }
3974
3975 Ok(LandlockChildFds {
3976 workdir_fd,
3977 workdir_exec,
3978 sealed_tmp_fd,
3979 sealed_identity_fds,
3980 grants,
3981 devices,
3982 })
3983 }
3984
3985 /// Opens `path` with `O_PATH | O_CLOEXEC` (identity/lifetime handle only, never a data fd),
3986 /// suitable for `landlock::PathBeneath`. Same open the vendored `landlock::PathFd::new`
3987 /// performs, done in the parent so the resulting fd can be moved into the child's `pre_exec`
3988 /// closure.
3989 ///
3990 /// Returns the `File` rather than a bare `OwnedFd` so the caller can `fstat` it — see the
3991 /// `list_dir` narrowing in [`open_landlock_fds`], which needs the kind of *this* fd and must
3992 /// not perform a second path lookup to get it.
3993 fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
3994 use std::os::unix::fs::OpenOptionsExt;
3995 std::fs::OpenOptions::new()
3996 .read(true)
3997 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
3998 .open(path)
3999 }
4000
4001 /// Best-effort write of a bounded failure message to the child-diagnostic pipe from inside
4002 /// `pre_exec`, using a raw `write` loop (allocation for the message itself already happened
4003 /// upstream — `child_install_enforcement`'s error paths use `format!`). Never fails the caller:
4004 /// a lost diagnostic only costs legibility, never the fail-closed guarantee (the closure still
4005 /// returns `Err`, so `execve` is still aborted).
4006 #[allow(unsafe_code)]
4007 pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
4008 let bytes = message.as_bytes();
4009 let len = bytes.len().min(MAX_DIAG_LEN);
4010 let mut written = 0;
4011 while written < len {
4012 // SAFETY: `fd` is the valid, open write end of the diagnostic pipe; the pointer/len
4013 // name a live sub-slice of `bytes` for the duration of this single `write` call.
4014 let n = unsafe {
4015 libc::write(
4016 fd,
4017 bytes[written..len].as_ptr() as *const libc::c_void,
4018 len - written,
4019 )
4020 };
4021 if n < 0 {
4022 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4023 continue;
4024 }
4025 break;
4026 }
4027 if n == 0 {
4028 break;
4029 }
4030 written += n as usize;
4031 }
4032 }
4033
4034 /// Parent-side read of whatever the child wrote to the diagnostic pipe before its `pre_exec`
4035 /// closure returned `Err`. Blocks until EOF, which arrives once every write end is closed —
4036 /// the child's (on `_exit`) and the parent's captured copy (which `execute_shell` drops before
4037 /// calling this). Returns `None` when nothing was written (the success-path EOF, or a `spawn`
4038 /// failure that never ran `pre_exec`).
4039 #[allow(unsafe_code)]
4040 pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
4041 let mut buf = Vec::new();
4042 let mut chunk = [0u8; 256];
4043 loop {
4044 // SAFETY: `fd` is the valid, open read end of the diagnostic pipe; `chunk` is a live
4045 // stack buffer of exactly the length passed.
4046 let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
4047 if n < 0 {
4048 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
4049 continue;
4050 }
4051 break;
4052 }
4053 if n == 0 {
4054 break;
4055 }
4056 buf.extend_from_slice(&chunk[..n as usize]);
4057 if buf.len() >= MAX_DIAG_LEN {
4058 break;
4059 }
4060 }
4061 if buf.is_empty() {
4062 None
4063 } else {
4064 Some(String::from_utf8_lossy(&buf).into_owned())
4065 }
4066 }
4067
4068 /// Spawns the background thread that receives the capsule network namespace's listening
4069 /// sockets from the child and starts serving them through the egress proxy — blocking on that
4070 /// receive, racing concurrently with the caller's (not-yet-issued) `.spawn()` call rather than
4071 /// running after it, per [`SupervisorHandle`]'s doc comment.
4072 ///
4073 /// The child sends exactly one `SCM_RIGHTS` batch over this socketpair, from
4074 /// `create_capsule_netns` — the first thing its `pre_exec` window does. The thread finishes as
4075 /// soon as the proxy is up and hands the live handle back over `proxy_rx` for
4076 /// [`SupervisorHandle::join_best_effort`] to shut down, so the proxy stops when the shell
4077 /// call has finished waiting on the subprocess tree. Both the foreground path and a demoted
4078 /// command's own thread drain the child's pipes to EOF before that shutdown, which is what
4079 /// makes them wait for every descendant holding the pipe.
4080 ///
4081 /// If the sockets never arrive (e.g. `fork()` itself failed, or the child's `pre_exec` closure
4082 /// errored before reaching the send), there is no live child left unserved:
4083 /// `Command::spawn()` surfaces that same underlying failure as its own `Err`, via the
4084 /// close-on-exec error pipe, independently of this thread.
4085 pub(super) fn start_supervisor(
4086 parent_sock: OwnedFd,
4087 egress_policy: crate::egress_proxy::EgressPolicy,
4088 expected_namespace_sockets: usize,
4089 diag_read: OwnedFd,
4090 ) -> SupervisorHandle {
4091 let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
4092 std::thread::spawn(move || {
4093 let proxy = start_namespace_proxy(
4094 parent_sock.as_raw_fd(),
4095 expected_namespace_sockets,
4096 egress_policy,
4097 );
4098 // A closed receiver means the caller gave up waiting (its bounded `recv_timeout`
4099 // elapsed). Dropping the handle here then shuts the proxy down on this thread instead,
4100 // which is the same outcome by a different route — never a proxy left running.
4101 if let Err(returned) = proxy_tx.send(proxy) {
4102 if let Some(proxy) = returned.0 {
4103 proxy.shutdown();
4104 }
4105 }
4106 });
4107
4108 SupervisorHandle::Linux {
4109 proxy_rx,
4110 diag_read,
4111 }
4112 }
4113
4114 /// Receives the in-namespace listening sockets and starts serving them.
4115 ///
4116 /// Returns `None` when the hand-off failed, which is not a silent degradation: the same
4117 /// `pre_exec` failure that prevented the send also makes the child return `Err`, so
4118 /// `Command::spawn()` reports it and no subprocess runs. There is no path where the capsule
4119 /// starts with a namespace whose proxy never came up — that would be a capsule with no
4120 /// network at all rather than an unbounded one, but it would present as an inexplicable
4121 /// outage, so it is logged here too.
4122 fn start_namespace_proxy(
4123 sock_fd: RawFd,
4124 expected: usize,
4125 policy: crate::egress_proxy::EgressPolicy,
4126 ) -> Option<crate::egress_proxy::EgressProxyHandle> {
4127 let sockets = match crate::network_namespace::receive_namespace_sockets(sock_fd, expected) {
4128 Ok(sockets) => sockets,
4129 Err(error) => {
4130 crate::runtime_err!(
4131 "[capsule-runtime] warning: failed to receive the capsule's network \
4132 namespace sockets: {error} (the subprocess spawn reports the underlying \
4133 pre_exec failure independently)"
4134 );
4135 return None;
4136 }
4137 };
4138 match crate::egress_proxy::start_egress_proxy(sockets, policy) {
4139 Ok(proxy) => Some(proxy),
4140 Err(error) => {
4141 crate::runtime_err!(
4142 "[capsule-runtime] warning: failed to serve the capsule's network namespace \
4143 sockets: {error} (the subprocess tree has no route off this host, so its \
4144 network calls fail closed)"
4145 );
4146 None
4147 }
4148 }
4149 }
4150
4151 /// What the child's seccomp filter costs, and the two kernel ceilings that cost is held
4152 /// against — all four numbers produced by running code on the host under test, never quoted.
4153 ///
4154 /// The written record these tests feed is
4155 /// `docs/content/reference/seccomp-filter-instruction-budget.md`.
4156 ///
4157 /// Nothing here ever loads a filter into the test process. A loaded seccomp filter is
4158 /// irreversible for the life of the task, so an in-process load would silently apply to the
4159 /// rest of the test binary and to everything it spawns. Every load happens in a forked child
4160 /// that does nothing else and then `_exit`s.
4161 #[cfg(test)]
4162 #[allow(clippy::print_stdout, clippy::print_stderr)]
4163 mod seccomp_budget {
4164 use std::io;
4165 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
4166
4167 // `seccomp_filter_instruction_count` is absent from this list because the test that
4168 // reports it carries the same name; it is called as `super::` throughout.
4169 use super::{
4170 build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
4171 LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
4172 };
4173 use crate::sandbox::{
4174 allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
4175 EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
4176 SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
4177 };
4178
4179 /// The kernel charges each filter already attached to a task four instructions on top of
4180 /// its own length when it decides whether the next attach fits under
4181 /// [`SECCOMP_MAX_INSNS_PER_PATH`].
4182 ///
4183 /// It is only part of the arithmetic. The length the kernel adds up is that of the
4184 /// *converted* program — `seccomp_prepare_filter` translates the classic BPF that
4185 /// `export_bpf` writes into eBPF before the chain is measured, and the translation comes
4186 /// out slightly longer. So a load count predicted from the exported classic length is an
4187 /// upper bound on the load count the kernel actually allows, never an equality, which is
4188 /// why the cumulative probe brackets it rather than pinning it.
4189 const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
4190
4191 /// The exit code a load probe's child reports on a refusal libseccomp gave no errno for.
4192 /// Every real errno is a small positive number, and 0 is reserved for a load that
4193 /// succeeded, so this cannot collide with either.
4194 const PROBE_ERRNO_UNKNOWN: i32 = 254;
4195
4196 /// A filter built only to be measured or refused: default `Allow`, and `rules`
4197 /// argument-conditional `Errno` rules on a syscall no probe child ever makes.
4198 ///
4199 /// Default `Allow` rather than the real filter's default deny, for two reasons that both
4200 /// matter. A child has to survive its own load long enough to `_exit` and, in the
4201 /// cumulative probe, to call `seccomp(2)` again — the real filter denies `seccomp` on
4202 /// purpose, so it cannot be stacked on itself. And libseccomp drops a rule whose action
4203 /// equals the default action, so the rules have to carry an action the default does not:
4204 /// `Errno` against a default of `Allow` is the inverse of the shipped filter and keeps
4205 /// every rule in the program.
4206 ///
4207 /// `set_api_sysrawrc` is what makes the errno observable at all: without it libseccomp
4208 /// collapses every `seccomp(2)` attach failure to `ECANCELED`, which would hide the very
4209 /// `EINVAL`/`ENOMEM` distinction these probes exist to establish.
4210 fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
4211 let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
4212 .map_err(to_io_err)?;
4213 filter.set_api_sysrawrc(true).map_err(to_io_err)?;
4214
4215 let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
4216 for value in 0..rules {
4217 filter
4218 .add_rule_conditional(
4219 libseccomp::ScmpAction::Errno(libc::EPERM),
4220 syscall,
4221 &[libseccomp::ScmpArgCompare::new(
4222 0,
4223 libseccomp::ScmpCompareOp::Equal,
4224 value as u64,
4225 )],
4226 )
4227 .map_err(to_io_err)?;
4228 }
4229
4230 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
4231 Ok((filter, exported.len() / SOCK_FILTER_BYTES))
4232 }
4233
4234 /// The smallest probe-filter rule count whose compiled program is longer than
4235 /// `instructions`. Doubling to bracket, then bisecting: the instruction count grows
4236 /// monotonically with the rule count, so both halves are sound and the whole search
4237 /// costs a couple of dozen exports rather than thousands.
4238 fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
4239 let mut under = 1;
4240 let mut over = 8;
4241 while probe_filter(over)?.1 <= instructions {
4242 under = over;
4243 over *= 2;
4244 assert!(
4245 over <= 1 << 20,
4246 "no probe filter of up to {over} rules exceeded {instructions} instructions — \
4247 the probe is not growing the program the way this search assumes"
4248 );
4249 }
4250 while over - under > 1 {
4251 let mid = under + (over - under) / 2;
4252 if probe_filter(mid)?.1 <= instructions {
4253 under = mid;
4254 } else {
4255 over = mid;
4256 }
4257 }
4258 Ok(over)
4259 }
4260
4261 /// The raw errno behind a libseccomp failure, for a context built by [`probe_filter`] and
4262 /// therefore carrying `SCMP_FLTATR_API_SYSRAWRC`. Known errnos come back as
4263 /// `ErrorKind::Errno`, anything else as the raw code; both are mapped here so the caller
4264 /// only ever sees a plain `libc` value.
4265 fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
4266 use libseccomp::error::SeccompErrno;
4267
4268 if let Some(raw) = error.sysrawrc() {
4269 return -raw;
4270 }
4271 match error.errno() {
4272 Some(SeccompErrno::EACCES) => libc::EACCES,
4273 Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
4274 Some(SeccompErrno::EINVAL) => libc::EINVAL,
4275 Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
4276 _ => PROBE_ERRNO_UNKNOWN,
4277 }
4278 }
4279
4280 /// Loads `filter` in a forked child and reports what the kernel said: `Ok(())` for a load
4281 /// that took, `Err(errno)` for one the kernel refused.
4282 ///
4283 /// The filter is built by the caller, before the fork, so the child's whole post-fork
4284 /// window is `load()` and `_exit`.
4285 fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
4286 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
4287 // child confines itself to work that does not depend on another thread's state. This
4288 // child calls `load()` and then `_exit`. `load()` allocates — libseccomp generates
4289 // the BPF program on every load and does not cache the one `export_bpf` produced —
4290 // so this probe is not async-signal-safe in the strict sense; it is bounded to a
4291 // child that touches nothing else and dies immediately.
4292 let pid = unsafe { libc::fork() };
4293 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
4294
4295 if pid == 0 {
4296 let code = match filter.load() {
4297 Ok(()) => 0,
4298 Err(error) => refusal_errno(&error),
4299 };
4300 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook,
4301 // which is what keeps libtest's state in the parent untouched.
4302 unsafe { libc::_exit(code) }
4303 }
4304
4305 let mut status: libc::c_int = 0;
4306 // SAFETY: `pid` is the child just forked; `status` is a live local.
4307 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
4308 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
4309 assert!(
4310 libc::WIFEXITED(status),
4311 "the load probe child did not exit normally (status {status})"
4312 );
4313 match libc::WEXITSTATUS(status) {
4314 0 => Ok(()),
4315 errno => Err(errno),
4316 }
4317 }
4318
4319 /// The number of distinct syscalls the allowlist actually contributes a rule for on this
4320 /// host, counted the way `build_seccomp_filter` contributes them — resolve the name, try
4321 /// the rule, keep what libseccomp accepted — but against a scratch context, so the count
4322 /// is an independent measurement rather than a restatement of the filter under test.
4323 fn resolvable_allowlist_syscalls() -> io::Result<usize> {
4324 use std::collections::HashSet;
4325
4326 let mut scratch =
4327 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4328 .map_err(to_io_err)?;
4329 let mut accepted = HashSet::new();
4330 for name in SECCOMP_SYSCALL_ALLOWLIST {
4331 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
4332 if scratch
4333 .add_rule(libseccomp::ScmpAction::Allow, syscall)
4334 .is_ok()
4335 {
4336 accepted.insert(i32::from(syscall));
4337 }
4338 }
4339 }
4340 Ok(accepted.len())
4341 }
4342
4343 /// The line both ceiling probes print instead of running, on a host where no seccomp
4344 /// filter can be loaded at all.
4345 fn skip_without_seccomp(what: &str) -> bool {
4346 let tier = detect_enforcement_tier();
4347 if tier == EnforcementTier::EnvironmentOnly {
4348 eprintln!(
4349 "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
4350 can load a seccomp filter at all, which is every tier above \
4351 EnforcementTier::EnvironmentOnly; detected {tier:?}"
4352 );
4353 return true;
4354 }
4355 false
4356 }
4357
4358 /// What the real filter compiles to, against the budget this project holds itself to and
4359 /// the ceiling the kernel holds it to.
4360 ///
4361 /// Both `unix_sockets_allowed` settings, because they build different rule sets — one
4362 /// moves `AF_UNIX` from the denied domains to the allowed ones — and the budget has to
4363 /// cover the larger of the two.
4364 #[test]
4365 fn seccomp_filter_instruction_count() {
4366 for unix_sockets_allowed in [false, true] {
4367 let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
4368 .expect("export the built seccomp filter");
4369
4370 eprintln!(
4371 "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
4372 -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
4373 per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
4374 cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
4375 BPF_MAXINSNS_CEILING as f64 / count as f64,
4376 SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
4377 );
4378
4379 assert!(
4380 count > 0,
4381 "the exported filter is empty, so nothing was measured — \
4382 `export_bpf` wrote no `struct sock_filter` records at all"
4383 );
4384 assert!(
4385 count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
4386 "the child's seccomp filter compiles to {count} instructions with \
4387 unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
4388 {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
4389 {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
4390 still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
4391 deliberately — updating \
4392 docs/content/reference/seccomp-filter-instruction-budget.md with the new \
4393 numbers — rather than letting the filter grow into a spawn failure"
4394 );
4395 }
4396 }
4397
4398 /// The rule sequence `build_seccomp_filter` lays down, pinned against the filter's own
4399 /// pseudo-filter-code export so a change to the shape is a test failure rather than a
4400 /// silent change of policy.
4401 ///
4402 /// The `EACCES` count is the load-bearing one. An unconditional `Allow` on `socket`
4403 /// anywhere in this function would make libseccomp discard every argument-conditional
4404 /// chain already recorded for that syscall — reopening `AF_UNIX`, `AF_NETLINK` and
4405 /// `AF_PACKET` — and it would show up here as zero `EACCES` actions.
4406 #[test]
4407 fn seccomp_filter_rule_shape() {
4408 let allowlist_syscalls =
4409 resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
4410 assert!(
4411 allowlist_syscalls > 0,
4412 "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
4413 for the wrong reason"
4414 );
4415
4416 for unix_sockets_allowed in [false, true] {
4417 let filter =
4418 build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
4419 assert_eq!(
4420 filter.get_act_default().expect("read the default action"),
4421 libseccomp::ScmpAction::Errno(libc::EPERM),
4422 "the filter's default action is no longer a deny with EPERM, which is what \
4423 makes every syscall outside the allowlist unreachable"
4424 );
4425
4426 let pfc =
4427 seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
4428 let denied = denied_socket_domains(unix_sockets_allowed);
4429 let allowed = allowed_socket_domains(unix_sockets_allowed);
4430
4431 // One block per syscall carrying at least one rule: `socket`, every allowlist
4432 // name libseccomp accepted, and the three Landlock syscalls.
4433 let syscall_blocks = pfc.matches("# filter for syscall ").count();
4434 assert_eq!(
4435 syscall_blocks,
4436 1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4437 "the built filter carries rules for {syscall_blocks} syscalls, not the \
4438 socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
4439 syscalls this code adds",
4440 LANDLOCK_SYSCALLS.len()
4441 );
4442
4443 let deny_actions = pfc
4444 .matches(&format!("action ERRNO({});", libc::EACCES))
4445 .count();
4446 assert_eq!(
4447 deny_actions,
4448 denied.len(),
4449 "the built filter carries {deny_actions} EACCES rules, not one per denied \
4450 socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
4451 exactly like this, and would have discarded every domain denial with it"
4452 );
4453
4454 // Every non-`socket` block contributes exactly one `Allow`; the `socket` block
4455 // contributes one per allowed domain.
4456 let allow_actions = pfc.matches("action ALLOW;").count();
4457 assert_eq!(
4458 allow_actions,
4459 allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
4460 "the built filter carries {allow_actions} Allow rules, not one per allowed \
4461 socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
4462 Landlock syscall"
4463 );
4464
4465 for (name, _) in LANDLOCK_SYSCALLS {
4466 assert!(
4467 pfc.contains(&format!("\"{name}\"")),
4468 "{name} has no rule in the built filter, so `apply_landlock_scope` would \
4469 be refused by the filter installed just before it"
4470 );
4471 }
4472
4473 eprintln!(
4474 "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
4475 default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
4476 {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
4477 denied.len(),
4478 allowed.len(),
4479 LANDLOCK_SYSCALLS.len(),
4480 );
4481 }
4482 }
4483
4484 /// Brackets `BPF_MAXINSNS`, the kernel's limit on a *single* filter's length, by loading
4485 /// two filters that straddle it and observing that the kernel takes one and refuses the
4486 /// other with `EINVAL`.
4487 ///
4488 /// `EINVAL`, not `ENOMEM`: an `ENOMEM` out of a seccomp attach never came from this
4489 /// ceiling, whatever the filter's length.
4490 #[test]
4491 fn seccomp_per_filter_ceiling() {
4492 if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
4493 return;
4494 }
4495
4496 let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
4497 .expect("bracket the per-filter ceiling");
4498 let (under_filter, under_count) =
4499 probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
4500 let (over_filter, over_count) =
4501 probe_filter(over_rules).expect("build the over-ceiling probe filter");
4502
4503 assert!(
4504 under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
4505 "the two probe filters ({under_count} and {over_count} instructions) do not \
4506 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
4507 nothing about where the ceiling is"
4508 );
4509
4510 let under_outcome = load_in_child(&under_filter);
4511 let over_outcome = load_in_child(&over_filter);
4512
4513 eprintln!(
4514 "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
4515 instructions; smallest that did not = {over_count} instructions, refused with \
4516 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
4517 over_outcome.err().unwrap_or(0),
4518 libc::EINVAL,
4519 );
4520
4521 assert_eq!(
4522 under_outcome,
4523 Ok(()),
4524 "a {under_count}-instruction filter was refused, below the \
4525 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
4526 );
4527 assert_eq!(
4528 over_outcome,
4529 Err(libc::EINVAL),
4530 "a {over_count}-instruction filter was not refused with EINVAL, so \
4531 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
4532 per-filter limit"
4533 );
4534 }
4535
4536 /// Measures `MAX_INSNS_PER_PATH`, the kernel's limit on the *whole chain* of filters one
4537 /// task carries, by stacking filters in a forked child until the kernel refuses.
4538 ///
4539 /// `ENOMEM` is what this ceiling returns, and it is the only seccomp attach path in the
4540 /// kernel that returns it — so an `ENOMEM` from a runtime that installs exactly one
4541 /// filter of a few hundred instructions, and that had not yet reached `load()` when it
4542 /// failed, did not come from the kernel's seccomp code.
4543 #[test]
4544 fn seccomp_cumulative_ceiling() {
4545 if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
4546 return;
4547 }
4548
4549 // A probe of roughly the shipped filter's size, so the number of stacked loads reads
4550 // directly as "how many filters like ours one task could carry".
4551 let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
4552 let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
4553 // Enough headroom past the ceiling that a kernel which never refuses is caught by the
4554 // cap rather than by the test hanging.
4555 let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
4556
4557 let mut fds = [0 as libc::c_int; 2];
4558 // SAFETY: `fds` is a live two-element array, which is what `pipe2` writes into.
4559 let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
4560 assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
4561 // SAFETY: both descriptors are fresh out of `pipe2` and owned by nothing else.
4562 let (read_fd, write_fd) =
4563 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
4564
4565 // SAFETY: `fork()` from a possibly-multithreaded process is sound as long as the
4566 // child confines itself to work that does not depend on another thread's state. This
4567 // child calls `load()` in a loop, writes eight bytes and `_exit`s.
4568 let pid = unsafe { libc::fork() };
4569 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
4570
4571 if pid == 0 {
4572 let mut loads: u32 = 0;
4573 let mut refusal: i32 = 0;
4574 while loads < max_loads as u32 {
4575 match filter.load() {
4576 Ok(()) => loads += 1,
4577 Err(error) => {
4578 refusal = refusal_errno(&error);
4579 break;
4580 }
4581 }
4582 }
4583
4584 let mut report = [0u8; 8];
4585 report[..4].copy_from_slice(&loads.to_ne_bytes());
4586 report[4..].copy_from_slice(&refusal.to_ne_bytes());
4587 // SAFETY: forked-child context; `write` is async-signal-safe, `report` is a live
4588 // local of exactly the length passed, and the descriptor is the pipe's write end.
4589 unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
4590 // SAFETY: forked-child context; `_exit` skips every destructor and atexit hook.
4591 unsafe { libc::_exit(0) }
4592 }
4593
4594 drop(write_fd);
4595 let mut report = [0u8; 8];
4596 std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
4597 .expect("read the cumulative probe child's report");
4598 let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
4599 let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
4600
4601 let mut status: libc::c_int = 0;
4602 // SAFETY: `pid` is the child just forked; `status` is a live local.
4603 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
4604 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
4605
4606 let stacked = loads * count;
4607 let cumulative_at_refusal = count + loads * per_filter_cost;
4608 let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
4609 let shipped = super::seccomp_filter_instruction_count(false)
4610 .expect("measure the shipped filter for comparison");
4611
4612 eprintln!(
4613 "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
4614 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
4615 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
4616 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
4617 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
4618 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
4619 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
4620 instructions, so reaching this ceiling would take {} of them in a single task.",
4621 loads + 1,
4622 libc::ENOMEM,
4623 SECCOMP_MAX_INSNS_PER_PATH / shipped,
4624 );
4625
4626 assert!(
4627 loads >= 2,
4628 "the kernel refused the filter chain after only {loads} load(s), so this is not \
4629 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
4630 );
4631 assert_eq!(
4632 refusal,
4633 libc::ENOMEM,
4634 "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
4635 {refusal}, not ENOMEM ({}) — the rule-out in \
4636 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
4637 being what this ceiling returns",
4638 libc::ENOMEM
4639 );
4640 assert!(
4641 loads <= loads_if_counted_classic,
4642 "the kernel took {loads} loads of a {count}-instruction filter — more than the \
4643 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
4644 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
4645 each one, so this kernel's ceiling is higher than the recorded value"
4646 );
4647 // Loose on purpose: the exported classic length under-counts what the kernel adds
4648 // up, so the bound below tolerates any plausible conversion expansion while still
4649 // refusing a ceiling of a different order of magnitude — a kernel that stopped at,
4650 // say, BPF_MAXINSNS would fail here.
4651 assert!(
4652 loads * 2 > loads_if_counted_classic,
4653 "the kernel refused after {loads} loads of a {count}-instruction filter, far \
4654 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
4655 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
4656 one recorded"
4657 );
4658 assert!(
4659 shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
4660 "the shipped filter ({shipped} instructions) is within a factor of two of the \
4661 cumulative ceiling, which would make the rule-out this test supports untrue"
4662 );
4663 }
4664 }
4665}
4666
4667#[cfg(test)]
4668mod tests {
4669 use super::*;
4670 use murmur_artifact::security_warnings::W_SEC_003;
4671
4672 /// Content check only: this asserts the *constant*, not the security property. Spawning a
4673 /// subprocess and asserting it cannot see an inherited fd passes vacuously without kernel
4674 /// enforcement, so it would read as evidence while proving nothing.
4675 ///
4676 /// It does catch the one silent, catastrophic edit: a `0` here marks stdio close-on-exec and
4677 /// breaks every subprocess invocation on both spawn paths.
4678 #[test]
4679 fn fd_hygiene_range_starts_above_stdio() {
4680 assert_eq!(
4681 FD_HYGIENE_FIRST_FD, 3,
4682 "fd hygiene must start at 3 — 0/1/2 are the stdio pipes both spawn paths need to \
4683 survive execve"
4684 );
4685 }
4686
4687 /// `mark_inherited_fds_cloexec` reaches `close_range` through `libc::syscall`, so its failure
4688 /// convention is the raw one: `-1` with `errno` set. That convention is what turns a kernel
4689 /// that rejects the call into a failed `Command::spawn()` rather than a spawn that silently
4690 /// runs without fd hygiene.
4691 ///
4692 /// The rejection is provoked with an all-bits-set flag word, which the kernel refuses during
4693 /// argument validation before it touches any descriptor — so this exercises the errno path
4694 /// without closing or flagging a single fd of the test process. A kernel older than 5.11
4695 /// rejects the production call with a different errno and the identical convention — see the
4696 /// **Kernel range narrowing** paragraph on `mark_inherited_fds_cloexec`.
4697 #[cfg(target_os = "linux")]
4698 #[allow(unsafe_code)]
4699 #[test]
4700 fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
4701 // SAFETY: `close_range` takes three scalar arguments and dereferences nothing. Every flag
4702 // bit is set here, so the kernel rejects the call on argument validation and no descriptor
4703 // of this process is closed or marked close-on-exec.
4704 let rc = unsafe {
4705 libc::syscall(
4706 libc::SYS_close_range,
4707 FD_HYGIENE_FIRST_FD,
4708 libc::c_uint::MAX,
4709 libc::c_uint::MAX,
4710 )
4711 };
4712
4713 assert_eq!(rc, -1, "an invalid flag word must not be accepted");
4714 assert_eq!(
4715 std::io::Error::last_os_error().raw_os_error(),
4716 Some(libc::EINVAL),
4717 "the two statements the production path runs on failure must carry the kernel's errno"
4718 );
4719 }
4720
4721 /// A host that can back the sealed mechanism: AppArmor out of the way, and a namespace probe
4722 /// that really created one.
4723 fn sealed_capable() -> crate::sealed::SealedProbe {
4724 crate::sealed::SealedProbe {
4725 userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
4726 namespace: crate::sealed::NamespaceProbe::Ok,
4727 }
4728 }
4729
4730 /// A host that cannot: the default, which claims nothing.
4731 fn sealed_incapable() -> crate::sealed::SealedProbe {
4732 crate::sealed::SealedProbe::default()
4733 }
4734
4735 /// A probe that learned nothing: no Landlock answer and a sealed half that claims nothing.
4736 /// The reading a host gives when neither half of the probe could run.
4737 fn unprobed() -> HostProbe {
4738 HostProbe::from_parts(None, crate::sealed::SealedProbe::default())
4739 }
4740
4741 /// A probe that could not run must land on the weakest Linux tier, not on either kernel tier
4742 /// it failed to demonstrate. This is the fail-closed direction, asserted at the one place the
4743 /// tier is decided from a reading.
4744 #[test]
4745 fn an_unprobed_host_resolves_to_seccomp_only() {
4746 let tier = unprobed().tier();
4747 if cfg!(target_os = "linux") {
4748 assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
4749 } else {
4750 assert_eq!(tier, EnforcementTier::EnvironmentOnly);
4751 }
4752 assert_ne!(tier, EnforcementTier::KernelSealed);
4753 assert_ne!(tier, EnforcementTier::KernelFull);
4754 }
4755
4756 /// A reading that demonstrated nothing must still name what is missing: a refusal with no
4757 /// blocker is a refusal an operator cannot act on.
4758 #[test]
4759 fn an_unprobed_host_names_a_blocker() {
4760 assert!(unprobed().sealed_blocker().is_some());
4761 }
4762
4763 /// The achieved class the trace records for an unprobed host stays strictly below `sealed`,
4764 /// so nothing downstream can read a failed probe as a met floor.
4765 #[test]
4766 fn an_unprobed_host_achieves_less_than_sealed() {
4767 let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
4768 assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
4769 }
4770
4771 /// The whole point of the direction: a capsule declaring `sealed` against a reading that
4772 /// demonstrated nothing is refused, with the shortfall naming the mechanism.
4773 #[test]
4774 fn an_unprobed_host_refuses_a_declared_sealed_floor() {
4775 let probe = unprobed();
4776 let achieved = crate::containment::achieved_class_for_tier(probe.tier());
4777 let error = crate::containment::check_containment_floor(
4778 murmur_artifact::ContainmentClass::Sealed,
4779 achieved,
4780 probe.sealed_blocker(),
4781 false,
4782 )
4783 .expect_err("an unprobed host cannot back a declared sealed floor");
4784 assert!(matches!(
4785 error,
4786 crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
4787 ));
4788 }
4789
4790 /// The tier decision reads only `UsernsGrant::permits_userns()`, so every grant that permits
4791 /// the namespace has to reach the same tier — otherwise reporting provenance would have
4792 /// changed an outcome, which is exactly what naming the grant set out not to do.
4793 #[test]
4794 fn tier_from_probe_is_identical_across_every_permitting_userns_grant() {
4795 use crate::sealed::{NamespaceProbe, SealedProbe, UsernsGrant};
4796
4797 for grant in UsernsGrant::ALL {
4798 let probe = SealedProbe {
4799 userns_grant: *grant,
4800 namespace: NamespaceProbe::Ok,
4801 };
4802 let expected = if grant.permits_userns() {
4803 EnforcementTier::KernelSealed
4804 } else {
4805 EnforcementTier::KernelFull
4806 };
4807 assert_eq!(
4808 tier_from_probe(true, Some(true), probe),
4809 expected,
4810 "grant {grant:?} must decide the tier only through permits_userns()"
4811 );
4812 }
4813 }
4814
4815 #[test]
4816 fn tier_from_probe_non_linux_is_always_environment_only() {
4817 for landlock in [None, Some(true), Some(false)] {
4818 for sealed in [sealed_capable(), sealed_incapable()] {
4819 assert_eq!(
4820 tier_from_probe(false, landlock, sealed),
4821 EnforcementTier::EnvironmentOnly
4822 );
4823 }
4824 }
4825 }
4826
4827 #[test]
4828 fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
4829 assert_eq!(
4830 tier_from_probe(true, Some(true), sealed_incapable()),
4831 EnforcementTier::KernelFull
4832 );
4833 }
4834
4835 #[test]
4836 fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
4837 assert_eq!(
4838 tier_from_probe(true, Some(false), sealed_incapable()),
4839 EnforcementTier::KernelSeccompOnly
4840 );
4841 }
4842
4843 #[test]
4844 fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
4845 assert_eq!(
4846 tier_from_probe(true, None, sealed_incapable()),
4847 EnforcementTier::KernelSeccompOnly
4848 );
4849 }
4850
4851 /// The whole conjunction, one element removed at a time: `KernelSealed` needs Linux, a usable
4852 /// Landlock ABI, AppArmor out of the way, and a namespace probe that succeeded — and drops to
4853 /// exactly the tier the missing element still supports, never further.
4854 #[test]
4855 fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
4856 use crate::sealed::{NamespaceProbe, SealedProbe};
4857
4858 assert_eq!(
4859 tier_from_probe(true, Some(true), sealed_capable()),
4860 EnforcementTier::KernelSealed
4861 );
4862
4863 // No AppArmor profile (and the restriction is on) → the mechanism is unavailable, but
4864 // Landlock still is: `scoped`, not `advisory`.
4865 assert_eq!(
4866 tier_from_probe(
4867 true,
4868 Some(true),
4869 SealedProbe {
4870 userns_grant: crate::sealed::UsernsGrant::Withheld,
4871 namespace: NamespaceProbe::Ok
4872 }
4873 ),
4874 EnforcementTier::KernelFull
4875 );
4876
4877 // The container case, and its three other namespace failure modes.
4878 for namespace in [
4879 NamespaceProbe::Denied,
4880 NamespaceProbe::MapDenied,
4881 NamespaceProbe::MountDenied,
4882 NamespaceProbe::Unsupported,
4883 ] {
4884 assert_eq!(
4885 tier_from_probe(
4886 true,
4887 Some(true),
4888 SealedProbe {
4889 userns_grant: crate::sealed::UsernsGrant::ProfileConfining,
4890 namespace
4891 }
4892 ),
4893 EnforcementTier::KernelFull,
4894 "namespace probe {namespace:?} must fall back to KernelFull, never below it"
4895 );
4896 }
4897
4898 // Landlock missing: sealed keeps Landlock inside as defence in depth, so a host without it
4899 // cannot reach sealed no matter how good its namespaces are.
4900 assert_eq!(
4901 tier_from_probe(true, Some(false), sealed_capable()),
4902 EnforcementTier::KernelSeccompOnly
4903 );
4904 assert_eq!(
4905 tier_from_probe(true, None, sealed_capable()),
4906 EnforcementTier::KernelSeccompOnly
4907 );
4908
4909 // Not Linux at all.
4910 assert_eq!(
4911 tier_from_probe(false, Some(true), sealed_capable()),
4912 EnforcementTier::EnvironmentOnly
4913 );
4914 }
4915
4916 /// A capsule that declared the weaker class keeps the weaker mechanism, even where the host
4917 /// could give it more. Silently installing a composed root under a `scoped` declaration would
4918 /// delete host paths its `interpreter_runtime` grants legitimately name.
4919 #[test]
4920 fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
4921 use murmur_artifact::ContainmentClass;
4922
4923 assert_eq!(
4924 applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
4925 EnforcementTier::KernelSealed
4926 );
4927 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
4928 assert_eq!(
4929 applied_tier(EnforcementTier::KernelSealed, declared),
4930 EnforcementTier::KernelFull,
4931 "declaring {declared} must not opt a capsule into the composed root"
4932 );
4933 }
4934
4935 // Every other tier is passed through untouched, whatever was declared — the declared floor
4936 // can only ever weaken the applied mechanism, never strengthen it past the host.
4937 for host_tier in [
4938 EnforcementTier::KernelFull,
4939 EnforcementTier::KernelSeccompOnly,
4940 EnforcementTier::EnvironmentOnly,
4941 ] {
4942 for declared in [
4943 ContainmentClass::Advisory,
4944 ContainmentClass::Scoped,
4945 ContainmentClass::Sealed,
4946 ] {
4947 assert_eq!(applied_tier(host_tier, declared), host_tier);
4948 }
4949 }
4950 }
4951
4952 #[test]
4953 fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
4954 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
4955
4956 let policy = CapabilityPolicy {
4957 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
4958 binary: "python3".to_string(),
4959 dirs: vec![
4960 // Already inside /usr, which the fixed list binds wholesale.
4961 InterpreterRuntimeDir {
4962 path: "/usr/lib/python3.11".to_string(),
4963 list_dir: true,
4964 },
4965 InterpreterRuntimeDir {
4966 path: "/opt/py/lib".to_string(),
4967 list_dir: true,
4968 },
4969 // A duplicate, and a relative path that names nothing absolute.
4970 InterpreterRuntimeDir {
4971 path: "/opt/py/lib".to_string(),
4972 list_dir: false,
4973 },
4974 InterpreterRuntimeDir {
4975 path: "relative/lib".to_string(),
4976 list_dir: false,
4977 },
4978 ],
4979 }],
4980 ..CapabilityPolicy::default()
4981 };
4982 let exec_allow = vec![
4983 PathBuf::from("/usr/bin/python3"),
4984 PathBuf::from("/opt/toolchain/bin/cc"),
4985 ];
4986
4987 assert_eq!(
4988 resolve_sealed_bind_dirs(&exec_allow, &policy),
4989 vec![
4990 PathBuf::from("/opt/toolchain/bin"),
4991 PathBuf::from("/opt/py/lib")
4992 ]
4993 );
4994 }
4995
4996 /// Staged trees are resolved by a different rule than `sealed_bind_dirs` above, and the
4997 /// differences are the point: a path inside a fixed runtime path is kept (dropping it would
4998 /// silently make a required grant depend on an unrelated list), duplicates collapse, and a
4999 /// relative path — which cannot be re-based under the composed root at all — is skipped.
5000 #[test]
5001 fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
5002 use murmur_artifact::StagedRuntimeGrant;
5003
5004 let grant = |binary: &str, source: &str| StagedRuntimeGrant {
5005 binary: binary.to_string(),
5006 source_path: source.to_string(),
5007 pin: "pin-1".to_string(),
5008 };
5009 let policy = CapabilityPolicy {
5010 shell_staged_runtime: vec![
5011 grant("python3", "/opt/py"),
5012 // Inside /usr, which the fixed list already binds. Kept anyway — unlike
5013 // `resolve_sealed_bind_dirs`, which drops exactly this case.
5014 grant("perl", "/usr/lib/perl5"),
5015 // A duplicate of the first, and a relative path naming nothing absolute.
5016 grant("python3.12", "/opt/py"),
5017 grant("ruby", "relative/ruby"),
5018 ],
5019 ..CapabilityPolicy::default()
5020 };
5021
5022 assert_eq!(
5023 resolve_staged_runtime_dirs(&policy),
5024 vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
5025 );
5026 }
5027
5028 /// The bind is only half of it: `sealed` keeps Landlock installed inside the composed root, so
5029 /// a staged tree with no Landlock rule is mounted-but-unreadable (`EACCES`). Both resolvers
5030 /// must therefore cover the same directories.
5031 #[test]
5032 fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
5033 use murmur_artifact::StagedRuntimeGrant;
5034
5035 let policy = CapabilityPolicy {
5036 shell_staged_runtime: vec![StagedRuntimeGrant {
5037 binary: "python3".to_string(),
5038 source_path: "/opt/py".to_string(),
5039 pin: "pin-1".to_string(),
5040 }],
5041 ..CapabilityPolicy::default()
5042 };
5043
5044 let dirs = resolve_staged_runtime_dirs(&policy);
5045 let grants = resolve_staged_runtime_landlock_grants(&policy);
5046
5047 assert_eq!(
5048 grants,
5049 vec![LandlockGrant {
5050 path: PathBuf::from("/opt/py"),
5051 list_dir: true,
5052 executable: true,
5053 }],
5054 "a staged tree is walked by the runtime that uses it, so it must be listable",
5055 );
5056 assert_eq!(
5057 grants
5058 .iter()
5059 .map(|grant| grant.path.clone())
5060 .collect::<Vec<_>>(),
5061 dirs,
5062 "the bound set and the granted set must not drift apart",
5063 );
5064 }
5065
5066 #[test]
5067 fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
5068 let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
5069
5070 assert_eq!(
5071 grants,
5072 crate::sealed::SEALED_RUNTIME_PATHS
5073 .iter()
5074 .map(|path| LandlockGrant {
5075 path: PathBuf::from(path),
5076 list_dir: true,
5077 executable: false,
5078 })
5079 .collect::<Vec<_>>(),
5080 "the composed root binds exactly these, so exactly these must be enumerable — \
5081 the bound set and the granted set must not drift apart",
5082 );
5083 assert!(
5084 grants.iter().all(|grant| grant.list_dir),
5085 "a runtime tree an interpreter walks is useless without getdents64",
5086 );
5087 assert!(
5088 grants.iter().all(|grant| !grant.executable),
5089 "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
5090 sealed capsule run every binary the host ships, whatever shell.allow says",
5091 );
5092 }
5093
5094 #[test]
5095 fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
5096 for tier in [
5097 EnforcementTier::KernelFull,
5098 EnforcementTier::KernelSeccompOnly,
5099 EnforcementTier::EnvironmentOnly,
5100 ] {
5101 assert!(
5102 resolve_sealed_runtime_landlock_grants(tier).is_empty(),
5103 "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
5104 would enumerate the real host filesystem",
5105 );
5106 }
5107 }
5108
5109 /// The gate as `ShellEnforcement::resolve` actually applies it. `applied_tier` never returns
5110 /// `KernelSealed` for a capsule declaring `scoped`, on any host, so this half is host-
5111 /// independent; the `sealed` half asserts against whatever tier this host resolved to, which
5112 /// is the only honest thing a test can do about a kernel capability it may not have.
5113 #[test]
5114 fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
5115 let policy = CapabilityPolicy::default();
5116 let usr = PathBuf::from("/usr");
5117
5118 let scoped = ShellEnforcement::resolve(
5119 &policy,
5120 murmur_artifact::ContainmentClass::Scoped,
5121 HostProbe::probe(),
5122 )
5123 .expect("an empty policy resolves");
5124 assert!(
5125 !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
5126 "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
5127 unenumerable there",
5128 );
5129
5130 let sealed = ShellEnforcement::resolve(
5131 &policy,
5132 murmur_artifact::ContainmentClass::Sealed,
5133 HostProbe::probe(),
5134 )
5135 .expect("an empty policy resolves");
5136 let granted: Vec<&LandlockGrant> = sealed
5137 .landlock_grants
5138 .iter()
5139 .filter(|grant| {
5140 crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
5141 })
5142 .collect();
5143 if sealed.tier == EnforcementTier::KernelSealed {
5144 assert_eq!(
5145 granted.len(),
5146 crate::sealed::SEALED_RUNTIME_PATHS.len(),
5147 "a sealed session must carry one listable grant per bound runtime path",
5148 );
5149 assert!(granted
5150 .iter()
5151 .all(|grant| grant.list_dir && !grant.executable));
5152 } else {
5153 assert!(
5154 granted.is_empty(),
5155 "this host fell back to {:?}, where there is no composed root to enumerate",
5156 sealed.tier,
5157 );
5158 }
5159 }
5160
5161 #[test]
5162 fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
5163 let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
5164
5165 assert_eq!(
5166 grants,
5167 crate::sealed::SEALED_ETC_PATHS
5168 .iter()
5169 .map(|entry| LandlockGrant {
5170 path: PathBuf::from(entry.path),
5171 list_dir: entry.directory,
5172 executable: false,
5173 })
5174 .collect::<Vec<_>>(),
5175 "the composed root binds exactly these /etc entries, so exactly these must be \
5176 readable — the bound set and the granted set must not drift apart",
5177 );
5178 assert!(
5179 grants.iter().all(|grant| !grant.executable),
5180 "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
5181 run — /etc/alternatives is a directory of symlinks into /usr/bin",
5182 );
5183
5184 // The `directory` classification itself, spelled out independently of the constant so a
5185 // silent reclassification (which would cost an entry its ReadDir, or claim ReadDir on a
5186 // regular file) shows up as a test failure rather than as a TLS bug on a real host.
5187 let listable: Vec<&str> = grants
5188 .iter()
5189 .filter(|grant| grant.list_dir)
5190 .map(|grant| grant.path.to_str().unwrap())
5191 .collect();
5192 assert_eq!(
5193 listable,
5194 vec![
5195 "/etc/ld.so.conf.d",
5196 "/etc/alternatives",
5197 "/etc/ssl",
5198 "/etc/pki",
5199 "/etc/ca-certificates",
5200 "/etc/terminfo",
5201 ],
5202 "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
5203 the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
5204 );
5205 }
5206
5207 #[test]
5208 fn sealed_etc_landlock_grants_are_sealed_tier_only() {
5209 for tier in [
5210 EnforcementTier::KernelFull,
5211 EnforcementTier::KernelSeccompOnly,
5212 EnforcementTier::EnvironmentOnly,
5213 ] {
5214 assert!(
5215 resolve_sealed_etc_landlock_grants(tier).is_empty(),
5216 "{tier:?} has no composed root, so these rules would hand out the real host's \
5217 /etc — its trust store, resolv.conf and account databases",
5218 );
5219 }
5220 }
5221
5222 /// The gate as `ShellEnforcement::resolve` actually applies it, modelled on
5223 /// `shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies`: the `scoped` half
5224 /// is host-independent (`applied_tier` never returns `KernelSealed` for a `scoped`
5225 /// declaration), and the `sealed` half asserts against whatever tier this host resolved to.
5226 #[test]
5227 fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
5228 let policy = CapabilityPolicy::default();
5229 let is_etc_grant = |grant: &LandlockGrant| {
5230 crate::sealed::SEALED_ETC_PATHS
5231 .iter()
5232 .any(|entry| Path::new(entry.path) == grant.path)
5233 };
5234
5235 let scoped = ShellEnforcement::resolve(
5236 &policy,
5237 murmur_artifact::ContainmentClass::Scoped,
5238 HostProbe::probe(),
5239 )
5240 .expect("an empty policy resolves");
5241 assert!(
5242 !scoped.landlock_grants.iter().any(is_etc_grant),
5243 "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
5244 neighbours must not become readable there",
5245 );
5246
5247 let sealed = ShellEnforcement::resolve(
5248 &policy,
5249 murmur_artifact::ContainmentClass::Sealed,
5250 HostProbe::probe(),
5251 )
5252 .expect("an empty policy resolves");
5253 let granted: Vec<&LandlockGrant> = sealed
5254 .landlock_grants
5255 .iter()
5256 .filter(|grant| is_etc_grant(grant))
5257 .collect();
5258 if sealed.tier == EnforcementTier::KernelSealed {
5259 assert_eq!(
5260 granted.len(),
5261 crate::sealed::SEALED_ETC_PATHS.len(),
5262 "a sealed session must carry one read grant per bound /etc entry",
5263 );
5264 assert!(granted.iter().all(|grant| !grant.executable));
5265 } else {
5266 assert!(
5267 granted.is_empty(),
5268 "this host fell back to {:?}, where there is no composed root and /etc is the \
5269 host's own",
5270 sealed.tier,
5271 );
5272 }
5273 }
5274
5275 #[test]
5276 fn resolve_network_allowlist_ips_empty_list_is_empty() {
5277 let ips = resolve_network_allowlist_ips(&[]).unwrap();
5278 assert!(ips.is_empty());
5279 }
5280
5281 #[test]
5282 fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
5283 let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
5284 assert!(
5285 !ips.is_empty(),
5286 "localhost should resolve to at least one address"
5287 );
5288 assert!(
5289 ips.iter().all(|ip| ip.is_loopback()),
5290 "every resolved localhost address should be loopback, got {ips:?}"
5291 );
5292 }
5293
5294 #[test]
5295 fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
5296 let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
5297 assert!(!error.is_empty());
5298 }
5299
5300 /// A host that does not resolve contributes zero IPs instead of failing the whole call. The
5301 /// resolved set only ever *shrinks* the kernel-level `shell.allow` enforcement set, so a DNS
5302 /// miss is fail-closed, not fatal. `.invalid` is reserved by RFC 2606 and is guaranteed never
5303 /// to resolve, which keeps this test independent of the host's DNS.
5304 #[test]
5305 fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
5306 let ips =
5307 resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
5308 .expect("an unresolvable host must not be a hard error");
5309 assert!(
5310 ips.is_empty(),
5311 "an unresolvable host should contribute no IPs, got {ips:?}"
5312 );
5313 }
5314
5315 /// The unresolvable host is skipped while the resolvable one still contributes its IPs — the
5316 /// loop continues rather than aborting on the first miss.
5317 #[test]
5318 fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
5319 let ips = resolve_network_allowlist_ips(&[
5320 "definitely-not-a-real-host.invalid".to_string(),
5321 "localhost".to_string(),
5322 ])
5323 .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
5324
5325 assert!(
5326 !ips.is_empty(),
5327 "localhost's addresses should survive alongside an unresolvable host"
5328 );
5329 assert!(
5330 ips.iter().all(|ip| ip.is_loopback()),
5331 "only localhost should have contributed addresses, got {ips:?}"
5332 );
5333 }
5334
5335 #[test]
5336 fn resolve_network_allowlist_ips_dedupes_across_hosts() {
5337 // Two entries that both resolve to loopback should not produce duplicate IPs beyond
5338 // what's actually distinct.
5339 let ips =
5340 resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
5341 .unwrap();
5342 let mut sorted = ips.clone();
5343 sorted.sort();
5344 sorted.dedup();
5345 assert_eq!(
5346 ips.len(),
5347 sorted.len(),
5348 "resolved IPs should already be deduplicated"
5349 );
5350 }
5351
5352 #[test]
5353 fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
5354 let enforcement = ShellEnforcement::environment_only();
5355 assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
5356 assert!(enforcement.network_allow_ips.is_empty());
5357 }
5358
5359 fn make_executable(path: &Path, contents: &str) {
5360 use std::os::unix::fs::PermissionsExt;
5361 std::fs::write(path, contents).unwrap();
5362 let mut perms = std::fs::metadata(path).unwrap().permissions();
5363 perms.set_mode(0o755);
5364 std::fs::set_permissions(path, perms).unwrap();
5365 }
5366
5367 fn bootstrap_log_contents(workdir: &Path) -> String {
5368 std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
5369 }
5370
5371 // ---- exec allowlist resolution (identity, not names) ----
5372
5373 #[test]
5374 fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
5375 let temp = tempfile::tempdir().unwrap();
5376 let bin_dir = temp.path().join("bin");
5377 std::fs::create_dir(&bin_dir).unwrap();
5378 let tool = bin_dir.join("mytool");
5379 make_executable(&tool, "#!/bin/sh\nexit 0\n");
5380
5381 let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
5382 assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
5383 }
5384
5385 #[test]
5386 fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
5387 let temp = tempfile::tempdir().unwrap();
5388 let resolved = resolve_exec_allowlist_in(
5389 &["definitely-not-a-real-binary".to_string(), String::new()],
5390 &[temp.path().to_path_buf()],
5391 );
5392 assert!(
5393 resolved.is_empty(),
5394 "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
5395 );
5396 }
5397
5398 #[test]
5399 fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
5400 let temp = tempfile::tempdir().unwrap();
5401 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
5402 let resolved =
5403 resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
5404 assert!(resolved.is_empty());
5405 }
5406
5407 #[test]
5408 fn resolve_exec_allowlist_canonicalizes_path_entries_through_symlinks() {
5409 let temp = tempfile::tempdir().unwrap();
5410 let real = temp.path().join("real-tool");
5411 make_executable(&real, "#!/bin/sh\nexit 0\n");
5412 let link = temp.path().join("link-tool");
5413 std::os::unix::fs::symlink(&real, &link).unwrap();
5414
5415 let resolved = resolve_exec_allowlist_in(&[link.to_string_lossy().into_owned()], &[]);
5416 assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
5417 }
5418
5419 // ---- invoked-binary reporting (shell-event.binary / trace.jsonl `binary`) ----
5420
5421 #[test]
5422 fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
5423 let temp = tempfile::tempdir().unwrap();
5424 let bin_dir = temp.path().join("bin");
5425 std::fs::create_dir(&bin_dir).unwrap();
5426 let tool = bin_dir.join("mytool");
5427 make_executable(&tool, "#!/bin/sh\nexit 0\n");
5428
5429 let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
5430 assert_eq!(
5431 resolved,
5432 std::fs::canonicalize(&tool)
5433 .unwrap()
5434 .to_string_lossy()
5435 .into_owned(),
5436 "a resolvable name must report the canonical absolute path, not the bare name"
5437 );
5438 }
5439
5440 #[test]
5441 fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
5442 let temp = tempfile::tempdir().unwrap();
5443 let path_dirs = [temp.path().to_path_buf()];
5444 let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
5445 assert_eq!(
5446 resolved, "definitely-not-a-real-binary",
5447 "an unresolvable name must fall back to the bare name, never error"
5448 );
5449 assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
5450 }
5451
5452 #[test]
5453 fn resolve_invoked_binary_path_takes_the_first_path_match() {
5454 let temp = tempfile::tempdir().unwrap();
5455 let first = temp.path().join("first");
5456 let second = temp.path().join("second");
5457 std::fs::create_dir(&first).unwrap();
5458 std::fs::create_dir(&second).unwrap();
5459 make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
5460 make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
5461
5462 let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
5463 assert_eq!(
5464 resolved,
5465 std::fs::canonicalize(first.join("mytool"))
5466 .unwrap()
5467 .to_string_lossy()
5468 .into_owned(),
5469 "reporting must match execvp: the first PATH hit is the one that ran"
5470 );
5471 }
5472
5473 #[test]
5474 fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
5475 let temp = tempfile::tempdir().unwrap();
5476 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
5477 assert_eq!(
5478 resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
5479 "mytool"
5480 );
5481 }
5482
5483 #[test]
5484 fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
5485 let temp = tempfile::tempdir().unwrap();
5486 let real = temp.path().join("real-tool");
5487 make_executable(&real, "#!/bin/sh\nexit 0\n");
5488 let link = temp.path().join("link-tool");
5489 std::os::unix::fs::symlink(&real, &link).unwrap();
5490
5491 assert_eq!(
5492 resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
5493 std::fs::canonicalize(&real)
5494 .unwrap()
5495 .to_string_lossy()
5496 .into_owned()
5497 );
5498 }
5499
5500 // ---- ELF dependency parsing (pure, synthetic fixtures — runs on every OS) ----
5501
5502 /// Builds a minimal but well-formed ELF64 image exercising exactly the fields
5503 /// `parse_elf_dependencies` reads: a single identity-mapped `PT_LOAD` (so virtual address ==
5504 /// file offset), an optional `PT_INTERP`, and — when any dynamic metadata is requested — a
5505 /// `PT_DYNAMIC` with `DT_NEEDED`/`DT_RUNPATH`/`DT_STRTAB`/`DT_STRSZ`/`DT_NULL` entries.
5506 fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
5507 let has_dynamic = !needed.is_empty() || runpath.is_some();
5508
5509 let mut phnum: u16 = 1; // PT_LOAD
5510 if interp.is_some() {
5511 phnum += 1;
5512 }
5513 if has_dynamic {
5514 phnum += 1;
5515 }
5516
5517 let ehsize = 64usize;
5518 let phentsize = 56usize;
5519 let phoff = ehsize;
5520 let body_start = phoff + phentsize * phnum as usize;
5521
5522 // Dynamic string table: index 0 is a NUL, then each soname and the runpath.
5523 let mut strtab: Vec<u8> = vec![0];
5524 let mut needed_offsets = Vec::new();
5525 for name in needed {
5526 needed_offsets.push(strtab.len() as u64);
5527 strtab.extend_from_slice(name.as_bytes());
5528 strtab.push(0);
5529 }
5530 let runpath_offset = runpath.map(|r| {
5531 let off = strtab.len() as u64;
5532 strtab.extend_from_slice(r.as_bytes());
5533 strtab.push(0);
5534 off
5535 });
5536
5537 let mut body: Vec<u8> = Vec::new();
5538
5539 let interp_offset = body_start + body.len();
5540 if let Some(i) = interp {
5541 body.extend_from_slice(i.as_bytes());
5542 body.push(0);
5543 }
5544
5545 // Align the dynamic array to 8 bytes.
5546 while !(body_start + body.len()).is_multiple_of(8) {
5547 body.push(0);
5548 }
5549 let dyn_offset = body_start + body.len();
5550 let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; // + DT_STRTAB, DT_STRSZ, DT_NULL
5551 let dyn_size = num_dyn * 16;
5552 let strtab_offset = dyn_offset + dyn_size;
5553
5554 if has_dynamic {
5555 let mut push_dyn = |tag: u64, val: u64| {
5556 body.extend_from_slice(&tag.to_le_bytes());
5557 body.extend_from_slice(&val.to_le_bytes());
5558 };
5559 for &o in &needed_offsets {
5560 push_dyn(1, o); // DT_NEEDED
5561 }
5562 if let Some(ro) = runpath_offset {
5563 push_dyn(29, ro); // DT_RUNPATH
5564 }
5565 push_dyn(5, strtab_offset as u64); // DT_STRTAB (vaddr == offset, identity PT_LOAD)
5566 push_dyn(10, strtab.len() as u64); // DT_STRSZ
5567 push_dyn(0, 0); // DT_NULL
5568 assert_eq!(body_start + body.len(), strtab_offset);
5569 body.extend_from_slice(&strtab);
5570 }
5571
5572 let total_len = body_start + body.len();
5573 let mut out = vec![0u8; total_len];
5574 out[0..4].copy_from_slice(b"\x7fELF");
5575 out[4] = 2; // ELFCLASS64
5576 out[5] = 1; // little-endian
5577 out[6] = 1; // EI_VERSION
5578 out[16..18].copy_from_slice(&3u16.to_le_bytes()); // e_type = ET_DYN
5579 out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); // e_machine = x86_64 (irrelevant)
5580 out[20..24].copy_from_slice(&1u32.to_le_bytes()); // e_version
5581 out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); // e_phoff
5582 out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); // e_phentsize
5583 out[56..58].copy_from_slice(&phnum.to_le_bytes()); // e_phnum
5584
5585 let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); // (p_type, p_offset, p_vaddr, p_filesz)
5586 phdrs.push((1, 0, 0, total_len as u64)); // PT_LOAD identity-maps the whole file
5587 if let Some(i) = interp {
5588 phdrs.push((
5589 3,
5590 interp_offset as u64,
5591 interp_offset as u64,
5592 i.len() as u64 + 1,
5593 ));
5594 }
5595 if has_dynamic {
5596 phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
5597 }
5598 for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
5599 let base = phoff + idx * phentsize;
5600 out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
5601 out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
5602 out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
5603 out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
5604 }
5605
5606 out[body_start..total_len].copy_from_slice(&body);
5607 out
5608 }
5609
5610 #[test]
5611 fn parse_elf_extracts_interp_needed_and_runpath() {
5612 let bytes = build_elf64(
5613 Some("/lib64/ld-linux-x86-64.so.2"),
5614 &["libc.so.6", "libm.so.6"],
5615 Some("$ORIGIN/../lib:/opt/custom/lib"),
5616 );
5617 let deps = parse_elf_dependencies(&bytes).expect("well-formed ELF64 must parse");
5618 assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
5619 assert_eq!(
5620 deps.needed,
5621 vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
5622 );
5623 assert_eq!(
5624 deps.runpaths,
5625 vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
5626 "DT_RUNPATH must be split on ':' with empty parts dropped"
5627 );
5628 }
5629
5630 #[test]
5631 fn parse_elf_static_binary_contributes_only_itself() {
5632 // No PT_INTERP, no PT_DYNAMIC — a statically linked binary.
5633 let bytes = build_elf64(None, &[], None);
5634 let deps = parse_elf_dependencies(&bytes).expect("a static ELF64 is still valid ELF");
5635 assert_eq!(deps, ElfDependencies::default());
5636 }
5637
5638 #[test]
5639 fn parse_elf_rejects_non_elf_and_non_elf64() {
5640 assert!(parse_elf_dependencies(b"not an elf at all, way too short").is_none());
5641 // Correct magic but ELFCLASS32 (byte 4 == 1) must be rejected.
5642 let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
5643 elf32[4] = 1;
5644 assert!(
5645 parse_elf_dependencies(&elf32).is_none(),
5646 "32-bit ELF is not a binary this runtime execs and must not parse"
5647 );
5648 }
5649
5650 // ---- soname resolution (loader search-order, synthetic fixtures) ----
5651
5652 #[test]
5653 fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
5654 let temp = tempfile::tempdir().unwrap();
5655 let origin = temp.path().join("app");
5656 std::fs::create_dir(&origin).unwrap();
5657 let libs = temp.path().join("libs");
5658 std::fs::create_dir(&libs).unwrap();
5659 std::fs::write(libs.join("libc.so.6"), "x").unwrap();
5660
5661 // Found in a standard search directory.
5662 assert_eq!(
5663 resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
5664 Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
5665 );
5666 // Unresolvable soname → None (shrink-not-fail).
5667 assert_eq!(
5668 resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
5669 None
5670 );
5671
5672 // Found via a `$ORIGIN`-relative DT_RUNPATH, taking precedence over the search dirs.
5673 let origin_lib = origin.join("lib");
5674 std::fs::create_dir(&origin_lib).unwrap();
5675 std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
5676 assert_eq!(
5677 resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
5678 Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
5679 );
5680 }
5681
5682 // ---- transitive Landlock grant derivation (synthetic ELF + fake libs) ----
5683
5684 #[test]
5685 fn resolve_landlock_grants_derives_interp_and_needed_closure() {
5686 let temp = tempfile::tempdir().unwrap();
5687 let bin_dir = temp.path().join("bin");
5688 let lib_dir = temp.path().join("lib");
5689 std::fs::create_dir(&bin_dir).unwrap();
5690 std::fs::create_dir(&lib_dir).unwrap();
5691
5692 // A fake interpreter and a fake shared library, both outside any "workdir".
5693 let interp = bin_dir.join("ld-fake.so");
5694 std::fs::write(&interp, "not really elf").unwrap();
5695 std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
5696
5697 // A synthetic dynamically-linked binary naming that interp + that soname.
5698 let prog = bin_dir.join("prog");
5699 let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
5700 std::fs::write(&prog, &elf).unwrap();
5701
5702 let grants =
5703 resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
5704
5705 let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
5706 assert!(
5707 grants.contains(&expect(prog)),
5708 "the binary itself must be granted: {grants:?}"
5709 );
5710 assert!(
5711 grants.contains(&expect(interp)),
5712 "the ELF interpreter must be granted: {grants:?}"
5713 );
5714 assert!(
5715 grants.contains(&expect(lib_dir.join("libfake.so.1"))),
5716 "each resolved DT_NEEDED library must be granted: {grants:?}"
5717 );
5718 }
5719
5720 #[test]
5721 fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
5722 let temp = tempfile::tempdir().unwrap();
5723 let bin_dir = temp.path().join("bin");
5724 std::fs::create_dir(&bin_dir).unwrap();
5725
5726 // A binary naming a soname that resolves nowhere, plus an interp that doesn't exist.
5727 let prog = bin_dir.join("prog");
5728 std::fs::write(
5729 &prog,
5730 build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
5731 )
5732 .unwrap();
5733 let missing = bin_dir.join("does-not-exist");
5734
5735 let grants =
5736 resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
5737
5738 // Only the real binary survives; the missing binary, missing interp, and unresolvable
5739 // soname each contribute nothing (shrink-not-fail).
5740 assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
5741 }
5742
5743 // ---- interpreter_runtime → LandlockGrant construction (pure, syscall-free, every OS) ----
5744
5745 fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
5746 InterpreterRuntimeGrant {
5747 binary: binary.to_string(),
5748 dirs: dirs
5749 .iter()
5750 .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
5751 path: path.to_string(),
5752 list_dir: *list_dir,
5753 })
5754 .collect(),
5755 }
5756 }
5757
5758 #[test]
5759 fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
5760 // Two directories under the same binary, opposite `list_dir` — proving enumerability is
5761 // per-directory and copied exactly, not inferred or unified.
5762 let grants = resolve_interpreter_runtime_grants(&[interp_grant(
5763 "python3",
5764 &[
5765 ("/usr/lib/python3.11", true),
5766 ("/usr/lib/python3.11/lib-dynload", false),
5767 ],
5768 )]);
5769
5770 assert_eq!(
5771 grants,
5772 vec![
5773 LandlockGrant {
5774 path: PathBuf::from("/usr/lib/python3.11"),
5775 list_dir: true,
5776 executable: true,
5777 },
5778 LandlockGrant {
5779 path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
5780 list_dir: false,
5781 executable: true,
5782 },
5783 ]
5784 );
5785 }
5786
5787 #[test]
5788 fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
5789 // The declared paths need not exist on this host (this is pure resolution — `apply_landlock_
5790 // scope` skips any that fail to open), and multiple grants flatten in order.
5791 let grants = resolve_interpreter_runtime_grants(&[
5792 interp_grant("python3", &[("/opt/py/nonexistent", false)]),
5793 interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
5794 ]);
5795
5796 assert_eq!(
5797 grants,
5798 vec![
5799 LandlockGrant {
5800 path: PathBuf::from("/opt/py/nonexistent"),
5801 list_dir: false,
5802 executable: true,
5803 },
5804 LandlockGrant {
5805 path: PathBuf::from("/opt/rb/also-nonexistent"),
5806 list_dir: true,
5807 executable: true,
5808 },
5809 ]
5810 );
5811 assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
5812 }
5813
5814 #[test]
5815 fn non_listable_files_marks_every_closure_file_non_enumerable() {
5816 // The DT_NEEDED closure yields individual files; wrapping them must never set `list_dir`
5817 // (ReadDir on a regular file is a no-op anyway).
5818 let wrapped = LandlockGrant::non_listable_files(vec![
5819 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
5820 PathBuf::from("/usr/bin/bash"),
5821 ]);
5822 assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
5823 assert_eq!(
5824 wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
5825 vec![
5826 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
5827 PathBuf::from("/usr/bin/bash"),
5828 ]
5829 );
5830 }
5831
5832 // ---- `CAPSULE_DEVICE_GRANTS` (fixed device set) ----------------------------------------
5833 //
5834 // The canonical statement of what the *content* checks in this file are worth, referenced by
5835 // the `denied_socket_domains` and `SECCOMP_SYSCALL_ALLOWLIST` sections below: they assert what
5836 // a Rust constant holds, NOT that any kernel grants or refuses anything. A green run is not
5837 // evidence that `/dev/null` is writable inside a capsule or that `/dev/random` is refused —
5838 // on a host below `KernelFull`, `apply_landlock_scope` never runs at all. A content check
5839 // buys one thing: changing a constant cannot happen by accident.
5840 //
5841 // The enforcement claim for this constant is verified only by the manual procedure in
5842 // `docs/content/reference/capsule-device-set-manual-verification.md`, on real,
5843 // uncontainerized Linux hardware.
5844
5845 #[test]
5846 fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
5847 let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
5848 .iter()
5849 .map(|device| (device.path, device.writable))
5850 .collect();
5851 assert_eq!(
5852 listed,
5853 vec![
5854 ("/dev/null", true),
5855 ("/dev/zero", false),
5856 ("/dev/urandom", false),
5857 ],
5858 "the capsule device set is fixed at exactly these three paths and these access \
5859 levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
5860 and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
5861 both. Widening this list is allowed only on demonstrated workload failure, and the \
5862 failure gets recorded alongside the widening (see the manual acceptance procedure on \
5863 the diagnostics reference page)."
5864 );
5865 }
5866
5867 #[test]
5868 fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
5869 // `/dev/random` is the one with a reasoning trap attached: a plain write() to it credits
5870 // *zero* entropy (crediting needs the RNDADDENTROPY ioctl under CAP_SYS_ADMIN, which the
5871 // shell child has dropped). It is excluded because nothing needs a blocking RNG when
5872 // /dev/urandom is granted — not because writing to it would poison the kernel pool.
5873 for excluded in [
5874 "/dev/random",
5875 "/dev/full",
5876 "/dev/tty",
5877 "/dev/console",
5878 "/dev/mem",
5879 "/dev/sda",
5880 "/dev",
5881 ] {
5882 assert!(
5883 !CAPSULE_DEVICE_GRANTS
5884 .iter()
5885 .any(|device| device.path == excluded),
5886 "{excluded} must not be in the capsule device set — it is denied by the ordinary \
5887 no-matching-rule path, and adding it here would be the only way to change that"
5888 );
5889 }
5890 }
5891
5892 #[test]
5893 fn capsule_device_grants_name_absolute_paths_under_dev() {
5894 // The list is opened verbatim with `O_PATH` in the parent: a relative path would resolve
5895 // against whatever cwd the host process happens to have.
5896 for device in CAPSULE_DEVICE_GRANTS {
5897 assert!(
5898 device.path.starts_with("/dev/"),
5899 "{} must be an absolute path under /dev",
5900 device.path
5901 );
5902 }
5903 }
5904
5905 // ---- `denied_socket_domains` ----------------------------------------------------------
5906 //
5907 // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
5908 // worth. That `socket(AF_UNIX, ...)` is actually denied on a real host is verified only by the
5909 // manual procedure in `docs/content/reference/af-unix-sockets-manual-verification.md`, on
5910 // real Linux hardware.
5911
5912 #[test]
5913 fn denied_socket_domains_denies_unix_by_default() {
5914 let denied = denied_socket_domains(false);
5915 assert!(
5916 denied.contains(&LINUX_AF_UNIX),
5917 "AF_UNIX must be denied when the manifest does not declare \
5918 capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
5919 );
5920 }
5921
5922 #[test]
5923 fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
5924 let denied = denied_socket_domains(true);
5925 assert!(
5926 !denied.contains(&LINUX_AF_UNIX),
5927 "a capsule that declared capabilities.network.unix_sockets: true must get no \
5928 AF_UNIX deny rule at all"
5929 );
5930 }
5931
5932 /// Netlink and packet sockets have no manifest key that widens them: the deny list must be
5933 /// identical for both values of the one flag that exists.
5934 #[test]
5935 fn denied_socket_domains_always_denies_netlink_and_packet() {
5936 for unix_sockets_allowed in [false, true] {
5937 let denied = denied_socket_domains(unix_sockets_allowed);
5938 assert!(
5939 denied.contains(&LINUX_AF_NETLINK),
5940 "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
5941 {unix_sockets_allowed})"
5942 );
5943 assert!(
5944 denied.contains(&LINUX_AF_PACKET),
5945 "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
5946 {unix_sockets_allowed})"
5947 );
5948 }
5949 }
5950
5951 /// The rule only ever subtracts: IP sockets stay governed by the capsule's own network
5952 /// namespace and egress proxy against `capabilities.network.allow`, never by a domain-level
5953 /// deny.
5954 #[test]
5955 fn denied_socket_domains_never_denies_ip_families() {
5956 for unix_sockets_allowed in [false, true] {
5957 let denied = denied_socket_domains(unix_sockets_allowed);
5958 assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
5959 assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
5960 }
5961 }
5962
5963 /// Pins the exact sets, so widening either one is a deliberate edit to this test rather than
5964 /// an unnoticed side effect of touching the function.
5965 #[test]
5966 fn denied_socket_domains_returns_exactly_the_documented_sets() {
5967 assert_eq!(
5968 denied_socket_domains(false),
5969 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
5970 );
5971 assert_eq!(
5972 denied_socket_domains(true),
5973 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
5974 );
5975 }
5976
5977 /// The domain constants are Linux ABI numbers spelled as literals (see their doc comment),
5978 /// so nothing checks them against `libc` at compile time. On a Linux build machine, assert
5979 /// they really do agree with `libc`; on macOS this test compiles away, because
5980 /// `libc::AF_NETLINK`/`AF_PACKET` do not exist there at all — which is the whole reason the
5981 /// constants are literals.
5982 #[cfg(target_os = "linux")]
5983 #[test]
5984 fn linux_socket_domain_constants_match_libc() {
5985 assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
5986 assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
5987 assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
5988 assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
5989 assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
5990 }
5991
5992 /// The allow and deny domain sets feed two rule loops on the *same* syscall and argument, so
5993 /// an overlap would mean one `socket()` call matching both an `Allow` and an `Errno` rule —
5994 /// a filter whose behavior depends on libseccomp's internal rule ordering rather than on
5995 /// anything stated here.
5996 #[test]
5997 fn socket_domain_allow_and_deny_sets_never_overlap() {
5998 for unix_sockets_allowed in [false, true] {
5999 let denied = denied_socket_domains(unix_sockets_allowed);
6000 for domain in allowed_socket_domains(unix_sockets_allowed) {
6001 assert!(
6002 !denied.contains(&domain),
6003 "socket domain {domain} is both allowed and denied \
6004 (unix_sockets_allowed = {unix_sockets_allowed})"
6005 );
6006 }
6007 }
6008 }
6009
6010 /// `capabilities.network.unix_sockets` is the only manifest key either set consults, and it
6011 /// must move `AF_UNIX` from one set to the other — never leave it in neither, which under a
6012 /// default-deny filter would silently keep unix sockets refused even after a capsule declared
6013 /// the capability.
6014 #[test]
6015 fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
6016 assert_eq!(
6017 allowed_socket_domains(false),
6018 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
6019 );
6020 assert_eq!(
6021 allowed_socket_domains(true),
6022 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
6023 );
6024 }
6025
6026 // ---- `SECCOMP_SYSCALL_ALLOWLIST` -------------------------------------------------------
6027 //
6028 // Content checks — see the `CAPSULE_DEVICE_GRANTS` section above for what that is and is not
6029 // worth. These assert the allowlist's contents, not that the kernel denies anything. They buy
6030 // one thing: re-permitting a dangerous syscall cannot happen by accident while reconciling
6031 // the allowlist against a newer upstream profile.
6032
6033 #[test]
6034 fn allowlist_contains_no_syscall_that_must_stay_denied() {
6035 for name in SECCOMP_MUST_STAY_DENIED {
6036 assert!(
6037 !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
6038 "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
6039 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
6040 in the same edit and say why in the commit"
6041 );
6042 }
6043 }
6044
6045 /// `execve`/`execveat` must be *present*: the default-deny action applies to anything unnamed,
6046 /// so omitting them would refuse the child's own first `execve` and no capsule could run a
6047 /// shell tool at all. Exec is decided by the Landlock domain instead; see
6048 /// `linux_enforce::workdir_access_rights` and `resolve_landlock_grants`.
6049 #[test]
6050 fn allowlist_permits_exec_because_landlock_now_decides_it() {
6051 for name in ["execve", "execveat"] {
6052 assert!(
6053 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
6054 "{name} must reach the kernel — the default action is a deny, so omitting it \
6055 refuses the child's own exec and nothing runs"
6056 );
6057 }
6058 }
6059
6060 /// `socket` carries argument-conditional rules, and libseccomp resolves an unconditional rule
6061 /// for a syscall by discarding every conditional chain already recorded for it. A bare
6062 /// `"socket"` in the allowlist array would therefore delete the AF_UNIX/AF_NETLINK/AF_PACKET
6063 /// denials at filter-build time, with nothing at runtime to show for it.
6064 #[test]
6065 fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
6066 assert!(
6067 !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
6068 "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
6069 );
6070 }
6071
6072 /// A duplicate is harmless to the kernel but means two edits disagree about the same syscall,
6073 /// which is exactly the state where one of them later gets removed and the other is missed.
6074 #[test]
6075 fn allowlist_has_no_duplicates() {
6076 let mut seen = std::collections::HashSet::new();
6077 for name in SECCOMP_SYSCALL_ALLOWLIST {
6078 assert!(seen.insert(*name), "{name} appears twice in the allowlist");
6079 }
6080 }
6081
6082 /// The whole design is an allowlist. An empty (or near-empty) array would still compile and
6083 /// still "pass" every other test here, while denying every syscall a shell needs.
6084 #[test]
6085 fn allowlist_covers_the_syscalls_no_process_can_run_without() {
6086 for name in [
6087 "read",
6088 "write",
6089 "openat",
6090 "close",
6091 "mmap",
6092 "munmap",
6093 "brk",
6094 "exit_group",
6095 "futex",
6096 "rt_sigreturn",
6097 "clone",
6098 "wait4",
6099 "getpid",
6100 "getdents64",
6101 ] {
6102 assert!(
6103 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
6104 "{name} is missing from the allowlist — no process could run under this filter"
6105 );
6106 }
6107 }
6108
6109 #[test]
6110 fn network_ip_allowed_matches_only_the_resolved_allowlist() {
6111 let allow = vec![IpAddr::from([127, 0, 0, 1])];
6112 assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
6113 assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
6114 assert!(
6115 !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
6116 "an empty resolved allowlist must deny every destination"
6117 );
6118 }
6119
6120 // ---- tier warnings (finding #3: assert behavior, not just "doesn't panic") ----
6121
6122 #[test]
6123 fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
6124 // The Linux "full" tier must NOT be silent: it is a filesystem and syscall scope, not a
6125 // namespace, and a silent tier would imply it has no documented limits. It warns when a
6126 // shell is declared, and stays silent only when there is no subprocess to enforce on.
6127 assert_eq!(
6128 tier_warning(EnforcementTier::KernelFull, false),
6129 Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
6130 );
6131 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
6132 }
6133
6134 #[test]
6135 fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
6136 assert_eq!(
6137 tier_warning(EnforcementTier::KernelSeccompOnly, false),
6138 Some((W_SEC_002, SECCOMP_ONLY_WARNING))
6139 );
6140 assert_eq!(
6141 tier_warning(EnforcementTier::EnvironmentOnly, false),
6142 Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
6143 );
6144 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
6145 assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
6146 assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
6147 }
6148
6149 /// The aggregate-bounding gap is warned about exactly where it is real *and* permanent: a
6150 /// non-Linux host running a capsule that can spawn a subprocess. Never on Linux — there the
6151 /// same condition refuses the launch outright, so a warning would describe a session that
6152 /// does not exist — and never for a capsule with no process tree to bound.
6153 #[test]
6154 fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
6155 assert_eq!(
6156 aggregate_bounding_warning(false, true, false),
6157 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
6158 );
6159 assert_eq!(
6160 aggregate_bounding_warning(false, false, false),
6161 None,
6162 "a capsule that cannot spawn a subprocess has nothing to bound"
6163 );
6164 assert_eq!(
6165 aggregate_bounding_warning(true, true, false),
6166 None,
6167 "on Linux this case is a refused launch, not a warning"
6168 );
6169 assert_eq!(
6170 aggregate_bounding_warning(true, true, true),
6171 None,
6172 "a scope exists, so there is no gap"
6173 );
6174 }
6175
6176 /// The warning must name the per-uid nature of `RLIMIT_NPROC` explicitly: "rlimits are still
6177 /// applied" reads as reassurance, and the whole point is that the one rlimit an operator
6178 /// would expect to stop a fork bomb does not.
6179 #[test]
6180 fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
6181 let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
6182 assert!(message.contains("per-uid"), "message was: {message}");
6183 assert!(message.contains("fork bomb"), "message was: {message}");
6184 assert!(message.contains("pids.max"), "message was: {message}");
6185 }
6186
6187 #[test]
6188 fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
6189 let temp = tempfile::tempdir().unwrap();
6190 warn_for_missing_aggregate_bounding(temp.path(), true, false);
6191 let log = bootstrap_log_contents(temp.path());
6192
6193 if cfg!(target_os = "linux") {
6194 assert!(
6195 log.is_empty(),
6196 "Linux refuses the launch instead of warning: {log}"
6197 );
6198 } else {
6199 assert!(log.contains(W_SEC_010), "log was: {log}");
6200 assert!(
6201 log.contains(&security_warning_link(W_SEC_010)),
6202 "log was: {log}"
6203 );
6204 }
6205 }
6206
6207 fn warning_test_policy() -> CapabilityPolicy {
6208 CapabilityPolicy {
6209 shell_allow: vec!["bash".to_string()],
6210 network_allow: vec!["https://api.example.com".to_string()],
6211 ..CapabilityPolicy::default()
6212 }
6213 }
6214
6215 #[test]
6216 fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
6217 let temp = tempfile::tempdir().unwrap();
6218 warn_for_enforcement_tier(
6219 EnforcementTier::KernelFull,
6220 temp.path(),
6221 &warning_test_policy(),
6222 );
6223 let log = bootstrap_log_contents(temp.path());
6224 assert!(
6225 log.contains(W_SEC_005),
6226 "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
6227 );
6228 assert!(
6229 log.contains("denied, not absent"),
6230 "must state the scope's documented limit: paths outside the workdir are denied, not \
6231 absent: {log}"
6232 );
6233 assert!(
6234 log.contains("read+execute") && log.contains("outside the workdir"),
6235 "must describe what is now actually granted (narrow, derived read+execute outside \
6236 the workdir), not an unfixed grant bug: {log}"
6237 );
6238 assert!(
6239 log.contains(&security_warning_link(W_SEC_005)),
6240 "must link to the diagnostics doc page: {log}"
6241 );
6242 }
6243
6244 #[test]
6245 fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
6246 let temp = tempfile::tempdir().unwrap();
6247 warn_for_enforcement_tier(
6248 EnforcementTier::KernelFull,
6249 temp.path(),
6250 &CapabilityPolicy::default(),
6251 );
6252 assert_eq!(
6253 bootstrap_log_contents(temp.path()),
6254 "",
6255 "with no shell declared there is no subprocess to enforce on — stay silent"
6256 );
6257 }
6258
6259 #[test]
6260 fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
6261 let temp = tempfile::tempdir().unwrap();
6262 warn_for_enforcement_tier(
6263 EnforcementTier::KernelSeccompOnly,
6264 temp.path(),
6265 &warning_test_policy(),
6266 );
6267 let log = bootstrap_log_contents(temp.path());
6268 assert!(
6269 log.contains("Landlock"),
6270 "must name the missing primitive: {log}"
6271 );
6272 assert!(
6273 log.contains("filesystem"),
6274 "must name what is not enforced: {log}"
6275 );
6276 assert!(
6277 log.contains(W_SEC_002),
6278 "must carry its warning code: {log}"
6279 );
6280 assert!(
6281 log.contains(&security_warning_link(W_SEC_002)),
6282 "must link to the diagnostics doc page: {log}"
6283 );
6284 }
6285
6286 #[test]
6287 fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
6288 let temp = tempfile::tempdir().unwrap();
6289 warn_for_enforcement_tier(
6290 EnforcementTier::EnvironmentOnly,
6291 temp.path(),
6292 &warning_test_policy(),
6293 );
6294 let log = bootstrap_log_contents(temp.path());
6295 assert!(
6296 log.contains("permanent"),
6297 "must state the tier is permanent, not pending: {log}"
6298 );
6299 assert!(
6300 log.contains("bash"),
6301 "the still-accurate bash/network bypass warning must also fire: {log}"
6302 );
6303 assert!(
6304 log.contains(W_SEC_001),
6305 "must carry the tier warning code: {log}"
6306 );
6307 assert!(
6308 log.contains(W_SEC_003),
6309 "must carry the bash/network bypass warning code: {log}"
6310 );
6311 }
6312
6313 // ---- fail-closed (finding #2: forced setup failure must prevent spawn) ----
6314
6315 struct ForcePrepareFailureGuard;
6316
6317 impl ForcePrepareFailureGuard {
6318 fn new() -> Self {
6319 FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
6320 Self
6321 }
6322 }
6323
6324 impl Drop for ForcePrepareFailureGuard {
6325 fn drop(&mut self) {
6326 FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
6327 }
6328 }
6329
6330 #[test]
6331 fn execute_shell_fails_closed_when_enforcement_setup_fails() {
6332 let temp = tempfile::tempdir().unwrap();
6333 let policy = CapabilityPolicy {
6334 shell_allow: vec!["bash".to_string()],
6335 ..CapabilityPolicy::default()
6336 };
6337 let marker = temp.path().join("spawned-anyway.marker");
6338 let script = format!("echo ran > '{}'", marker.display());
6339
6340 {
6341 let _guard = ForcePrepareFailureGuard::new();
6342 let error = crate::shell::execute_shell(
6343 "bash",
6344 &["-c", &script],
6345 &[],
6346 temp.path(),
6347 temp.path(),
6348 &policy,
6349 &ShellEnforcement::environment_only(),
6350 )
6351 .unwrap_err();
6352 assert!(
6353 error.to_string().contains("sandbox"),
6354 "the error must name what failed to initialize: {error}"
6355 );
6356 assert!(
6357 !marker.exists(),
6358 "no subprocess may be spawned at all when enforcement setup fails"
6359 );
6360 }
6361
6362 // Control run without the forced failure: the identical call spawns and writes the
6363 // marker — proving the marker really would have caught a spawn above.
6364 let result = crate::shell::execute_shell(
6365 "bash",
6366 &["-c", &script],
6367 &[],
6368 temp.path(),
6369 temp.path(),
6370 &policy,
6371 &ShellEnforcement::environment_only(),
6372 )
6373 .unwrap();
6374 assert_eq!(result.exit_code, 0);
6375 assert!(marker.exists());
6376 }
6377}
6378
6379#[cfg(target_os = "linux")]
6380#[cfg(test)]
6381#[allow(clippy::print_stdout, clippy::print_stderr)]
6382mod landlock_probe_isolation {
6383 /// The Landlock probe must leave the calling process exactly as it found it.
6384 ///
6385 /// Stated as "the sealed probe gives the same answer before and after" rather than by
6386 /// inspecting the process's Landlock domain, because there is no API to ask whether a task
6387 /// is landlocked — and because that framing is what actually broke: the probe restricted
6388 /// itself, and every later attempt to build a composed root was refused, on every host, in
6389 /// a way that looked like the host's fault.
6390 ///
6391 /// Host-independent by construction. On a machine that cannot do `sealed` at all, both
6392 /// halves report the same failure and this passes trivially; on a capable one, an
6393 /// in-process `restrict_self` turns the second answer into a mount denial and fails it.
6394 #[test]
6395 fn the_landlock_probe_does_not_restrict_the_calling_process() {
6396 let before = crate::sealed::probe_sealed_support().namespace;
6397 let _ = super::linux_enforce::probe_landlock_full_access();
6398 let after = crate::sealed::probe_sealed_support().namespace;
6399
6400 assert_eq!(
6401 before, after,
6402 "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
6403 it has restricted the calling process instead of a forked child, which permanently \
6404 forbids the mount family to this process and everything it spawns",
6405 );
6406 }
6407}
6408
6409#[cfg(all(test, target_os = "linux"))]
6410#[allow(clippy::print_stdout, clippy::print_stderr)]
6411mod linux_integration_tests {
6412 use std::path::Path;
6413
6414 use super::*;
6415
6416 /// Spread-base supplying the four *host-bounding* fields (`resource_limits`,
6417 /// `nproc_baseline`, `cgroup_scope`, `workdir_guard`) to the enforcement literals below,
6418 /// which spell out the policy half by hand because they need to pin `tier` explicitly.
6419 ///
6420 /// These tests exercise seccomp, Landlock and the network allowlist — not rlimits or
6421 /// cgroups — so they take exactly what `ShellEnforcement::resolve` produces before
6422 /// `with_host_bounding` attaches a live session's handles: the real default ceilings (not
6423 /// "unbounded" — zeroing them would misrepresent what a real host does, and the rlimit half
6424 /// of the child `pre_exec` runs on every tier), the real uid baseline, and no cgroup scope
6425 /// or workdir guard. The `tier` it carries is always overridden by the literal that spreads
6426 /// it; only the host-bounding tail is ever consumed from here.
6427 fn host_bounding_base() -> ShellEnforcement {
6428 ShellEnforcement::environment_only()
6429 }
6430
6431 /// `<accessible>/.murmur/<session id>/`, the session workdir a `--workdir` launch stages
6432 /// into — the shape that makes the two workdirs different paths, which is the only shape in
6433 /// which putting a staging directory in the wrong one is visible.
6434 fn session_workdir_under(accessible: &Path) -> PathBuf {
6435 let session = accessible.join(".murmur").join("ses_sandbox_tests");
6436 std::fs::create_dir_all(&session).unwrap();
6437 session
6438 }
6439
6440 /// Pure content check on the workdir access-right set: no kernel call, no fork, no spawn.
6441 /// It pins *which* Landlock ABI v1 rights `apply_landlock_scope` hands the workdir, so
6442 /// re-adding device-node creation becomes a test failure instead of a silent regression.
6443 ///
6444 /// It proves **nothing** about whether the kernel enforces that set — that is the manual
6445 /// acceptance procedure in
6446 /// `docs/content/reference/workdir-device-node-manual-verification.md`, not this test.
6447 /// It lives in this `#[cfg(target_os = "linux")]` module rather than the cross-platform
6448 /// `tests` module only because `landlock::AccessFs` is a Linux-only dependency.
6449 #[test]
6450 fn workdir_landlock_grant_withholds_device_and_socket_creation() {
6451 use landlock::{Access, AccessFs, ABI};
6452
6453 let all_v1 = AccessFs::from_all(ABI::V1);
6454 // The `workdir_exec: true` set — the widest the workdir can ever be — so the three
6455 // unconditionally-withheld rights below are checked against the permissive case rather
6456 // than passing trivially because `Execute` happened to be absent too.
6457 let granted = linux_enforce::workdir_access_rights(true);
6458
6459 for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
6460 assert!(
6461 all_v1.contains(withheld),
6462 "{withheld:?} is expected to be part of Landlock ABI v1 — if it is not, this \
6463 test's premise (and the workdir grant's comment) needs revisiting"
6464 );
6465 assert!(
6466 !granted.contains(withheld),
6467 "the workdir grant must withhold {withheld:?}: with the full ABI v1 set declared \
6468 by handle_access, withholding it here is what makes it denied rather than merely \
6469 un-granted"
6470 );
6471 }
6472
6473 assert!(
6474 granted.contains(AccessFs::MakeFifo),
6475 "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
6476 );
6477
6478 let expected = all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
6479 assert_eq!(
6480 granted, expected,
6481 "the workdir_exec grant must be exactly Landlock ABI v1 minus \
6482 MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
6483 this test and the constant's comment together"
6484 );
6485 }
6486
6487 /// The `workdir_exec` bit, pinned in both directions. A *content* check on the right set
6488 /// only: it proves nothing about whether a kernel refuses an exec, which is the manual
6489 /// procedure in
6490 /// `docs/content/reference/workdir-exec-landlock-manual-verification.md`. It catches the one
6491 /// regression that matters here: flipping the default back — which would silently reopen the
6492 /// rename-to-an-allowlisted-basename bypass — cannot happen without this test failing.
6493 #[test]
6494 fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
6495 use landlock::AccessFs;
6496
6497 assert!(
6498 !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
6499 "the default must withhold Execute from the workdir — that withholding is the whole \
6500 of what enforces capabilities.shell.allow now that the exec supervisor is gone"
6501 );
6502 assert!(
6503 linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
6504 "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
6505 compile-and-run workflows it exists for silently stop working"
6506 );
6507
6508 // Nothing else moves with the flag: the two sets differ by exactly `Execute`.
6509 assert_eq!(
6510 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
6511 linux_enforce::workdir_access_rights(false),
6512 "workdir_exec must be a one-bit axis — no other right may ride along with it"
6513 );
6514 }
6515
6516 /// The tier gate on the workdir-backed `/tmp` fd, in both directions, against a real
6517 /// `tempfile::tempdir()` workdir. Opening an fd is an ordinary `open()` — no Landlock call, no
6518 /// namespace, no privilege — so this runs on any Linux host, unlike the enforcement it feeds.
6519 ///
6520 /// It proves **nothing** about whether the kernel then honors the rule built from that fd; that
6521 /// is the hand-run procedure in
6522 /// `docs/content/reference/sealed-containment-manual-verification.md` (step "3d — writability").
6523 /// It catches two things: that the fd is there to build a rule from at all, and that the
6524 /// directory backing it exists before `build_sealed_root` — which runs *after* this function —
6525 /// binds it.
6526 #[test]
6527 fn the_workdir_backed_tmp_store_is_opened_for_landlock_on_the_sealed_tier_only() {
6528 let accessible = tempfile::tempdir().unwrap();
6529 let session = session_workdir_under(accessible.path());
6530 let fds = linux_enforce::open_landlock_fds(
6531 accessible.path(),
6532 &session,
6533 &[],
6534 &[],
6535 false,
6536 EnforcementTier::KernelSealed,
6537 )
6538 .expect("a real, writable workdir must yield sealed Landlock fds");
6539
6540 assert!(
6541 fds.sealed_tmp_fd().is_some(),
6542 "the composed root binds {} at /tmp read-write on every sealed capsule, so its fd must \
6543 always be opened — a missing rule leaves /tmp mounted but EACCES",
6544 crate::sealed::SEALED_TMP_DIR_NAME,
6545 );
6546 assert!(
6547 session.join(crate::sealed::SEALED_TMP_DIR_NAME).is_dir(),
6548 "the /tmp store must be created here: open_landlock_fds runs before build_sealed_root, \
6549 so nothing else has made this directory yet",
6550 );
6551 assert!(
6552 !accessible
6553 .path()
6554 .join(crate::sealed::SEALED_TMP_DIR_NAME)
6555 .exists(),
6556 "enabling sealed must add nothing to the top level of the accessible workdir — that \
6557 directory is the capsule's deliverable",
6558 );
6559
6560 for tier in [
6561 EnforcementTier::KernelFull,
6562 EnforcementTier::KernelSeccompOnly,
6563 EnforcementTier::EnvironmentOnly,
6564 ] {
6565 let accessible = tempfile::tempdir().unwrap();
6566 let session = session_workdir_under(accessible.path());
6567 let fds = linux_enforce::open_landlock_fds(
6568 accessible.path(),
6569 &session,
6570 &[],
6571 &[],
6572 false,
6573 tier,
6574 )
6575 .expect("a real, writable workdir must yield Landlock fds on any tier");
6576
6577 assert!(
6578 fds.sealed_tmp_fd().is_none(),
6579 "{tier:?} composes no root and binds nothing at /tmp, so granting a workdir-backed \
6580 /tmp would grant a path this tier's capsule never sees",
6581 );
6582 assert!(
6583 !session.join(crate::sealed::SEALED_TMP_DIR_NAME).exists(),
6584 "{tier:?} must not even create the store — an unbound directory in the session \
6585 workdir is litter the capsule can see",
6586 );
6587 }
6588 }
6589
6590 /// The synthetic account databases, written and opened by the same parent-side call, against a
6591 /// real `tempfile::tempdir()` workdir.
6592 ///
6593 /// Like the `/tmp` test above it proves nothing about kernel enforcement — that is the hand-run
6594 /// in `docs/content/reference/sealed-containment-manual-verification.md`. It catches three
6595 /// things: that the files exist before `build_sealed_root` binds them, that there is an fd to
6596 /// build each rule from (without one, Landlock matches the *host's* `/etc/passwd` inode and the
6597 /// bound synthetic file is mounted-but-`EACCES`), and that neither is created on a tier that
6598 /// composes no root.
6599 #[test]
6600 fn the_synthetic_etc_files_are_written_and_opened_on_the_sealed_tier_only() {
6601 let accessible = tempfile::tempdir().unwrap();
6602 let session = session_workdir_under(accessible.path());
6603 let fds = linux_enforce::open_landlock_fds(
6604 accessible.path(),
6605 &session,
6606 &[],
6607 &[],
6608 false,
6609 EnforcementTier::KernelSealed,
6610 )
6611 .expect("a real, writable workdir must yield sealed Landlock fds");
6612
6613 let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
6614 .iter()
6615 .filter(|entry| entry.synthetic.is_some())
6616 .collect();
6617 assert_eq!(
6618 fds.sealed_identity_fd_count(),
6619 synthetic.len(),
6620 "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
6621 );
6622 for entry in &synthetic {
6623 let path = crate::sealed::synthetic_etc_source(&session, entry.synthetic.unwrap());
6624 assert!(
6625 path.is_file(),
6626 "{} must be written here: open_landlock_fds runs before build_sealed_root, so \
6627 nothing else has written it yet",
6628 entry.path,
6629 );
6630 }
6631 assert!(
6632 !accessible
6633 .path()
6634 .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
6635 .exists(),
6636 "the staging directory belongs to the session, not to the capsule's deliverable",
6637 );
6638
6639 for tier in [
6640 EnforcementTier::KernelFull,
6641 EnforcementTier::KernelSeccompOnly,
6642 EnforcementTier::EnvironmentOnly,
6643 ] {
6644 let accessible = tempfile::tempdir().unwrap();
6645 let session = session_workdir_under(accessible.path());
6646 let fds = linux_enforce::open_landlock_fds(
6647 accessible.path(),
6648 &session,
6649 &[],
6650 &[],
6651 false,
6652 tier,
6653 )
6654 .expect("a real, writable workdir must yield Landlock fds on any tier");
6655
6656 assert_eq!(
6657 fds.sealed_identity_fd_count(),
6658 0,
6659 "{tier:?} composes no root, so its capsule reads the host's real /etc/passwd and \
6660 these files stand in for nothing",
6661 );
6662 assert!(
6663 !session
6664 .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
6665 .exists(),
6666 "{tier:?} must not even create the staging directory — same reason as .mur-tmp",
6667 );
6668 }
6669 }
6670
6671 /// The one field that has to agree across two files: the synthetic `pw_dir` and `$HOME`.
6672 ///
6673 /// Both are derived from `shell::SYNTHETIC_HOME_DIR_NAME`, and this asserts the end result
6674 /// rather than the constant — a capsule sees `pwd.getpwuid(os.getuid()).pw_dir` and
6675 /// `os.environ["HOME"]`, and a disagreement between them is silent until a tool picks the
6676 /// wrong one. The uid/gid are checked against the workdir's own owner, which is where
6677 /// `ensure_sealed_identity_files` reads the capsule's identity from.
6678 #[test]
6679 fn the_synthetic_passwd_entry_carries_exactly_the_home_the_subprocess_env_gets() {
6680 use std::os::unix::fs::MetadataExt;
6681
6682 let accessible = tempfile::tempdir().unwrap();
6683 let session = session_workdir_under(accessible.path());
6684 let written = ensure_sealed_identity_files(&session).unwrap();
6685 let env = crate::shell::build_shell_env(
6686 &crate::types::CapabilityPolicy::default(),
6687 &[],
6688 &session,
6689 )
6690 .unwrap();
6691
6692 let passwd = std::fs::read_to_string(&written[0]).unwrap();
6693 let owner = std::fs::metadata(&session).unwrap();
6694 let entry: Vec<&str> = passwd
6695 .lines()
6696 .last()
6697 .expect("the capsule's own entry is the last line")
6698 .split(':')
6699 .collect();
6700
6701 assert_eq!(entry[2], owner.uid().to_string(), "uid field");
6702 assert_eq!(entry[3], owner.gid().to_string(), "gid field");
6703 assert_eq!(
6704 Some(&entry[5].to_string()),
6705 env.get("HOME"),
6706 "pw_dir and $HOME are the same string or the entry is worse than useless",
6707 );
6708
6709 let group = std::fs::read_to_string(&written[1]).unwrap();
6710 assert_eq!(
6711 group.lines().last().unwrap().split(':').nth(2),
6712 Some(owner.gid().to_string().as_str()),
6713 "gid field of the capsule's own group",
6714 );
6715 }
6716
6717 /// Pure content check on the right-set the composed root's `/tmp` rule carries: no kernel call,
6718 /// no fork, no spawn.
6719 ///
6720 /// `apply_landlock_scope` resolves `workdir_access_rights(fds.workdir_exec)` into a single
6721 /// local and adds it to *both* the workdir rule and the `/tmp` rule, so the two sets are equal
6722 /// by construction; what this test pins is the content of that shared set as `/tmp`'s users
6723 /// need it — a compiler writing intermediates, `mktemp` creating and removing files, a package
6724 /// manager creating build directories — and the `Execute` axis, which must track
6725 /// `workdir_exec` on `/tmp` exactly as it does on the workdir, because both paths resolve to
6726 /// the same storage and a binary staged in one is the same bytes as a binary staged in the
6727 /// other.
6728 #[test]
6729 fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
6730 use landlock::AccessFs;
6731
6732 for workdir_exec in [false, true] {
6733 let rights = linux_enforce::workdir_access_rights(workdir_exec);
6734
6735 for needed in [
6736 AccessFs::WriteFile,
6737 AccessFs::ReadFile,
6738 AccessFs::ReadDir,
6739 AccessFs::MakeReg,
6740 AccessFs::MakeDir,
6741 AccessFs::RemoveFile,
6742 AccessFs::RemoveDir,
6743 ] {
6744 assert!(
6745 rights.contains(needed),
6746 "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
6747 `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
6748 );
6749 }
6750
6751 assert_eq!(
6752 rights.contains(AccessFs::Execute),
6753 workdir_exec,
6754 "/tmp is backed by a directory inside the workdir, so Execute there must follow \
6755 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
6756 make /tmp a hole through which capsule-written bytes run",
6757 );
6758 }
6759
6760 assert_eq!(
6761 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
6762 linux_enforce::workdir_access_rights(false),
6763 "the /tmp rule and the workdir rule share one right-set binding in \
6764 apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
6765 that binding must be revisited together",
6766 );
6767 }
6768
6769 #[test]
6770 fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
6771 let mut command = std::process::Command::new("true");
6772 let enforcement = ShellEnforcement::environment_only();
6773 let supervisor = prepare_enforcement(
6774 &mut command,
6775 &enforcement,
6776 Path::new("/tmp"),
6777 Path::new("/tmp"),
6778 )
6779 .expect("environment_only must never fail");
6780 supervisor.join_best_effort();
6781 }
6782
6783 // Exec denial outside the allowlist is deliberately not asserted here. Exec is a Landlock
6784 // right, so the claim holds only on `KernelFull`/`KernelSealed`; on any weaker tier such a
6785 // test passes while exercising a path that enforces nothing. The two tests below assert the
6786 // opposite direction — that allowlisted binaries still run — which any tier does evidence.
6787
6788 #[test]
6789 fn kernel_tier_allows_exec_within_shell_allowlist() {
6790 if crate::network_namespace::skip_without_egress_namespace(
6791 "kernel_tier_allows_exec_within_shell_allowlist",
6792 ) {
6793 return;
6794 }
6795 let tier = detect_enforcement_tier();
6796 if tier == EnforcementTier::EnvironmentOnly {
6797 eprintln!(
6798 "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
6799 EnvironmentOnly on a Linux host — degrading gracefully"
6800 );
6801 return;
6802 }
6803
6804 let temp = tempfile::tempdir().unwrap();
6805 let policy = CapabilityPolicy {
6806 shell_allow: vec!["bash".to_string()],
6807 ..CapabilityPolicy::default()
6808 };
6809 let enforcement = ShellEnforcement {
6810 tier,
6811 network_allow_ips: Vec::new(),
6812 unix_sockets_allowed: policy.unix_sockets_allowed,
6813 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
6814 &resolve_exec_allowlist(&policy.shell_allow),
6815 )),
6816 ..host_bounding_base()
6817 };
6818
6819 let result = crate::shell::execute_shell(
6820 "bash",
6821 &["-c", "exit 7"],
6822 &[],
6823 temp.path(),
6824 temp.path(),
6825 &policy,
6826 &enforcement,
6827 )
6828 .unwrap();
6829
6830 assert_eq!(
6831 result.exit_code, 7,
6832 "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
6833 proving the allowlist isn't a blanket deny"
6834 );
6835 }
6836
6837 #[test]
6838 fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
6839 if crate::network_namespace::skip_without_egress_namespace(
6840 "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
6841 ) {
6842 return;
6843 }
6844 let tier = detect_enforcement_tier();
6845 if tier == EnforcementTier::EnvironmentOnly {
6846 eprintln!(
6847 "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
6848 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
6849 );
6850 return;
6851 }
6852
6853 let temp = tempfile::tempdir().unwrap();
6854 let policy = CapabilityPolicy {
6855 shell_allow: vec!["bash".to_string(), "ls".to_string()],
6856 ..CapabilityPolicy::default()
6857 };
6858 let enforcement = ShellEnforcement {
6859 tier,
6860 network_allow_ips: Vec::new(),
6861 unix_sockets_allowed: policy.unix_sockets_allowed,
6862 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
6863 &resolve_exec_allowlist(&policy.shell_allow),
6864 )),
6865 ..host_bounding_base()
6866 };
6867
6868 // Unlike `exit 7` (a builtin), `ls` forces a genuinely *nested* execve from inside
6869 // bash — the case the no-regression scenario ("allowlisted binary still functions")
6870 // actually cares about.
6871 let result = crate::shell::execute_shell(
6872 "bash",
6873 &["-c", "ls"],
6874 &[],
6875 temp.path(),
6876 temp.path(),
6877 &policy,
6878 &enforcement,
6879 )
6880 .unwrap();
6881
6882 assert_eq!(
6883 result.exit_code, 0,
6884 "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
6885 (stderr: {})",
6886 result.stderr
6887 );
6888 }
6889
6890 #[test]
6891 fn kernel_tier_denies_network_connect_outside_allowlist() {
6892 if crate::network_namespace::skip_without_egress_namespace(
6893 "kernel_tier_denies_network_connect_outside_allowlist",
6894 ) {
6895 return;
6896 }
6897 let tier = detect_enforcement_tier();
6898 if tier == EnforcementTier::EnvironmentOnly {
6899 eprintln!(
6900 "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
6901 EnvironmentOnly on a Linux host — degrading gracefully"
6902 );
6903 return;
6904 }
6905
6906 // A real listener on the *host*, so the destination port is genuinely open. The failure
6907 // below is therefore about reachability, not about a closed port: the subprocess runs in
6908 // its own network namespace, where `127.0.0.1` is that namespace's own loopback and this
6909 // listener does not exist at all.
6910 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
6911 let port = listener.local_addr().unwrap().port();
6912
6913 let temp = tempfile::tempdir().unwrap();
6914 let policy = CapabilityPolicy {
6915 shell_allow: vec!["bash".to_string()],
6916 ..CapabilityPolicy::default()
6917 };
6918 let enforcement = ShellEnforcement {
6919 tier,
6920 // Empty allowlist: nothing is reachable, and the proxy would refuse it even if the
6921 // capsule found its way to the endpoint.
6922 network_allow_rules: Vec::new(),
6923 network_allow_ips: Vec::new(),
6924 unix_sockets_allowed: policy.unix_sockets_allowed,
6925 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
6926 &resolve_exec_allowlist(&policy.shell_allow),
6927 )),
6928 ..host_bounding_base()
6929 };
6930
6931 // bash's /dev/tcp is a builtin socket+connect — no extra binary needed, no DNS.
6932 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
6933 let result = crate::shell::execute_shell(
6934 "bash",
6935 &["-c", &script],
6936 &[],
6937 temp.path(),
6938 temp.path(),
6939 &policy,
6940 &enforcement,
6941 )
6942 .unwrap();
6943
6944 assert_ne!(
6945 result.exit_code, 0,
6946 "a direct connect() to a destination outside capabilities.network.allow must fail \
6947 even though the port is really open on the host — the subprocess is in its own \
6948 network namespace with no route to it"
6949 );
6950 }
6951
6952 /// The no-regression counterpart of the test above: enforcement must add denials for
6953 /// out-of-policy destinations without breaking permitted ones.
6954 ///
6955 /// The allowlist has to be declared rather than only pre-resolved, because it decides two
6956 /// things: which addresses are permitted *and* which ports the namespace binds a listener on.
6957 /// A port no allow entry implies has nothing listening and is refused by the namespace itself.
6958 ///
6959 /// Deliberately *not* a test of the security property — a green suite proves nothing about
6960 /// the kernel-level claim, and the real check is the hand-run procedure in
6961 /// `docs/content/reference/network-namespace-egress-proxy-manual-verification.md`. This
6962 /// asserts only that the permitted path still functions.
6963 #[test]
6964 fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
6965 if crate::network_namespace::skip_without_egress_namespace(
6966 "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
6967 ) {
6968 return;
6969 }
6970 let tier = detect_enforcement_tier();
6971 if tier == EnforcementTier::EnvironmentOnly {
6972 eprintln!(
6973 "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
6974 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
6975 );
6976 return;
6977 }
6978
6979 // A real listener on the host, answering one connection, so a success below means bytes
6980 // really crossed the namespace boundary through the proxy rather than merely connecting.
6981 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
6982 let port = listener.local_addr().unwrap().port();
6983 let server = std::thread::spawn(move || {
6984 if let Ok((mut stream, _)) = listener.accept() {
6985 use std::io::Write;
6986 let _ = stream.write_all(b"pong\n");
6987 }
6988 });
6989
6990 let temp = tempfile::tempdir().unwrap();
6991 // Naming the port is required, not incidental: it is what puts a listener on that port
6992 // inside the namespace. See `egress_proxy::egress_listen_ports`.
6993 let allow = vec![format!("http://127.0.0.1:{port}")];
6994 let policy = CapabilityPolicy {
6995 shell_allow: vec!["bash".to_string()],
6996 network_allow: allow.clone(),
6997 ..CapabilityPolicy::default()
6998 };
6999 let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
7000 let enforcement = ShellEnforcement {
7001 tier,
7002 egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
7003 network_allow_rules: rules,
7004 network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
7005 unix_sockets_allowed: policy.unix_sockets_allowed,
7006 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7007 &resolve_exec_allowlist(&policy.shell_allow),
7008 )),
7009 ..host_bounding_base()
7010 };
7011
7012 // Builtins only — `/dev/tcp` and `read` are bash itself — so no second binary is exec'd
7013 // and this measures the network path alone.
7014 let script =
7015 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
7016 let result = crate::shell::execute_shell(
7017 "bash",
7018 &["-c", &script],
7019 &[],
7020 temp.path(),
7021 temp.path(),
7022 &policy,
7023 &enforcement,
7024 )
7025 .unwrap();
7026 let _ = server.join();
7027
7028 assert_eq!(
7029 result.exit_code, 0,
7030 "a destination inside capabilities.network.allow must stay reachable — enforcement \
7031 adds denials for out-of-policy destinations, it does not degrade permitted ones \
7032 (stderr: {})",
7033 result.stderr
7034 );
7035 assert!(
7036 result.stdout.contains("pong"),
7037 "the host listener's bytes must come back through the proxy unmodified \
7038 (stdout: {:?}, stderr: {})",
7039 result.stdout,
7040 result.stderr
7041 );
7042 }
7043
7044 #[test]
7045 fn kernel_full_denies_filesystem_access_outside_workdir() {
7046 if crate::network_namespace::skip_without_egress_namespace(
7047 "kernel_full_denies_filesystem_access_outside_workdir",
7048 ) {
7049 return;
7050 }
7051 let tier = detect_enforcement_tier();
7052 if tier != EnforcementTier::KernelFull {
7053 eprintln!(
7054 "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
7055 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
7056 instead"
7057 );
7058 return;
7059 }
7060
7061 let temp = tempfile::tempdir().unwrap();
7062 let policy = CapabilityPolicy {
7063 shell_allow: vec!["bash".to_string()],
7064 ..CapabilityPolicy::default()
7065 };
7066 let enforcement = ShellEnforcement {
7067 tier,
7068 network_allow_ips: Vec::new(),
7069 unix_sockets_allowed: policy.unix_sockets_allowed,
7070 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7071 &resolve_exec_allowlist(&policy.shell_allow),
7072 )),
7073 ..host_bounding_base()
7074 };
7075
7076 let result = crate::shell::execute_shell(
7077 "bash",
7078 &["-c", "cat /etc/hostname"],
7079 &[],
7080 temp.path(),
7081 temp.path(),
7082 &policy,
7083 &enforcement,
7084 )
7085 .unwrap();
7086
7087 assert_ne!(
7088 result.exit_code, 0,
7089 "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
7090 );
7091 }
7092
7093 // The two tests below exercise the derived Landlock grants. A green run is not evidence they
7094 // work: below `KernelFull` the weaker `KernelSeccompOnly` path runs instead, and on non-Linux
7095 // no Landlock domain is installed at all. Both tests print a skip line and return when the
7096 // host is not `KernelFull`.
7097
7098 #[test]
7099 fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
7100 {
7101 if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
7102 return;
7103 }
7104 let tier = detect_enforcement_tier();
7105 if tier != EnforcementTier::KernelFull {
7106 eprintln!(
7107 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7108 kernel_full_runs_nontrivial_shell_allowlist_... requires \
7109 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7110 This run does NOT execute the derived-grant Landlock code path at all, so a \
7111 green result here is not evidence the workdir-scope exec bug is fixed — only a \
7112 real KernelFull Linux host can prove that."
7113 );
7114 return;
7115 }
7116
7117 let temp = tempfile::tempdir().unwrap();
7118 // A non-trivial allowlist: an interpreter (`bash`) plus a second, genuinely nested,
7119 // dynamically-linked binary (`cat`). Both are guaranteed present on the target dev/CI
7120 // Linux hosts and both live outside the workdir, so this only succeeds if the derived
7121 // Landlock grants cover each binary, its ELF interpreter, and its shared libraries.
7122 let policy = CapabilityPolicy {
7123 shell_allow: vec!["bash".to_string(), "cat".to_string()],
7124 ..CapabilityPolicy::default()
7125 };
7126 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7127 let enforcement = ShellEnforcement {
7128 tier,
7129 network_allow_ips: Vec::new(),
7130 unix_sockets_allowed: policy.unix_sockets_allowed,
7131 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7132 &exec_allow_paths,
7133 )),
7134 ..host_bounding_base()
7135 };
7136
7137 // `echo` is a bash builtin; piping it into `cat` forces bash to fork+exec `cat`, a real
7138 // nested, dynamically-linked exec — the case the whole fix exists for.
7139 let result = crate::shell::execute_shell(
7140 "bash",
7141 &["-c", "echo landlock-grant-ok | cat"],
7142 &[],
7143 temp.path(),
7144 temp.path(),
7145 &policy,
7146 &enforcement,
7147 )
7148 .expect("execute_shell should return Ok, not Err");
7149
7150 assert_eq!(
7151 result.exit_code, 0,
7152 "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
7153 must let each exec and dynamic-link outside the workdir (stderr: {})",
7154 result.stderr
7155 );
7156 assert!(
7157 result.stdout.contains("landlock-grant-ok"),
7158 "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
7159 result.stdout,
7160 result.stderr
7161 );
7162 }
7163
7164 #[test]
7165 fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
7166 if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
7167 return;
7168 }
7169 let tier = detect_enforcement_tier();
7170 if tier != EnforcementTier::KernelFull {
7171 eprintln!(
7172 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7173 kernel_full_denies_write_outside_workdir_... requires \
7174 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7175 This run does NOT execute the Landlock code path, so a green result here is not \
7176 evidence writes outside the workdir are actually denied — only a real KernelFull \
7177 Linux host can prove that."
7178 );
7179 return;
7180 }
7181
7182 let workdir = tempfile::tempdir().unwrap();
7183 // A target OUTSIDE the workdir (a separate temp dir). Its parent exists, so the only
7184 // reason a write can fail is the Landlock scope — not a missing directory.
7185 let outside = tempfile::tempdir().unwrap();
7186 let target = outside.path().join("should-not-be-written.txt");
7187
7188 let policy = CapabilityPolicy {
7189 shell_allow: vec!["bash".to_string()],
7190 ..CapabilityPolicy::default()
7191 };
7192 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7193 let enforcement = ShellEnforcement {
7194 tier,
7195 network_allow_ips: Vec::new(),
7196 unix_sockets_allowed: policy.unix_sockets_allowed,
7197 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
7198 &exec_allow_paths,
7199 )),
7200 ..host_bounding_base()
7201 };
7202
7203 // `echo >file` uses only a bash builtin + an open-for-write of an outside path — the
7204 // write itself is what Landlock must refuse. The derived read+execute grants never widen
7205 // to write, so nothing outside the workdir (grant paths included) is writable.
7206 let script = format!("echo pwned > '{}'", target.display());
7207 let result = crate::shell::execute_shell(
7208 "bash",
7209 &["-c", &script],
7210 &[],
7211 workdir.path(),
7212 workdir.path(),
7213 &policy,
7214 &enforcement,
7215 )
7216 .expect("execute_shell should return Ok with a nonzero exit code, not Err");
7217
7218 assert!(
7219 !target.exists(),
7220 "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
7221 );
7222 assert_ne!(
7223 result.exit_code, 0,
7224 "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
7225 );
7226 }
7227
7228 /// Builds a KernelFull enforcement for `policy`, combining the derived `DT_NEEDED`-closure
7229 /// file grants with the policy's `interpreter_runtime` directory grants — exactly what
7230 /// `ShellEnforcement::resolve` does, spelled out so the test controls the tier explicitly.
7231 fn kernel_full_enforcement(
7232 tier: EnforcementTier,
7233 policy: &CapabilityPolicy,
7234 ) -> ShellEnforcement {
7235 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
7236 let mut landlock_grants =
7237 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
7238 landlock_grants.extend(resolve_interpreter_runtime_grants(
7239 &policy.shell_interpreter_runtime,
7240 ));
7241 ShellEnforcement {
7242 tier,
7243 network_allow_ips: Vec::new(),
7244 unix_sockets_allowed: policy.unix_sockets_allowed,
7245 landlock_grants,
7246 ..host_bounding_base()
7247 }
7248 }
7249
7250 /// A real sealed run against two different workdirs: the composed root's `/tmp` and its
7251 /// synthetic `/etc/passwd` both have to resolve from the session workdir, and the capsule's
7252 /// own directory has to gain nothing from either.
7253 ///
7254 /// This is the one test that drives a composed root end to end, so it needs a host that can
7255 /// actually reach `KernelSealed` and skips loudly on one that cannot; the parent-side halves
7256 /// are covered unconditionally by the `open_landlock_fds` tests above. `mur doctor` reports
7257 /// the same host answer this checks.
7258 #[test]
7259 fn a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir() {
7260 if crate::network_namespace::skip_without_egress_namespace(
7261 "a_sealed_command_finds_tmp_and_etc_after_they_moved_into_the_session_workdir",
7262 ) {
7263 return;
7264 }
7265 let probe = HostProbe::probe();
7266 if probe.tier() != EnforcementTier::KernelSealed {
7267 eprintln!(
7268 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
7269 a_sealed_command_finds_tmp_and_etc_... needs a host that reaches KernelSealed \
7270 (unprivileged user namespaces plus a usable Landlock ABI); detected {:?}. No \
7271 composed root is built in this run.",
7272 probe.tier()
7273 );
7274 return;
7275 }
7276
7277 let accessible = tempfile::tempdir().unwrap();
7278 let session = accessible.path().join(".murmur").join("ses_sealed_e2e");
7279 std::fs::create_dir_all(&session).unwrap();
7280 let policy = CapabilityPolicy {
7281 shell_allow: vec!["bash".to_string(), "cat".to_string(), "id".to_string()],
7282 ..CapabilityPolicy::default()
7283 };
7284 let enforcement =
7285 ShellEnforcement::resolve(&policy, murmur_artifact::ContainmentClass::Sealed, probe)
7286 .expect("a sealed-capable host resolves sealed enforcement");
7287 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
7288
7289 let result = crate::shell::execute_shell(
7290 "bash",
7291 &["-c", "echo t > /tmp/probe && cat /tmp/probe && id -un"],
7292 &[],
7293 accessible.path(),
7294 &session,
7295 &policy,
7296 &enforcement,
7297 )
7298 .expect("execute_shell returns Ok even when the command itself fails");
7299
7300 assert_eq!(
7301 result.exit_code, 0,
7302 "stdout: {} stderr: {}",
7303 result.stdout, result.stderr
7304 );
7305 let mut lines = result.stdout.lines();
7306 assert_eq!(
7307 lines.next(),
7308 Some("t"),
7309 "the capsule's /tmp must be writable"
7310 );
7311 assert!(
7312 lines.next().is_some_and(|name| !name.is_empty()),
7313 "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
7314 {}",
7315 result.stdout
7316 );
7317
7318 assert_eq!(
7319 std::fs::read_to_string(
7320 session
7321 .join(crate::sealed::SEALED_TMP_DIR_NAME)
7322 .join("probe")
7323 )
7324 .unwrap(),
7325 "t\n",
7326 "the bytes written to /tmp land in the session workdir's own store"
7327 );
7328 assert!(session
7329 .join(crate::sealed::SEALED_ETC_STAGING_DIR_NAME)
7330 .join("passwd")
7331 .is_file());
7332 for name in [
7333 crate::sealed::SEALED_TMP_DIR_NAME,
7334 crate::sealed::SEALED_ETC_STAGING_DIR_NAME,
7335 crate::shell::SYNTHETIC_HOME_DIR_NAME,
7336 ] {
7337 assert!(
7338 !accessible.path().join(name).exists(),
7339 "enabling sealed must add nothing to the top level of the accessible workdir, \
7340 and it added {name}"
7341 );
7342 }
7343 }
7344
7345 // As above: a green run proves nothing below `KernelFull`. Both tests print a skip line and
7346 // return when the host is not `KernelFull`.
7347
7348 #[test]
7349 fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
7350 if crate::network_namespace::skip_without_egress_namespace(
7351 "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
7352 ) {
7353 return;
7354 }
7355 let tier = detect_enforcement_tier();
7356 if tier != EnforcementTier::KernelFull {
7357 eprintln!(
7358 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7359 kernel_full_interpreter_runtime_list_dir_false_... requires \
7360 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7361 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
7362 result here is not evidence list_dir:false behaves correctly — only a real \
7363 KernelFull Linux host can prove that."
7364 );
7365 return;
7366 }
7367
7368 let workdir = tempfile::tempdir().unwrap();
7369 // A "stdlib"-shaped directory OUTSIDE the workdir, holding one known file.
7370 let stdlib = tempfile::tempdir().unwrap();
7371 let module = stdlib.path().join("mod.py");
7372 std::fs::write(&module, "x = 1\n").unwrap();
7373
7374 let policy = CapabilityPolicy {
7375 shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
7376 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7377 binary: "bash".to_string(),
7378 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7379 path: stdlib.path().to_string_lossy().into_owned(),
7380 list_dir: false,
7381 }],
7382 }],
7383 ..CapabilityPolicy::default()
7384 };
7385 let enforcement = kernel_full_enforcement(tier, &policy);
7386
7387 // Opening the file by its exact known name must succeed — Landlock's read rights apply to
7388 // the subtree beneath the granted directory.
7389 let read = crate::shell::execute_shell(
7390 "bash",
7391 &["-c", &format!("cat '{}'", module.display())],
7392 &[],
7393 workdir.path(),
7394 workdir.path(),
7395 &policy,
7396 &enforcement,
7397 )
7398 .expect("execute_shell should return Ok");
7399 assert_eq!(
7400 read.exit_code, 0,
7401 "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
7402 read.stderr
7403 );
7404 assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
7405
7406 // Listing the directory's own entries must fail — ReadDir was not granted.
7407 let list = crate::shell::execute_shell(
7408 "bash",
7409 &["-c", &format!("ls '{}'", stdlib.path().display())],
7410 &[],
7411 workdir.path(),
7412 workdir.path(),
7413 &policy,
7414 &enforcement,
7415 )
7416 .expect("execute_shell should return Ok with a nonzero exit code");
7417 assert_ne!(
7418 list.exit_code, 0,
7419 "list_dir:false must deny enumerating the granted directory's own entries"
7420 );
7421 }
7422
7423 #[test]
7424 fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
7425 if crate::network_namespace::skip_without_egress_namespace(
7426 "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
7427 ) {
7428 return;
7429 }
7430 let tier = detect_enforcement_tier();
7431 if tier != EnforcementTier::KernelFull {
7432 eprintln!(
7433 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7434 kernel_full_interpreter_runtime_list_dir_true_... requires \
7435 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
7436 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
7437 result here is not evidence list_dir:true (and its non-widening to the parent) \
7438 behaves correctly — only a real KernelFull Linux host can prove that."
7439 );
7440 return;
7441 }
7442
7443 let workdir = tempfile::tempdir().unwrap();
7444 // parent/ (NOT granted) contains stdlib/ (granted list_dir:true) contains one file.
7445 let parent = tempfile::tempdir().unwrap();
7446 let stdlib = parent.path().join("stdlib");
7447 std::fs::create_dir(&stdlib).unwrap();
7448 std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
7449
7450 let policy = CapabilityPolicy {
7451 shell_allow: vec!["bash".to_string(), "ls".to_string()],
7452 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7453 binary: "bash".to_string(),
7454 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7455 path: stdlib.to_string_lossy().into_owned(),
7456 list_dir: true,
7457 }],
7458 }],
7459 ..CapabilityPolicy::default()
7460 };
7461 let enforcement = kernel_full_enforcement(tier, &policy);
7462
7463 // Listing the granted directory must succeed.
7464 let list = crate::shell::execute_shell(
7465 "bash",
7466 &["-c", &format!("ls '{}'", stdlib.display())],
7467 &[],
7468 workdir.path(),
7469 workdir.path(),
7470 &policy,
7471 &enforcement,
7472 )
7473 .expect("execute_shell should return Ok");
7474 assert_eq!(
7475 list.exit_code, 0,
7476 "list_dir:true must permit enumerating the granted directory (stderr: {})",
7477 list.stderr
7478 );
7479 assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
7480
7481 // Listing the PARENT (never named in any grant) must fail — naming one subdirectory does
7482 // not widen enumerability to its ancestors. This is the `/usr/lib` ceiling case.
7483 let list_parent = crate::shell::execute_shell(
7484 "bash",
7485 &["-c", &format!("ls '{}'", parent.path().display())],
7486 &[],
7487 workdir.path(),
7488 workdir.path(),
7489 &policy,
7490 &enforcement,
7491 )
7492 .expect("execute_shell should return Ok with a nonzero exit code");
7493 assert_ne!(
7494 list_parent.exit_code, 0,
7495 "a grant on stdlib/ must not make its (ungranted) parent enumerable"
7496 );
7497 }
7498
7499 /// The `executable` axis, asserted against a real Landlock kernel.
7500 ///
7501 /// This is the property the `sealed` runtime-tree grant depends on: a whole tree can be made
7502 /// readable and enumerable without becoming a place to run programs from. It is asserted here
7503 /// on the plain Landlock path (`KernelFull`) because the access shape is tier-independent —
7504 /// `apply_landlock_scope` builds the same rule either way — and because a `KernelSealed` host
7505 /// would have to be driven through a composed root, which no unit test can reach.
7506 ///
7507 /// Runs on any host with a usable Landlock ABI, `KernelSealed` ones included: the tier is
7508 /// pinned in the enforcement literal, so only the ABI has to be real. Skips loudly elsewhere.
7509 #[test]
7510 fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
7511 if crate::network_namespace::skip_without_egress_namespace(
7512 "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
7513 ) {
7514 return;
7515 }
7516 let host_tier = detect_enforcement_tier();
7517 if !matches!(
7518 host_tier,
7519 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
7520 ) {
7521 eprintln!(
7522 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7523 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
7524 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
7525 result is not evidence that withholding Execute denies execve."
7526 );
7527 return;
7528 }
7529
7530 let workdir = tempfile::tempdir().unwrap();
7531 // A runtime-tree stand-in: one readable file plus one runnable binary, outside the workdir.
7532 let tree = tempfile::tempdir().unwrap();
7533 std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
7534 let runme = tree.path().join("runme");
7535 std::fs::copy("/bin/echo", &runme).unwrap();
7536
7537 let policy = CapabilityPolicy {
7538 shell_allow: vec!["bash".to_string(), "ls".to_string()],
7539 ..CapabilityPolicy::default()
7540 };
7541 let with_tree_grant = |executable: bool| {
7542 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
7543 enforcement.landlock_grants.push(LandlockGrant {
7544 path: tree.path().to_path_buf(),
7545 list_dir: true,
7546 executable,
7547 });
7548 enforcement
7549 };
7550 let run = |enforcement: &ShellEnforcement, command: String| {
7551 crate::shell::execute_shell(
7552 "bash",
7553 &["-c", &command],
7554 &[],
7555 workdir.path(),
7556 workdir.path(),
7557 &policy,
7558 enforcement,
7559 )
7560 .expect("execute_shell should return Ok even when the command itself fails")
7561 };
7562
7563 let denied = with_tree_grant(false);
7564 let list = run(&denied, format!("ls '{}'", tree.path().display()));
7565 assert_eq!(
7566 list.exit_code, 0,
7567 "a non-executable grant must still be enumerable — that is its entire purpose \
7568 (stderr: {})",
7569 list.stderr
7570 );
7571 assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
7572
7573 let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
7574 assert_ne!(
7575 exec_denied.exit_code, 0,
7576 "withholding Execute must deny execve inside the granted tree; a program ran instead \
7577 (stdout: {})",
7578 exec_denied.stdout
7579 );
7580 assert!(
7581 !exec_denied.stdout.contains("ran"),
7582 "the binary produced output, so it executed: {}",
7583 exec_denied.stdout
7584 );
7585
7586 // The control: the identical grant with `executable: true` does run it. Without this the
7587 // assertion above could be passing for an unrelated reason (a bad path, a missing library).
7588 let allowed = with_tree_grant(true);
7589 let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
7590 assert_eq!(
7591 exec_allowed.exit_code, 0,
7592 "the same tree granted Execute must run (stderr: {})",
7593 exec_allowed.stderr
7594 );
7595 assert!(
7596 exec_allowed.stdout.contains("ran"),
7597 "stdout: {}",
7598 exec_allowed.stdout
7599 );
7600 }
7601
7602 /// The access shape most of `SEALED_ETC_PATHS` needs: a non-executable grant on a *regular
7603 /// file* (`/etc/ssl/certs/ca-certificates.crt`'s stand-in) must install and be readable.
7604 ///
7605 /// Two kernel facts are pinned here. First, `ReadFile` alone —
7606 /// `apply_landlock_scope`'s `(false, false)` arm — is enough to `open()` the file. Second,
7607 /// `landlock_append_fs_rule` rejects `ReadDir` on a non-directory with `EINVAL`, and the rule
7608 /// loop is fail-closed, so a `list_dir: true` *claim* on a file would refuse the whole launch
7609 /// if `open_landlock_fds` did not narrow it against the fd it opened. The second half of this
7610 /// test is that narrowing: the same grant asserted `list_dir: true` must still launch and still
7611 /// read, not die in `pre_exec`.
7612 ///
7613 /// Runs on any host with a usable Landlock ABI; skips loudly elsewhere.
7614 #[test]
7615 fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
7616 if crate::network_namespace::skip_without_egress_namespace(
7617 "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
7618 ) {
7619 return;
7620 }
7621 let host_tier = detect_enforcement_tier();
7622 if !matches!(
7623 host_tier,
7624 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
7625 ) {
7626 eprintln!(
7627 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
7628 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
7629 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
7630 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
7631 a file is narrowed instead of refusing the launch."
7632 );
7633 return;
7634 }
7635
7636 let workdir = tempfile::tempdir().unwrap();
7637 // A trust-store stand-in: a regular file outside the workdir, granted on its own.
7638 let store = tempfile::tempdir().unwrap();
7639 let bundle = store.path().join("ca-certificates.crt");
7640 std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
7641
7642 let policy = CapabilityPolicy {
7643 shell_allow: vec!["bash".to_string(), "cat".to_string()],
7644 ..CapabilityPolicy::default()
7645 };
7646 let with_file_grant = |list_dir: bool| {
7647 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
7648 enforcement.landlock_grants.push(LandlockGrant {
7649 path: bundle.clone(),
7650 list_dir,
7651 executable: false,
7652 });
7653 enforcement
7654 };
7655 let read = |enforcement: &ShellEnforcement| {
7656 crate::shell::execute_shell(
7657 "bash",
7658 &["-c", &format!("cat '{}'", bundle.display())],
7659 &[],
7660 workdir.path(),
7661 workdir.path(),
7662 &policy,
7663 enforcement,
7664 )
7665 .expect("execute_shell should return Ok even when the command itself fails")
7666 };
7667
7668 let honest = read(&with_file_grant(false));
7669 assert_eq!(
7670 honest.exit_code, 0,
7671 "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
7672 file entries rely on (stderr: {})",
7673 honest.stderr
7674 );
7675 assert!(
7676 honest.stdout.contains("BEGIN CERTIFICATE"),
7677 "stdout: {}",
7678 honest.stdout
7679 );
7680
7681 let overclaimed = read(&with_file_grant(true));
7682 assert_eq!(
7683 overclaimed.exit_code, 0,
7684 "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
7685 into an EINVAL that refuses the launch (stderr: {})",
7686 overclaimed.stderr
7687 );
7688 assert!(
7689 overclaimed.stdout.contains("BEGIN CERTIFICATE"),
7690 "stdout: {}",
7691 overclaimed.stdout
7692 );
7693 }
7694
7695 // ---- every distinct pre_exec setup failure is legible + fail-closed ----
7696 //
7697 // These force each of the three distinct sandbox setup failures and assert each produces its
7698 // OWN legible message (not the undifferentiated bare EINVAL), and that the target binary never
7699 // executes. One failure is real (an unresolvable workdir, now caught before fork()); two use
7700 // the child-side (`pre_exec`) test seams below. All are safe on any Linux host: the workdir
7701 // and no_new_privs failures happen before any real kernel enforcement, and the Landlock seam
7702 // short-circuits before any real Landlock syscall.
7703
7704 struct ForceNoNewPrivsFailureGuard;
7705
7706 impl ForceNoNewPrivsFailureGuard {
7707 fn new() -> Self {
7708 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
7709 Self
7710 }
7711 }
7712
7713 impl Drop for ForceNoNewPrivsFailureGuard {
7714 fn drop(&mut self) {
7715 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
7716 }
7717 }
7718
7719 struct ForceLandlockFailureGuard;
7720
7721 impl ForceLandlockFailureGuard {
7722 fn new() -> Self {
7723 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
7724 Self
7725 }
7726 }
7727
7728 impl Drop for ForceLandlockFailureGuard {
7729 fn drop(&mut self) {
7730 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
7731 }
7732 }
7733
7734 /// A `KernelFull` enforcement over an empty Landlock grant set. The forced-failure tests do not
7735 /// depend on any specific grant — they force the failure directly — so this exercises the
7736 /// child-side `pre_exec` steps on any Linux host regardless of its real Landlock support.
7737 fn kernel_full_empty_grants() -> ShellEnforcement {
7738 ShellEnforcement {
7739 tier: EnforcementTier::KernelFull,
7740 network_allow_ips: Vec::new(),
7741 unix_sockets_allowed: false,
7742 landlock_grants: Vec::new(),
7743 ..host_bounding_base()
7744 }
7745 }
7746
7747 /// Scenario 1 (real): `prepare_enforcement` with a `KernelFull` tier and a workdir that does
7748 /// not exist. Because the workdir's Landlock fd is opened in the PARENT, this fails
7749 /// synchronously before fork() — `.spawn()` is never called. Returns the error string.
7750 fn workdir_resolution_error() -> String {
7751 let temp = tempfile::tempdir().unwrap();
7752 let missing = temp.path().join("does-not-exist-workdir");
7753 let marker = temp.path().join("never-spawned.marker");
7754 let mut command = std::process::Command::new("bash");
7755 command
7756 .arg("-c")
7757 .arg(format!("echo ran > '{}'", marker.display()));
7758
7759 let error = prepare_enforcement(
7760 &mut command,
7761 &kernel_full_empty_grants(),
7762 &missing,
7763 &missing,
7764 )
7765 .expect_err("an unresolvable workdir must fail before fork()");
7766 assert!(
7767 !marker.exists(),
7768 "prepare_enforcement must never spawn a subprocess: {error}"
7769 );
7770 error
7771 }
7772
7773 /// Runs `execute_shell` for `bash -c 'echo ran > marker'` under `KernelFull` with `arm`'s
7774 /// child-side failure active, asserts the target script never ran (marker absent), and returns
7775 /// the resulting error string. The RAII guard `arm` returns stays alive across `.spawn()`.
7776 fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
7777 child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
7778 }
7779
7780 /// [`child_setup_failure_error`] without the lossy `.to_string()`, and over a caller-chosen
7781 /// enforcement. Tests that care which *kind* of failure happened — not merely what it reads
7782 /// like — use this: the whole point of the sealed composed-root failure is that it keeps its
7783 /// identity out to the CLI instead of arriving as one more error string.
7784 fn child_setup_failure_typed<G>(
7785 enforcement: &ShellEnforcement,
7786 arm: impl FnOnce() -> G,
7787 ) -> crate::shell::ShellExecError {
7788 let temp = tempfile::tempdir().unwrap();
7789 let policy = CapabilityPolicy {
7790 shell_allow: vec!["bash".to_string()],
7791 ..CapabilityPolicy::default()
7792 };
7793 let marker = temp.path().join("spawned-anyway.marker");
7794 let script = format!("echo ran > '{}'", marker.display());
7795
7796 let _guard = arm();
7797 let error = crate::shell::execute_shell(
7798 "bash",
7799 &["-c", &script],
7800 &[],
7801 temp.path(),
7802 temp.path(),
7803 &policy,
7804 enforcement,
7805 )
7806 .expect_err("a forced child-side setup failure must make execute_shell return Err");
7807 assert!(
7808 !marker.exists(),
7809 "no subprocess may run when pre_exec setup fails: {error}"
7810 );
7811 error
7812 }
7813
7814 #[test]
7815 fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
7816 let error = workdir_resolution_error();
7817 assert!(
7818 error.contains("workdir") && error.contains("does-not-exist-workdir"),
7819 "the error must name the workdir path that failed to resolve: {error}"
7820 );
7821 assert!(
7822 !error.contains("os error 22"),
7823 "must not collapse to the bare EINVAL string: {error}"
7824 );
7825 }
7826
7827 #[test]
7828 fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
7829 if crate::network_namespace::skip_without_egress_namespace(
7830 "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
7831 ) {
7832 return;
7833 }
7834 let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
7835 assert!(
7836 error.contains("no_new_privs"),
7837 "the error must name the no_new_privs step specifically: {error}"
7838 );
7839 assert!(
7840 !error.contains("os error 22"),
7841 "must not collapse to the bare EINVAL string: {error}"
7842 );
7843 }
7844
7845 #[test]
7846 fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
7847 if crate::network_namespace::skip_without_egress_namespace(
7848 "pre_exec_landlock_failure_is_distinct_and_fails_closed",
7849 ) {
7850 return;
7851 }
7852 let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
7853 assert!(
7854 error.contains("landlock"),
7855 "the error must name the Landlock construction step specifically: {error}"
7856 );
7857 assert!(
7858 !error.contains("os error 22"),
7859 "must not collapse to the bare EINVAL string: {error}"
7860 );
7861 }
7862
7863 #[test]
7864 fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
7865 if crate::network_namespace::skip_without_egress_namespace(
7866 "pre_exec_setup_failures_produce_pairwise_distinct_messages",
7867 ) {
7868 return;
7869 }
7870 let workdir_msg = workdir_resolution_error();
7871 let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
7872 let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
7873
7874 let messages = [
7875 ("workdir", &workdir_msg),
7876 ("no_new_privs", &no_new_privs_msg),
7877 ("landlock", &landlock_msg),
7878 ];
7879 for (i, (left_name, left)) in messages.iter().enumerate() {
7880 for (right_name, right) in &messages[i + 1..] {
7881 assert_ne!(left, right, "{left_name} vs {right_name} must differ");
7882 }
7883 }
7884
7885 for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
7886 assert!(!message.is_empty(), "a distinct message must not be empty");
7887 assert!(
7888 !message.contains("os error 22"),
7889 "no failure may read as a bare EINVAL: {message}"
7890 );
7891 }
7892 }
7893
7894 /// The first half of the composed-root failure path: a `pre_exec`
7895 /// composed-root failure comes back out of `execute_shell` as the *typed*
7896 /// `SealedRootConstructionFailed`, carrying the child's diagnostic, and reports itself as
7897 /// session-fatal. `runtime::tests` covers the second half (what the dispatch layer then
7898 /// does with it) and `murmur-cli`'s error test covers the third (`E-RUN-014`).
7899 #[test]
7900 fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
7901 if crate::network_namespace::skip_without_egress_namespace(
7902 "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
7903 ) {
7904 return;
7905 }
7906 let error =
7907 child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
7908
7909 assert!(
7910 matches!(
7911 error,
7912 crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
7913 ),
7914 "a sealed-root diagnostic must survive as its own variant, not collapse into \
7915 ShellExecError::Failed: {error}"
7916 );
7917 let fatal = error
7918 .session_fatal()
7919 .expect("a composed-root failure must end the session, not just the tool call");
7920 assert!(
7921 matches!(
7922 fatal,
7923 crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
7924 ),
7925 "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
7926 );
7927 let rendered = error.to_string();
7928 assert!(
7929 rendered.contains("composed root") && rendered.contains("unshare"),
7930 "the message must name both the mechanism and the step that failed: {rendered}"
7931 );
7932 assert!(
7933 !rendered.contains("os error 22"),
7934 "must not collapse to the bare EINVAL string: {rendered}"
7935 );
7936 }
7937
7938 /// The contrast that makes the assertion above mean something: an equally fatal-looking
7939 /// `pre_exec` failure that is *not* the composed root stays an ordinary failure, so nothing
7940 /// in this path ends a session for a Landlock or `no_new_privs` problem.
7941 #[test]
7942 fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
7943 let landlock =
7944 child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
7945 assert!(
7946 landlock.session_fatal().is_none(),
7947 "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
7948 );
7949
7950 let no_new_privs = child_setup_failure_typed(
7951 &kernel_full_empty_grants(),
7952 ForceNoNewPrivsFailureGuard::new,
7953 );
7954 assert!(
7955 no_new_privs.session_fatal().is_none(),
7956 "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
7957 );
7958 }
7959}