Skip to main content

capsule_runtime/
containment.rs

1//! Containment classes: what an operator *declares* versus what a host actually *achieves*.
2//!
3//! The two directions are deliberately kept apart:
4//!
5//! * The **declared floor** ([`murmur_artifact::ContainmentClass`]) is a requirement, combined
6//!   from the manifest, the workspace config and `--containment` by taking the strongest (see
7//!   [`murmur_artifact::effective_containment_floor`]).
8//! * The **achieved class** starts from [`EnforcementTier`], which is host-probed at launch time.
9//!   [`achieved_class_for_tier`] takes nothing else — not the manifest, not the grant set — so no
10//!   declaration can ever talk a host into reporting a class it cannot back. Exactly one manifest
11//!   property may then talk a *capsule* down from that ceiling, and only downwards:
12//!   `capabilities.filesystem.workdir_exec: true` caps the result at
13//!   [`ContainmentClass::Advisory`] (see [`achieved_containment_class`]), because a capsule whose
14//!   own workdir is executable has given up the claim `scoped` makes. The cap is applied in a
15//!   separate wrapper rather than inside [`achieved_class_for_tier`] precisely so the "no
16//!   declaration raises a class" property stays a property of one small, pure function.
17//!
18//! [`ContainmentClass::Sealed`] is reachable exactly when [`EnforcementTier::KernelSealed`] is:
19//! a Linux host with a usable Landlock ABI, AppArmor out of the way of unprivileged user
20//! namespaces, and a namespace probe that really created one. See [`crate::sealed`] for the
21//! mechanism and for the [`SealedBlocker`] taxonomy that turns "not sealed here" into a specific
22//! command an operator can run.
23//!
24//! The shortfall reason for `sealed` is therefore *mechanism-specific*, not a fixed string: a
25//! host missing the AppArmor profile and a container missing `CAP_SYS_ADMIN` both fail to reach
26//! `sealed`, but they are fixed in completely different places, and a refusal that cannot tell
27//! them apart is a refusal an operator cannot act on.
28
29use murmur_artifact::ContainmentClass;
30use serde::Serialize;
31
32use crate::{
33    errors::RuntimeError,
34    sandbox::{detect_enforcement_tier, EnforcementTier},
35    sealed::SealedBlocker,
36    types::CapabilityPolicy,
37};
38
39pub use crate::sandbox::detect_sealed_blocker;
40
41/// The class a host in `tier` can actually back, and nothing stronger.
42///
43/// Pure — the host probe lives in [`detect_enforcement_tier`], mirroring the
44/// `tier_from_probe`/`detect_enforcement_tier` split in `sandbox.rs` so this mapping stays
45/// unit-testable on any OS.
46pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
47    match tier {
48        // A private mount namespace pivoted onto a composed root: paths outside it are absent,
49        // not merely denied — exactly the mechanism `sealed` names. Landlock and seccomp still
50        // install inside it, so this arm is strictly stronger than `KernelFull`'s, never an
51        // alternative to it.
52        EnforcementTier::KernelSealed => ContainmentClass::Sealed,
53        // Landlock mediates the filesystem and seccomp mediates exec/network: exactly the
54        // mechanism `scoped` names.
55        EnforcementTier::KernelFull => ContainmentClass::Scoped,
56        // Seccomp alone leaves the filesystem scope convention-only, and `EnvironmentOnly` has
57        // no kernel primitive at all. Neither can back a filesystem claim, so neither clears
58        // `scoped`.
59        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
60            ContainmentClass::Advisory
61        }
62    }
63}
64
65/// The class a session actually achieves: the host's ceiling from [`achieved_class_for_tier`],
66/// capped by what the capsule's own `capabilities.filesystem.workdir_exec` leaves standing.
67///
68/// This is the one place a *manifest* property lowers an achieved class, and it does so by capping
69/// rather than by deciding: [`achieved_class_for_tier`] above stays pure and tier-only, so no
70/// declaration can talk a host up, and this wrapper can only ever talk a capsule *down*.
71///
72/// Why `workdir_exec` caps at [`ContainmentClass::Advisory`]: `scoped` (and `sealed` above it) both
73/// claim that what runs inside the capsule is bounded by what the operator declared. With the
74/// workdir's `Execute` right granted, a binary the agent compiles, downloads or renames inside its
75/// own workdir runs whatever `capabilities.shell.allow` says — the allowlist stops being an
76/// enforceable property. A host that could back `scoped` still cannot back a claim the capsule
77/// itself has given up, so the honest report is `advisory`, on every tier.
78pub(crate) fn achieved_containment_class(
79    tier: EnforcementTier,
80    workdir_exec: bool,
81) -> ContainmentClass {
82    let host_ceiling = achieved_class_for_tier(tier);
83    if workdir_exec {
84        host_ceiling.min(ContainmentClass::Advisory)
85    } else {
86        host_ceiling
87    }
88}
89
90/// Probes this host and reports the strongest class it can actually provide.
91///
92/// Host-only, and deliberately still takes no capsule input: it answers "what can this machine
93/// back?", which is what `mur doctor` and the escape-conformance harness ask. A *session* asks the
94/// narrower question and must use [`achieved_containment_class`] with its own probed tier instead
95/// — see `runtime::stage_session`, which probes the tier once and passes it to both that call and
96/// [`scope_report_for_tier`].
97pub fn detect_achieved_containment() -> ContainmentClass {
98    achieved_class_for_tier(detect_enforcement_tier())
99}
100
101/// Why `achieved` falls short of `declared`, naming the missing *mechanism* rather than the
102/// missing class. `None` when the floor is met.
103///
104/// Pure and host-independent: the caller supplies both classes *and* — for the `sealed` arm — the
105/// already-probed [`SealedBlocker`], so this function still probes nothing and stays testable
106/// without a kernel. `sealed_blocker` is `None` when the caller has not probed (or cannot: a
107/// non-Linux host), in which case the arm falls back to naming the mechanism generically.
108///
109/// `workdir_exec` is the capsule's own `capabilities.filesystem.workdir_exec`. It is checked before
110/// every host arm below, because it is the one shortfall an operator cannot fix by changing hosts:
111/// no kernel backs `scoped` for a capsule that has declared its workdir executable. Naming a
112/// missing Landlock ABI to someone whose real problem is a line in their own manifest sends them to
113/// the wrong machine.
114pub fn containment_shortfall_reason(
115    declared: ContainmentClass,
116    achieved: ContainmentClass,
117    sealed_blocker: Option<SealedBlocker>,
118    workdir_exec: bool,
119) -> Option<String> {
120    if achieved >= declared {
121        return None;
122    }
123
124    if workdir_exec && declared > ContainmentClass::Advisory {
125        return Some(WORKDIR_EXEC_SHORTFALL.to_string());
126    }
127
128    let reason = match declared {
129        // Unreachable in practice — `Advisory` is the weakest class, so nothing is below it —
130        // but stated rather than `unreachable!()` so a future variant reordering cannot panic
131        // a launch path.
132        ContainmentClass::Advisory => {
133            "advisory is the weakest containment class and is satisfied by every host".to_string()
134        }
135        ContainmentClass::Scoped => {
136            "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
137             ABI); this host provides no kernel filesystem mediation, so paths outside the \
138             workdir are constrained by convention only"
139                .to_string()
140        }
141        // The mechanism exists now, so the reason names *which part of it* this host is missing
142        // and how to fix that part — never the old blanket "no host can provide it today".
143        ContainmentClass::Sealed => match sealed_blocker {
144            Some(blocker) => blocker.reason(),
145            None => "sealed requires a private mount namespace pivoted onto a composed root \
146                     (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
147                     provide"
148                .to_string(),
149        },
150    };
151
152    Some(reason)
153}
154
155/// The reason a capsule that declared `capabilities.filesystem.workdir_exec: true` falls short of
156/// any floor above `advisory`. Fixed text rather than a `match` arm per declared class: the
157/// mechanism, the consequence and the remedy are identical for `scoped` and `sealed`, because both
158/// rest on the claim this declaration gives up.
159const WORKDIR_EXEC_SHORTFALL: &str =
160    "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
161     workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
162     capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
163     No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
164     then enforced by the kernel on the resolved path) or lower the declared containment floor to \
165     advisory";
166
167/// The refusal gate: `Ok(())` when the host's `achieved` class meets or exceeds the `declared`
168/// floor, [`RuntimeError::ContainmentFloorUnmet`] otherwise.
169///
170/// Pure — takes every input as an argument and probes nothing, so the whole decision is
171/// testable without a kernel.
172pub fn check_containment_floor(
173    declared: ContainmentClass,
174    achieved: ContainmentClass,
175    sealed_blocker: Option<SealedBlocker>,
176    workdir_exec: bool,
177) -> Result<(), RuntimeError> {
178    match containment_shortfall_reason(declared, achieved, sealed_blocker, workdir_exec) {
179        None => Ok(()),
180        Some(reason) => Err(RuntimeError::ContainmentFloorUnmet {
181            declared,
182            achieved,
183            reason,
184        }),
185    }
186}
187
188/// Read-only answer to "what would this capsule actually be allowed to do on this host?".
189///
190/// Built from the already-parsed [`CapabilityPolicy`] plus one host tier probe. Nothing here
191/// stages artifacts, touches a registry, creates a workdir or resolves DNS — it is a diagnostic,
192/// so it reports even when [`Self::floor_met`] is `false`.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct ScopeReport {
195    /// Strongest class any source asked for (manifest / workspace config / `--containment`).
196    pub declared_containment: ContainmentClass,
197    /// Strongest class this host can actually back, derived from the tier probe alone.
198    pub achieved_containment: ContainmentClass,
199    /// `achieved_containment >= declared_containment`. `false` means `mur run` would refuse.
200    pub floor_met: bool,
201    /// The missing mechanism when `floor_met` is `false`; absent otherwise.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub shortfall_reason: Option<String>,
204    /// The probed host tier, as a stable wire name.
205    pub enforcement_tier: &'static str,
206    /// `capabilities.filesystem.scope`, verbatim.
207    pub filesystem_scope: Option<String>,
208    /// `capabilities.filesystem.workdir_exec`. Always present (never skipped when `false`), so a
209    /// consumer can tell "declared false" from "this runtime predates the key" by the field's
210    /// presence rather than by its value.
211    ///
212    /// `true` is the only manifest declaration that lowers [`Self::achieved_containment`], which is
213    /// why it is reported next to the grants rather than buried: reading `achieved: advisory` on a
214    /// Landlock-capable host makes sense only alongside it.
215    pub workdir_exec: bool,
216    /// `capabilities.network.allow`, verbatim — declared destinations, not resolved IPs.
217    pub network_allow: Vec<String>,
218    /// `capabilities.network.unix_sockets`.
219    pub unix_sockets: bool,
220    /// `capabilities.shell.allow`, verbatim.
221    pub shell_allow: Vec<String>,
222    /// `capabilities.spawn.allow`, verbatim.
223    pub spawn_allow: Vec<String>,
224    /// `capabilities.env.allow`, verbatim.
225    pub env_allow: Vec<String>,
226    /// Every host directory a `capabilities.shell.interpreter_runtime` grant opens, rendered as
227    /// `<binary>: <dir>[ (list_dir)]`. These are the paths outside the workdir that stay
228    /// reachable even at `scoped`.
229    pub interpreter_runtime_grants: Vec<String>,
230    /// Every `capabilities.shell.staged_runtime` grant, rendered as
231    /// `<binary>: <source_path> (pin: <pin>)`. These are host runtime trees a `sealed` capsule
232    /// asks to have bind-mounted read-only into its composed root.
233    ///
234    /// Reported whatever the floor and whatever this host can back — a capsule that declares a
235    /// staged runtime and cannot run here is exactly the case an operator is inspecting when they
236    /// reach for `--explain-scope`, so hiding the grant behind a met floor would blank out the
237    /// diagnostic in the one case it is for.
238    pub staged_runtime_grants: Vec<String>,
239}
240
241impl ScopeReport {
242    /// Human-readable rendering used by `mur run --explain-scope` without `--json`.
243    pub fn render(&self) -> String {
244        let mut out = String::new();
245        out.push_str("Containment\n");
246        out.push_str(&format!("  declared:  {}\n", self.declared_containment));
247        out.push_str(&format!("  achieved:  {}\n", self.achieved_containment));
248        out.push_str(&format!(
249            "  floor met: {}\n",
250            if self.floor_met { "yes" } else { "no" }
251        ));
252        if let Some(reason) = &self.shortfall_reason {
253            out.push_str(&format!("  reason:    {reason}\n"));
254        }
255        out.push_str(&format!("  mechanism: {}\n", self.enforcement_tier));
256
257        out.push_str("\nEffective grants\n");
258        out.push_str(&format!(
259            "  filesystem scope: {}\n",
260            self.filesystem_scope.as_deref().unwrap_or("<none>")
261        ));
262        out.push_str(&format!("  workdir exec:     {}\n", self.workdir_exec));
263        push_list(&mut out, "network allow", &self.network_allow);
264        out.push_str(&format!("  unix sockets:     {}\n", self.unix_sockets));
265        push_list(&mut out, "shell allow", &self.shell_allow);
266        push_list(&mut out, "spawn allow", &self.spawn_allow);
267        push_list(&mut out, "env allow", &self.env_allow);
268        push_list(
269            &mut out,
270            "interpreter runtime",
271            &self.interpreter_runtime_grants,
272        );
273        push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
274
275        if !self.floor_met {
276            out.push_str(
277                "\nThis is a report only — `mur run` without --explain-scope would refuse to \
278                 launch here.\n",
279            );
280        }
281        out
282    }
283}
284
285fn push_list(out: &mut String, label: &str, values: &[String]) {
286    if values.is_empty() {
287        out.push_str(&format!("  {label}: <none>\n"));
288        return;
289    }
290    out.push_str(&format!("  {label}:\n"));
291    for value in values {
292        out.push_str(&format!("    - {value}\n"));
293    }
294}
295
296/// Builds a [`ScopeReport`] for `policy` against this host, with `declared` as the already-
297/// combined floor. Probes the host tier once and reads nothing else.
298pub fn explain_scope(policy: &CapabilityPolicy, declared: ContainmentClass) -> ScopeReport {
299    scope_report_for_tier(
300        policy,
301        declared,
302        detect_enforcement_tier(),
303        detect_sealed_blocker(),
304    )
305}
306
307/// [`explain_scope`] with the tier and the sealed blocker injected — the seam every test uses so
308/// no test depends on the host it happens to run on.
309pub(crate) fn scope_report_for_tier(
310    policy: &CapabilityPolicy,
311    declared: ContainmentClass,
312    tier: EnforcementTier,
313    sealed_blocker: Option<SealedBlocker>,
314) -> ScopeReport {
315    let achieved = achieved_containment_class(tier, policy.workdir_exec_allowed);
316    let shortfall_reason = containment_shortfall_reason(
317        declared,
318        achieved,
319        sealed_blocker,
320        policy.workdir_exec_allowed,
321    );
322
323    ScopeReport {
324        declared_containment: declared,
325        achieved_containment: achieved,
326        floor_met: shortfall_reason.is_none(),
327        shortfall_reason,
328        enforcement_tier: enforcement_tier_name(tier),
329        filesystem_scope: policy.filesystem_scope.clone(),
330        workdir_exec: policy.workdir_exec_allowed,
331        network_allow: policy.network_allow.clone(),
332        unix_sockets: policy.unix_sockets_allowed,
333        shell_allow: policy.shell_allow.clone(),
334        spawn_allow: policy.spawn_allow.clone(),
335        env_allow: policy.env_allow.clone(),
336        interpreter_runtime_grants: policy
337            .shell_interpreter_runtime
338            .iter()
339            .flat_map(|grant| {
340                grant.dirs.iter().map(move |dir| {
341                    let listable = if dir.list_dir { " (list_dir)" } else { "" };
342                    format!("{}: {}{}", grant.binary, dir.path, listable)
343                })
344            })
345            .collect(),
346        staged_runtime_grants: policy
347            .shell_staged_runtime
348            .iter()
349            .map(|grant| {
350                format!(
351                    "{}: {} (pin: {})",
352                    grant.binary, grant.source_path, grant.pin
353                )
354            })
355            .collect(),
356    }
357}
358
359/// Stable wire name for a tier, so `--explain-scope --json` consumers see the mechanism rather
360/// than a `Debug` rendering that is free to change.
361fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
362    match tier {
363        EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
364        EnforcementTier::KernelFull => "landlock+seccomp",
365        EnforcementTier::KernelSeccompOnly => "seccomp-only",
366        EnforcementTier::EnvironmentOnly => "none",
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
374
375    /// Every tier, so a new variant cannot quietly escape a test that iterates "all of them".
376    const ALL_TIERS: &[EnforcementTier] = &[
377        EnforcementTier::KernelSealed,
378        EnforcementTier::KernelFull,
379        EnforcementTier::KernelSeccompOnly,
380        EnforcementTier::EnvironmentOnly,
381    ];
382
383    #[test]
384    fn kernel_full_achieves_scoped() {
385        assert_eq!(
386            achieved_class_for_tier(EnforcementTier::KernelFull),
387            ContainmentClass::Scoped
388        );
389    }
390
391    /// The sealed tier, and only the sealed tier, reports `sealed`.
392    #[test]
393    fn only_the_sealed_tier_achieves_sealed() {
394        assert_eq!(
395            achieved_class_for_tier(EnforcementTier::KernelSealed),
396            ContainmentClass::Sealed
397        );
398        for tier in ALL_TIERS
399            .iter()
400            .filter(|t| **t != EnforcementTier::KernelSealed)
401        {
402            assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
403        }
404    }
405
406    #[test]
407    fn tiers_without_landlock_achieve_only_advisory() {
408        assert_eq!(
409            achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
410            ContainmentClass::Advisory
411        );
412        assert_eq!(
413            achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
414            ContainmentClass::Advisory
415        );
416    }
417
418    /// The whole tier × `workdir_exec` truth table, with both inputs supplied by the test and no
419    /// kernel consulted — the seam `achieved_class_for_tier`/`achieved_containment_class` exists
420    /// for. This is emphatically *not* a test of the security property (that a workdir binary
421    /// cannot execute); it tests only the class arithmetic, which is all that can honestly be
422    /// tested without a Landlock-capable host.
423    #[test]
424    fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
425        // Without the declaration: the host ceiling, unchanged.
426        assert_eq!(
427            achieved_containment_class(EnforcementTier::KernelSealed, false),
428            ContainmentClass::Sealed
429        );
430        assert_eq!(
431            achieved_containment_class(EnforcementTier::KernelFull, false),
432            ContainmentClass::Scoped
433        );
434        assert_eq!(
435            achieved_containment_class(EnforcementTier::KernelSeccompOnly, false),
436            ContainmentClass::Advisory
437        );
438        assert_eq!(
439            achieved_containment_class(EnforcementTier::EnvironmentOnly, false),
440            ContainmentClass::Advisory
441        );
442
443        // With it: advisory, whatever the host could otherwise back.
444        for tier in ALL_TIERS {
445            assert_eq!(
446                achieved_containment_class(*tier, true),
447                ContainmentClass::Advisory,
448                "workdir_exec must cap tier {tier:?} at advisory"
449            );
450        }
451    }
452
453    /// The cap goes in the wrapper, never in the tier-only function — a regression here would mean
454    /// a manifest key had leaked into the one mapping that must stay host-only.
455    #[test]
456    fn the_tier_only_mapping_is_unaffected_by_the_cap() {
457        for tier in ALL_TIERS {
458            assert_eq!(
459                achieved_class_for_tier(*tier),
460                achieved_containment_class(*tier, false),
461                "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
462            );
463        }
464    }
465
466    /// The refusal an operator actually hits: `workdir_exec: true` plus
467    /// `capabilities.containment: scoped`, on a host that could otherwise back `scoped`. It must
468    /// refuse, and the reason must send them to their manifest rather than to another machine.
469    #[test]
470    fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
471        let achieved = achieved_containment_class(EnforcementTier::KernelFull, true);
472        assert_eq!(achieved, ContainmentClass::Advisory);
473
474        let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true)
475            .expect_err("workdir_exec + scoped must refuse");
476        match error {
477            RuntimeError::ContainmentFloorUnmet {
478                declared,
479                achieved,
480                reason,
481            } => {
482                assert_eq!(declared, ContainmentClass::Scoped);
483                assert_eq!(achieved, ContainmentClass::Advisory);
484                assert!(
485                    reason.contains("workdir_exec"),
486                    "the reason must name the manifest key, got: {reason}"
487                );
488                assert!(
489                    !reason.contains("this host provides no kernel filesystem mediation"),
490                    "the reason must not blame the host for a manifest declaration: {reason}"
491                );
492            }
493            other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
494        }
495    }
496
497    /// `sealed` gets the same manifest-side reason rather than a `SealedBlocker` one: a probed
498    /// blocker is real but is not what is stopping *this* capsule, and reporting it would send the
499    /// operator to install an AppArmor profile that changes nothing.
500    #[test]
501    fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
502        let reason = containment_shortfall_reason(
503            ContainmentClass::Sealed,
504            ContainmentClass::Advisory,
505            Some(SealedBlocker::AppArmorProfileMissing),
506            true,
507        )
508        .expect("refusal");
509        assert!(reason.contains("workdir_exec"));
510        assert!(!reason.contains("apparmor_parser"));
511    }
512
513    /// The declaration is not itself a refusal: a capsule that declares `workdir_exec` and asks
514    /// for nothing above `advisory` launches normally on every host.
515    #[test]
516    fn workdir_exec_alone_refuses_nothing() {
517        for tier in ALL_TIERS {
518            let achieved = achieved_containment_class(*tier, true);
519            assert!(
520                check_containment_floor(ContainmentClass::Advisory, achieved, None, true).is_ok(),
521                "workdir_exec at an advisory floor must launch on tier {tier:?}"
522            );
523        }
524    }
525
526    #[test]
527    fn advisory_floor_is_met_on_every_tier() {
528        for tier in ALL_TIERS {
529            let achieved = achieved_class_for_tier(*tier);
530            assert!(
531                check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok()
532            );
533        }
534    }
535
536    #[test]
537    fn scoped_floor_is_met_on_every_landlock_capable_tier() {
538        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
539            let achieved = achieved_class_for_tier(tier);
540            assert!(
541                check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok()
542            );
543        }
544
545        for tier in [
546            EnforcementTier::KernelSeccompOnly,
547            EnforcementTier::EnvironmentOnly,
548        ] {
549            let achieved = achieved_class_for_tier(tier);
550            let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, false)
551                .expect_err("scoped must refuse without Landlock");
552            match error {
553                RuntimeError::ContainmentFloorUnmet {
554                    declared,
555                    achieved,
556                    reason,
557                } => {
558                    assert_eq!(declared, ContainmentClass::Scoped);
559                    assert_eq!(achieved, ContainmentClass::Advisory);
560                    assert!(
561                        reason.contains("Landlock"),
562                        "reason must name the missing mechanism, got: {reason}"
563                    );
564                }
565                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
566            }
567        }
568    }
569
570    /// The edge case the new tier makes possible: a capsule declaring the *weaker* class on a
571    /// sealed-capable host. The floor is met (a stronger host satisfies a weaker requirement),
572    /// and the applied mechanism is the declared one — asserted next door in
573    /// `sandbox::applied_tier`'s tests, since a careless `match` here could force sealed-only
574    /// behaviour onto a `scoped` declaration.
575    #[test]
576    fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
577        let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
578        assert_eq!(achieved, ContainmentClass::Sealed);
579        assert!(check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok());
580        assert!(check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok());
581        assert_eq!(
582            containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false),
583            None
584        );
585    }
586
587    #[test]
588    fn sealed_floor_is_met_only_on_the_sealed_tier() {
589        assert!(check_containment_floor(
590            ContainmentClass::Sealed,
591            achieved_class_for_tier(EnforcementTier::KernelSealed),
592            None,
593            false
594        )
595        .is_ok());
596
597        for tier in ALL_TIERS
598            .iter()
599            .filter(|t| **t != EnforcementTier::KernelSealed)
600        {
601            let achieved = achieved_class_for_tier(*tier);
602            let error = check_containment_floor(
603                ContainmentClass::Sealed,
604                achieved,
605                Some(SealedBlocker::NamespaceCreationDenied),
606                false,
607            )
608            .expect_err("sealed must refuse on a host that cannot back it");
609            match error {
610                RuntimeError::ContainmentFloorUnmet {
611                    declared, reason, ..
612                } => {
613                    assert_eq!(declared, ContainmentClass::Sealed);
614                    assert!(
615                        reason.contains("--cap-add SYS_ADMIN"),
616                        "reason must name the blocking mechanism's remediation, got: {reason}"
617                    );
618                }
619                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
620            }
621        }
622    }
623
624    /// The refusal text is mechanism-specific, not one fixed sentence: the same
625    /// declared/achieved pair produces different, individually actionable reasons.
626    #[test]
627    fn the_sealed_refusal_names_the_specific_blocker() {
628        let achieved = ContainmentClass::Scoped;
629
630        let apparmor = containment_shortfall_reason(
631            ContainmentClass::Sealed,
632            achieved,
633            Some(SealedBlocker::AppArmorProfileMissing),
634            false,
635        )
636        .expect("refusal");
637        assert!(apparmor.contains("mur-sealed"));
638        assert!(apparmor.contains("apparmor_parser -r"));
639        assert!(!apparmor.contains("--cap-add"));
640
641        let container = containment_shortfall_reason(
642            ContainmentClass::Sealed,
643            achieved,
644            Some(SealedBlocker::NamespaceCreationDenied),
645            false,
646        )
647        .expect("refusal");
648        assert!(container.contains("--cap-add SYS_ADMIN"));
649        assert!(container.contains("outside the container"));
650        assert!(!container.contains("apparmor_parser"));
651
652        // Never the pre-mechanism blanket text again, under any blocker.
653        for blocker in [
654            SealedBlocker::NotLinux,
655            SealedBlocker::AppArmorProfileMissing,
656            SealedBlocker::NamespaceCreationDenied,
657            SealedBlocker::MountDenied,
658            SealedBlocker::KernelUnsupported,
659            SealedBlocker::LandlockUnavailable,
660        ] {
661            let reason = containment_shortfall_reason(
662                ContainmentClass::Sealed,
663                achieved,
664                Some(blocker),
665                false,
666            )
667            .expect("refusal");
668            assert!(
669                !reason.contains("no host can provide it today"),
670                "got: {reason}"
671            );
672        }
673    }
674
675    /// A caller with no probe result still gets a reason naming the mechanism rather than an
676    /// empty or misleading one.
677    #[test]
678    fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
679        let reason = containment_shortfall_reason(
680            ContainmentClass::Sealed,
681            ContainmentClass::Advisory,
682            None,
683            false,
684        )
685        .expect("refusal");
686        assert!(reason.contains("pivot_root"));
687        assert!(reason.contains("CLONE_NEWNS"));
688    }
689
690    #[test]
691    fn shortfall_reason_is_absent_when_the_floor_is_met() {
692        assert_eq!(
693            containment_shortfall_reason(
694                ContainmentClass::Advisory,
695                ContainmentClass::Scoped,
696                None,
697                false
698            ),
699            None
700        );
701        assert_eq!(
702            containment_shortfall_reason(
703                ContainmentClass::Scoped,
704                ContainmentClass::Scoped,
705                None,
706                false
707            ),
708            None
709        );
710        assert_eq!(
711            containment_shortfall_reason(
712                ContainmentClass::Sealed,
713                ContainmentClass::Sealed,
714                Some(SealedBlocker::NamespaceCreationDenied),
715                false
716            ),
717            None,
718            "a met floor must ignore a stale blocker rather than manufacture a refusal"
719        );
720    }
721
722    fn sample_policy() -> CapabilityPolicy {
723        CapabilityPolicy {
724            network_allow: vec!["https://api.example.com".to_string()],
725            unix_sockets_allowed: false,
726            filesystem_scope: Some("workdir".to_string()),
727            shell_allow: vec!["python3".to_string()],
728            spawn_allow: vec!["helper".to_string()],
729            env_allow: vec!["TZ".to_string()],
730            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
731                binary: "python3".to_string(),
732                dirs: vec![InterpreterRuntimeDir {
733                    path: "/usr/lib/python3.11".to_string(),
734                    list_dir: true,
735                }],
736            }],
737            ..CapabilityPolicy::default()
738        }
739    }
740
741    #[test]
742    fn scope_report_mirrors_the_policy_and_the_probed_tier() {
743        let report = scope_report_for_tier(
744            &sample_policy(),
745            ContainmentClass::Scoped,
746            EnforcementTier::KernelFull,
747            None,
748        );
749
750        assert_eq!(report.declared_containment, ContainmentClass::Scoped);
751        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
752        assert!(report.floor_met);
753        assert_eq!(report.shortfall_reason, None);
754        assert_eq!(report.enforcement_tier, "landlock+seccomp");
755        assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
756        assert_eq!(report.network_allow, vec!["https://api.example.com"]);
757        assert!(!report.unix_sockets);
758        assert_eq!(report.shell_allow, vec!["python3"]);
759        assert_eq!(report.spawn_allow, vec!["helper"]);
760        assert_eq!(report.env_allow, vec!["TZ"]);
761        assert!(!report.workdir_exec);
762        assert_eq!(
763            report.interpreter_runtime_grants,
764            vec!["python3: /usr/lib/python3.11 (list_dir)"]
765        );
766    }
767
768    /// `--explain-scope` is where an operator finds out *why* a Landlock-capable host is reporting
769    /// `advisory`, so the declaration and the class it produced have to appear together — in the
770    /// struct, in the JSON, and in the rendered text.
771    #[test]
772    fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
773        let policy = CapabilityPolicy {
774            shell_allow: vec!["gcc".to_string()],
775            workdir_exec_allowed: true,
776            ..CapabilityPolicy::default()
777        };
778
779        let report = scope_report_for_tier(
780            &policy,
781            ContainmentClass::Scoped,
782            EnforcementTier::KernelFull,
783            None,
784        );
785
786        assert!(report.workdir_exec);
787        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
788        assert!(!report.floor_met);
789        // The host is fully capable; the mechanism line must keep saying so, so the report does
790        // not read as a broken host.
791        assert_eq!(report.enforcement_tier, "landlock+seccomp");
792        assert!(report
793            .shortfall_reason
794            .as_deref()
795            .expect("refusal")
796            .contains("workdir_exec"));
797
798        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
799        assert_eq!(value["workdir_exec"], true);
800
801        let rendered = report.render();
802        assert!(rendered.contains("workdir exec:     true"));
803        assert!(rendered.contains("achieved:  advisory"));
804    }
805
806    /// The field is always written, including `false`, so a consumer can distinguish "this capsule
807    /// declared nothing" from "this runtime does not know the key".
808    #[test]
809    fn scope_report_json_always_carries_workdir_exec() {
810        let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
811            &sample_policy(),
812            ContainmentClass::Scoped,
813            EnforcementTier::KernelFull,
814            None,
815        ))
816        .unwrap();
817        assert_eq!(value["workdir_exec"], false);
818    }
819
820    /// `--explain-scope` is a diagnostic, not a launch: a capsule declaring `staged_runtime` on a
821    /// host that cannot back `sealed` is exactly the case an operator inspects with it, so the
822    /// grant must be reported on every tier — including the ones where `mur run` would refuse.
823    #[test]
824    fn staged_runtime_grants_are_reported_on_every_tier() {
825        let policy = CapabilityPolicy {
826            shell_allow: vec!["python3".to_string()],
827            shell_staged_runtime: vec![StagedRuntimeGrant {
828                binary: "python3".to_string(),
829                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
830                pin: "conda-4.10.3/python-3.9.19".to_string(),
831            }],
832            ..CapabilityPolicy::default()
833        };
834
835        for tier in ALL_TIERS {
836            let report = scope_report_for_tier(
837                &policy,
838                ContainmentClass::Sealed,
839                *tier,
840                Some(SealedBlocker::NamespaceCreationDenied),
841            );
842            assert_eq!(
843                report.staged_runtime_grants,
844                vec![
845                    "python3: /opt/testbed/conda/envs/django__django \
846                     (pin: conda-4.10.3/python-3.9.19)"
847                ],
848                "tier {tier:?} must still report the declared staged runtime"
849            );
850            assert!(
851                report.render().contains("staged runtime"),
852                "the rendered report must carry a staged runtime section on tier {tier:?}"
853            );
854        }
855    }
856
857    /// The absent case: no `staged_runtime` in the manifest leaves the section empty rather than
858    /// inventing one, and `render` still names it as `<none>` like every other empty list.
859    #[test]
860    fn absent_staged_runtime_reports_nothing() {
861        let report = scope_report_for_tier(
862            &sample_policy(),
863            ContainmentClass::Scoped,
864            EnforcementTier::KernelFull,
865            None,
866        );
867        assert!(report.staged_runtime_grants.is_empty());
868        assert!(report.render().contains("staged runtime: <none>"));
869    }
870
871    /// A capsule that declares `scoped` *and* opens host paths through `interpreter_runtime`
872    /// still achieves only `scoped` — the grant set is reported, never consulted to upgrade
873    /// the achieved class.
874    #[test]
875    fn grant_set_never_raises_the_achieved_class() {
876        let report = scope_report_for_tier(
877            &sample_policy(),
878            ContainmentClass::Sealed,
879            EnforcementTier::KernelFull,
880            Some(SealedBlocker::NamespaceCreationDenied),
881        );
882        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
883        assert!(!report.floor_met);
884    }
885
886    #[test]
887    fn scope_report_still_reports_when_the_floor_is_unmet() {
888        let report = scope_report_for_tier(
889            &sample_policy(),
890            ContainmentClass::Sealed,
891            EnforcementTier::EnvironmentOnly,
892            Some(SealedBlocker::NotLinux),
893        );
894
895        assert_eq!(report.declared_containment, ContainmentClass::Sealed);
896        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
897        assert!(!report.floor_met);
898        assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
899        assert_eq!(report.enforcement_tier, "none");
900    }
901
902    #[test]
903    fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
904        let report = scope_report_for_tier(
905            &sample_policy(),
906            ContainmentClass::Sealed,
907            EnforcementTier::KernelSealed,
908            None,
909        );
910        assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
911        assert!(report.floor_met);
912        assert_eq!(
913            report.enforcement_tier,
914            "mountns+pivot_root+landlock+seccomp"
915        );
916    }
917
918    #[test]
919    fn scope_report_json_uses_wire_names() {
920        let report = scope_report_for_tier(
921            &CapabilityPolicy::default(),
922            ContainmentClass::Advisory,
923            EnforcementTier::KernelSeccompOnly,
924            None,
925        );
926        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
927
928        assert_eq!(value["declared_containment"], "advisory");
929        assert_eq!(value["achieved_containment"], "advisory");
930        assert_eq!(value["floor_met"], true);
931        assert_eq!(value["enforcement_tier"], "seccomp-only");
932        // Absent rather than null when the floor is met.
933        assert!(value.get("shortfall_reason").is_none());
934    }
935
936    #[test]
937    fn rendered_report_names_both_classes_and_the_refusal() {
938        let rendered = scope_report_for_tier(
939            &sample_policy(),
940            ContainmentClass::Sealed,
941            EnforcementTier::KernelFull,
942            Some(SealedBlocker::AppArmorProfileMissing),
943        )
944        .render();
945
946        assert!(rendered.contains("declared:  sealed"));
947        assert!(rendered.contains("achieved:  scoped"));
948        assert!(rendered.contains("floor met: no"));
949        assert!(rendered.contains("mur-sealed"));
950        assert!(rendered.contains("would refuse to launch"));
951    }
952}