Skip to main content

capsule_runtime/
containment.rs

1//! Containment classes: what an operator *declares* versus what a session 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** is the class of the tier the session's commands run on. That tier,
9//!   and the class read off it, are one [`ContainmentDecision`], made once per session from one
10//!   [`HostProbe`] reading and the declared floor. The host's tier is a ceiling the declaration
11//!   can only lower: a capsule declaring less than `sealed` on a sealed-capable host runs on
12//!   [`EnforcementTier::KernelFull`] and achieves `scoped`, and no declaration raises a tier
13//!   above what the host backs. [`achieved_class_for_tier`] takes nothing but a tier, so no
14//!   declaration can talk a tier into reporting a class it does not back. Two capsule-side facts
15//!   then cap the class at [`ContainmentClass::Advisory`] without changing the tier:
16//!   `capabilities.filesystem.workdir_exec: true` and a runtime directory inside the workdir (see
17//!   [`achieved_containment_class`]).
18//!
19//! What the host could back for a capsule declaring `sealed` is reported separately, as
20//! [`ContainmentDecision::host_containment`], and decides nothing.
21//!
22//! [`ContainmentClass::Sealed`] is reachable exactly when [`EnforcementTier::KernelSealed`] is
23//! applied: a Linux host with a usable Landlock ABI and a namespace probe that really built the
24//! composed root inside the capsule's network namespace, and a capsule declaring `sealed`. See
25//! [`crate::sealed`] for the mechanism and for the [`crate::sealed::SealedBlocker`] taxonomy that
26//! turns "not sealed here" into a specific command an operator can run.
27//!
28//! The shortfall reason for `sealed` is therefore *mechanism-specific*, not a fixed string: a
29//! host missing the AppArmor profile and a container missing `CAP_SYS_ADMIN` both fail to reach
30//! `sealed`, but they are fixed in completely different places, and a refusal that cannot tell
31//! them apart is a refusal an operator cannot act on.
32
33use murmur_artifact::{ContainmentClass, ExportMode, Exports, FileExport, PeerFilesExport};
34use serde::Serialize;
35
36use crate::{
37    cgroup::{CgroupScopeReport, CgroupScopeStatus, IoMaxReport, IoMaxStatus},
38    errors::RuntimeError,
39    runtime_dir::RuntimeDirPlacement,
40    sandbox::{EnforcementTier, HostProbe},
41    sealed::{SealedShortfall, UsernsGrant},
42    types::CapabilityPolicy,
43};
44
45pub use crate::sandbox::{detect_sealed_shortfall, detect_userns_grant};
46
47/// The class a session whose commands run on `tier` achieves, before any capsule-side cap.
48///
49/// Pure and tier-only, so this mapping stays unit-testable on any OS and no declaration can make a
50/// tier report a class it does not back.
51pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
52    match tier {
53        // A private mount namespace pivoted onto a composed root: paths outside it are absent,
54        // not merely denied — exactly the mechanism `sealed` names. Landlock and seccomp still
55        // install inside it, so this arm is strictly stronger than `KernelFull`'s, never an
56        // alternative to it.
57        EnforcementTier::KernelSealed => ContainmentClass::Sealed,
58        // Landlock mediates the filesystem and seccomp mediates exec/network: exactly the
59        // mechanism `scoped` names.
60        EnforcementTier::KernelFull => ContainmentClass::Scoped,
61        // Seccomp alone leaves the filesystem scope convention-only, and `EnvironmentOnly` has
62        // no kernel primitive at all. Neither can back a filesystem claim, so neither clears
63        // `scoped`.
64        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
65            ContainmentClass::Advisory
66        }
67        // The operator's own stance: nothing is installed, and the class says so.
68        EnforcementTier::Unconfined => ContainmentClass::Unconfined,
69    }
70}
71
72/// The class a session achieves: the class of the tier its commands run on, from
73/// [`achieved_class_for_tier`], capped by what the capsule's own
74/// `capabilities.filesystem.workdir_exec` leaves standing and by where its runtime directory lies.
75///
76/// Both caps lower the class without changing the tier: the commands still run under the same
77/// mechanism, which no longer backs the claim the higher class makes.
78///
79/// Why `workdir_exec` caps at [`ContainmentClass::Advisory`]: `scoped` (and `sealed` above it) both
80/// claim that what runs inside the capsule is bounded by what the operator declared. With the
81/// workdir's `Execute` right granted, a binary the agent compiles, downloads or renames inside its
82/// own workdir runs whatever `capabilities.shell.allow` says — the allowlist stops being an
83/// enforceable property. A tier that backs `scoped` still cannot back a claim the capsule itself
84/// has given up, so the honest report is `advisory`, on every tier.
85///
86/// Why `runtime_dir_inside_workdir` caps at [`ContainmentClass::Advisory`] too: with
87/// `~/.murmur/sessions` inside the workdir the capsule's commands write, they can rewrite the
88/// task, `MURMUR.md`, a submitted plan or an installed tool's manifest, which every class above
89/// `advisory` claims they cannot.
90fn achieved_containment_class(
91    tier: EnforcementTier,
92    workdir_exec: bool,
93    runtime_dir_inside_workdir: bool,
94) -> ContainmentClass {
95    let class = achieved_class_for_tier(tier);
96    if workdir_exec || runtime_dir_inside_workdir {
97        class.min(ContainmentClass::Advisory)
98    } else {
99        class
100    }
101}
102
103/// A session's containment, decided once: the tier its commands run on and the class that tier
104/// achieves for this capsule.
105///
106/// Every reader of a session's class or tier takes it from here — the floor refusal, the
107/// [`ScopeReport`] (and so `--explain-scope` and `session_start`), the `/tmp` mapping, the
108/// `MURMUR.md` exec facts, the tier `ShellEnforcement` installs, the resource plane's and peer
109/// handoff's symlink rule, `mur doctor` and the escape-conformance harness — so no report can name
110/// a class or tier the session's commands do not run under.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ContainmentDecision {
113    declared: ContainmentClass,
114    host_tier: EnforcementTier,
115    applied_tier: EnforcementTier,
116    achieved: ContainmentClass,
117}
118
119impl ContainmentDecision {
120    /// Decides from one host tier reading, the combined declared floor, the capsule's
121    /// `capabilities.filesystem.workdir_exec` and whether its runtime directory lies inside the
122    /// workdir. The last two cap the class and never change the tier.
123    pub(crate) fn decide(
124        host_tier: EnforcementTier,
125        declared: ContainmentClass,
126        workdir_exec: bool,
127        runtime_dir_inside_workdir: bool,
128    ) -> Self {
129        let applied_tier = crate::sandbox::applied_tier(host_tier, declared);
130        Self {
131            declared,
132            host_tier,
133            applied_tier,
134            achieved: achieved_containment_class(
135                applied_tier,
136                workdir_exec,
137                runtime_dir_inside_workdir,
138            ),
139        }
140    }
141
142    /// The combined declared floor the decision was made for.
143    pub fn declared(&self) -> ContainmentClass {
144        self.declared
145    }
146
147    /// The class this session's commands run under.
148    pub fn achieved(&self) -> ContainmentClass {
149        self.achieved
150    }
151
152    /// The class the host could back for a capsule declaring `sealed`, with no capsule-side cap.
153    /// Reported only; nothing decides from it.
154    pub fn host_containment(&self) -> ContainmentClass {
155        achieved_class_for_tier(self.host_tier)
156    }
157
158    /// The wire name of the tier this session installs, as [`ScopeReport::enforcement_tier`]
159    /// spells it.
160    pub fn enforcement_tier(&self) -> &'static str {
161        enforcement_tier_name(self.applied_tier)
162    }
163
164    /// The tier this session installs. `pub(crate)` because [`EnforcementTier`] is.
165    pub(crate) fn applied_tier(&self) -> EnforcementTier {
166        self.applied_tier
167    }
168}
169
170/// Probes this host once and decides for a capsule declaring `declared`, with neither capsule-side
171/// cap, returning the probe's sealed shortfall beside it.
172///
173/// What `mur doctor`'s callers and the escape-conformance harness ask: the class and tier a
174/// session declaring `declared` would run under here, and why `sealed` is out of reach when it is.
175/// A *session* takes its own [`HostProbe`] in `runtime::stage_session` and decides with its real
176/// `workdir_exec` and runtime-directory placement instead.
177pub fn detect_containment_decision(
178    declared: ContainmentClass,
179) -> (ContainmentDecision, Option<SealedShortfall>) {
180    let probe = HostProbe::probe();
181    (
182        ContainmentDecision::decide(probe.tier(), declared, false, false),
183        probe.sealed_shortfall(),
184    )
185}
186
187/// Why `achieved` falls short of `declared`, naming the missing *mechanism* rather than the
188/// missing class. `None` when the floor is met.
189///
190/// Pure and host-independent: the caller supplies both classes *and* — for the `sealed` arm — the
191/// already-probed [`SealedShortfall`], so this function still probes nothing and stays testable
192/// without a kernel. `sealed_shortfall` is `None` when the caller has not probed, in which case
193/// the arm falls back to naming the mechanism generically.
194///
195/// `workdir_exec` is the capsule's own `capabilities.filesystem.workdir_exec`. It is checked before
196/// every host arm below, because it is the one shortfall an operator cannot fix by changing hosts:
197/// no kernel backs `scoped` for a capsule that has declared its workdir executable. Naming a
198/// missing Landlock ABI to someone whose real problem is a line in their own manifest sends them to
199/// the wrong machine.
200///
201/// `runtime_dir_inside` is the placement when the session's runtime directory lies inside the
202/// workdir, checked right after `workdir_exec` and for the same reason: the fix is a different
203/// `--workdir`, not a different host.
204pub fn containment_shortfall_reason(
205    declared: ContainmentClass,
206    achieved: ContainmentClass,
207    sealed_shortfall: Option<&SealedShortfall>,
208    workdir_exec: bool,
209    runtime_dir_inside: Option<&RuntimeDirPlacement>,
210) -> Option<String> {
211    containment_shortfall(
212        declared,
213        achieved,
214        sealed_shortfall,
215        workdir_exec,
216        runtime_dir_inside,
217    )
218    .map(|(reason, _)| reason)
219}
220
221/// Why a containment floor is unmet, which decides what an operator is told to change: a host
222/// that cannot back the class, or one of the two capsule-side causes no host can fix.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum ShortfallCause {
225    /// This host lacks the mechanism the declared class needs.
226    Host,
227    /// `capabilities.filesystem.workdir_exec: true` caps the class at `advisory`.
228    WorkdirExec,
229    /// The runtime or records directory lies inside the workdir, which caps the class at
230    /// `advisory`.
231    InsideWorkdir,
232}
233
234/// [`containment_shortfall_reason`], with the [`ShortfallCause`] it was decided by.
235fn containment_shortfall(
236    declared: ContainmentClass,
237    achieved: ContainmentClass,
238    sealed_shortfall: Option<&SealedShortfall>,
239    workdir_exec: bool,
240    runtime_dir_inside: Option<&RuntimeDirPlacement>,
241) -> Option<(String, ShortfallCause)> {
242    if achieved >= declared {
243        return None;
244    }
245
246    if workdir_exec && declared > ContainmentClass::Advisory {
247        return Some((
248            WORKDIR_EXEC_SHORTFALL.to_string(),
249            ShortfallCause::WorkdirExec,
250        ));
251    }
252
253    if let Some(placement) = runtime_dir_inside.filter(|_| declared > ContainmentClass::Advisory) {
254        return Some((placement.inside_shortfall(), ShortfallCause::InsideWorkdir));
255    }
256
257    let reason = match declared {
258        // Unreachable: every class meets an unconfined floor, so `achieved >= declared` above
259        // has already returned.
260        ContainmentClass::Unconfined => {
261            "unconfined declares no floor and is met by every session".to_string()
262        }
263        // Unreachable in practice — `Advisory` is the weakest class, so nothing is below it —
264        // but stated rather than `unreachable!()` so a future variant reordering cannot panic
265        // a launch path.
266        ContainmentClass::Advisory => {
267            "advisory is the weakest containment class and is satisfied by every host".to_string()
268        }
269        ContainmentClass::Scoped => {
270            "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
271             ABI); this host provides no kernel filesystem mediation, so paths outside the \
272             workdir are constrained by convention only"
273                .to_string()
274        }
275        // The mechanism exists now, so the reason names *which part of it* this host is missing
276        // and how to fix that part — never the old blanket "no host can provide it today".
277        ContainmentClass::Sealed => match sealed_shortfall {
278            Some(shortfall) => shortfall.reason(),
279            None => "sealed requires a private mount namespace pivoted onto a composed root \
280                     (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
281                     provide"
282                .to_string(),
283        },
284    };
285
286    Some((reason, ShortfallCause::Host))
287}
288
289/// The reason a capsule that declared `capabilities.filesystem.workdir_exec: true` falls short of
290/// any floor above `advisory`. Fixed text rather than a `match` arm per declared class: the
291/// mechanism, the consequence and the remedy are identical for `scoped` and `sealed`, because both
292/// rest on the claim this declaration gives up.
293const WORKDIR_EXEC_SHORTFALL: &str =
294    "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
295     workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
296     capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
297     No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
298     then enforced by the kernel on the resolved path) or lower the declared containment floor to \
299     advisory";
300
301/// The refusal gate: `Ok(())` when the session's `achieved` class
302/// ([`ContainmentDecision::achieved`]) meets or exceeds the `declared` floor,
303/// [`RuntimeError::ContainmentFloorUnmet`] otherwise.
304///
305/// Pure — takes every input as an argument and probes nothing, so the whole decision is
306/// testable without a kernel.
307pub fn check_containment_floor(
308    declared: ContainmentClass,
309    achieved: ContainmentClass,
310    sealed_shortfall: Option<&SealedShortfall>,
311    workdir_exec: bool,
312    runtime_dir_inside: Option<&RuntimeDirPlacement>,
313) -> Result<(), RuntimeError> {
314    match containment_shortfall(
315        declared,
316        achieved,
317        sealed_shortfall,
318        workdir_exec,
319        runtime_dir_inside,
320    ) {
321        None => Ok(()),
322        Some((reason, cause)) => Err(RuntimeError::ContainmentFloorUnmet {
323            declared,
324            achieved,
325            reason,
326            cause,
327            required_by: Vec::new(),
328        }),
329    }
330}
331
332/// The declared `exports.files` block as it appears in a [`ScopeReport`] — and therefore in
333/// `mur run --explain-scope --json` and in `trace.jsonl`'s `session_start.effective_grants`.
334///
335/// A flattened copy rather than [`FileExport`] itself: this is a wire shape with a stable field
336/// order and a `mode` that serializes as `"read-only"`, and it must not move whenever the
337/// manifest type gains a field the report has no business publishing.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
339pub struct ExportsFilesReport {
340    /// `exports.files.root` verbatim, as declared.
341    pub root: String,
342    pub mode: ExportMode,
343    /// The effective per-file read ceiling, with the default already applied.
344    pub max_bytes: u64,
345}
346
347impl From<&FileExport> for ExportsFilesReport {
348    fn from(export: &FileExport) -> Self {
349        Self {
350            root: export.root.clone(),
351            mode: export.mode,
352            max_bytes: export.max_bytes,
353        }
354    }
355}
356
357/// The declared `exports.peer_files` block as it appears in a [`ScopeReport`].
358///
359/// A flattened copy rather than [`PeerFilesExport`] itself, on the same terms as
360/// [`ExportsFilesReport`]: a wire shape with a stable field order, which must not move whenever
361/// the manifest type gains a field the report has no business publishing.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
363pub struct PeerFilesReport {
364    /// `exports.peer_files.root` verbatim, as declared.
365    pub root: String,
366    /// The effective handle-lifetime ceiling in seconds, with the ephemeral default already
367    /// applied — so an operator reads the value the runtime will actually clamp to, not the
368    /// absence of a declaration.
369    pub max_ttl_secs: u64,
370    /// The effective per-file ceiling on a redeemed read, with the default already applied.
371    pub max_bytes: u64,
372}
373
374impl From<&PeerFilesExport> for PeerFilesReport {
375    fn from(export: &PeerFilesExport) -> Self {
376        Self {
377            root: export.root.clone(),
378            max_ttl_secs: export.effective_max_ttl_secs(),
379            max_bytes: export.max_bytes,
380        }
381    }
382}
383
384/// One artifact's durable state grant as it appears in a [`ScopeReport`].
385///
386/// A grant rather than a disclosure, unlike [`ExportsFilesReport`] beside it — but reported on
387/// exactly the same terms, and for the same reason: it opens a host path outside the workdir that
388/// an operator reading `filesystem scope` alone would never see.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390pub struct StateStoreReport {
391    /// The artifact whose entry declared `capabilities.state`.
392    pub artifact: String,
393    /// The store directory name, with the capsule-name default already applied — so an operator
394    /// reads the directory that will actually be opened, not the absence of a declaration.
395    pub store: String,
396    /// Absolute host path of the store, `~/.murmur/state/<store>` with the home already resolved.
397    /// Reported as a string rather than a path so the JSON shape does not vary by platform.
398    pub host_path: String,
399}
400
401/// The filesystem surface one artifact's guest is preopened into, as a stable wire name.
402///
403/// The three values are the whole space: a WASI guest either holds a directory descriptor for the
404/// accessible workdir, holds one for a subtree of it, or holds none at all. Which one an entry
405/// resolves to is decided by its role and its `capabilities.filesystem.scope` in
406/// [`crate::network_policy::preopen_reports`]; the baselines the two roles start from are
407/// [`crate::network_policy::ToolCapabilityGrant`] and
408/// [`crate::network_policy::HookCapabilityGrant`].
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
410#[serde(rename_all = "kebab-case")]
411pub enum PreopenSurface {
412    /// The entire accessible workdir, preopened as `"."` with `FsPerms::ReadWrite`. The wide
413    /// default a `runtime: tool` or `runtime: driver` entry with no
414    /// `capabilities.filesystem.scope` resolves to — see
415    /// [`crate::network_policy::ToolCapabilityGrant`] for the threat that default is chosen
416    /// against and the threat it is not.
417    WholeWorkdir,
418    /// `<accessible workdir>/<scope>` and nothing above it, preopened as `"."` with the same
419    /// rights. What a declared `capabilities.filesystem.scope` resolves to, whatever the role.
420    ScopedSubtree,
421    /// No preopened directory of any kind, so the guest holds no `wasi:filesystem` descriptor. The
422    /// default-deny baseline a `runtime: hook` entry with no `capabilities.filesystem.scope`
423    /// resolves to — see [`crate::network_policy::HookCapabilityGrant`].
424    Nothing,
425    /// The session's records directory's `hooks/` and nothing else, preopened as `"."`
426    /// read-write: what a `runtime: hook` entry declaring `capabilities.filesystem.records: true`
427    /// resolves to, in place of a workdir scope.
428    RecordsHooks,
429}
430
431impl PreopenSurface {
432    /// The phrase [`PreopenReport::render`] names this surface with. Distinct per variant rather
433    /// than a formatted path, because the point of the line is that an operator can tell the three
434    /// apart without knowing the resolved workdir.
435    fn summary(self) -> &'static str {
436        match self {
437            PreopenSurface::WholeWorkdir => "the whole accessible workdir",
438            PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
439            PreopenSurface::Nothing => "nothing preopened",
440            PreopenSurface::RecordsHooks => "the records directory's hooks/",
441        }
442    }
443}
444
445/// One artifact's resolved WASI preopen as it appears in a [`ScopeReport`] — and therefore in
446/// `mur run --explain-scope`, in `mur doctor`, and in `trace.jsonl`'s
447/// `session_start.effective_grants`.
448///
449/// Per-artifact, like [`StateStoreReport`] beside it, and reported for the same reason: the
450/// capsule-wide [`ScopeReport::filesystem_scope`] is joined into no guest preopen root, so an
451/// operator reading that field alone learns nothing about what any tool, driver or hook can
452/// actually see.
453#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
454pub struct PreopenReport {
455    /// The artifact whose entry in the capsule operator's own manifest this describes.
456    pub artifact: String,
457    /// [`murmur_artifact::ArtifactRuntime::as_str`] verbatim, so the report and the manifest name
458    /// a role with one vocabulary.
459    pub role: String,
460    /// The entry's declared `capabilities.filesystem.scope`, verbatim. `None` = undeclared, which
461    /// is what [`Self::surface`] has already resolved against the role's own baseline.
462    pub scope: Option<String>,
463    /// What the guest is preopened into once the role's baseline and [`Self::scope`] are both
464    /// applied.
465    pub surface: PreopenSurface,
466    /// How the artifact reaches the session's runtime directory, read-only: at `murmur-runtime/`
467    /// for a `runtime: tool` with no `capabilities.filesystem.scope`. `None` — serialized
468    /// `null` — for every other entry, which has no reach.
469    pub runtime_dir_reach: Option<RuntimeDirReach>,
470    /// The host folders the guest is also preopened into, under `murmur-host`
471    /// ([`crate::guest_roots`]), in preopen order. Filled for exactly the entries that reach the
472    /// runtime directory, from the session's unconfined stance or `host_paths`. Always
473    /// serialized: `[]` for every other entry.
474    pub extra_roots: Vec<ExtraRootReport>,
475}
476
477/// One extra root as [`PreopenReport::extra_roots`] carries it. Every key is always present.
478#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct ExtraRootReport {
480    /// Where the guest opens it: `murmur-host` followed by [`Self::path`].
481    pub guest_path: String,
482    /// The canonical host directory.
483    pub path: String,
484    /// `read` or `read-write`.
485    pub access: crate::host_paths::HostPathAccess,
486    /// `unconfined` or `host_paths`.
487    pub source: crate::guest_roots::GuestRootSource,
488}
489
490impl From<&crate::guest_roots::GuestRoot> for ExtraRootReport {
491    fn from(root: &crate::guest_roots::GuestRoot) -> Self {
492        Self {
493            guest_path: root.guest_path.clone(),
494            path: root.host_path.display().to_string(),
495            access: root.access,
496            source: root.source,
497        }
498    }
499}
500
501/// Every report that reaches the runtime directory given `roots`, the session's extra roots
502/// ([`crate::guest_roots::extra_guest_roots`]). `stage_session`, `mur run --explain-scope` and
503/// `mur doctor` all call it, so the three agree.
504pub fn attach_extra_roots(reports: &mut [PreopenReport], roots: &[crate::guest_roots::GuestRoot]) {
505    for report in reports {
506        report.extra_roots = if report.runtime_dir_reach.is_some() {
507            roots.iter().map(ExtraRootReport::from).collect()
508        } else {
509            Vec::new()
510        };
511    }
512}
513
514/// How one artifact reaches the runtime directory, as [`PreopenReport::runtime_dir_reach`]
515/// carries it.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
517#[serde(rename_all = "kebab-case")]
518pub enum RuntimeDirReach {
519    /// A tool's read-only preopen at `murmur-runtime/`.
520    MurmurRuntime,
521}
522
523impl PreopenReport {
524    /// The single line this preopen renders as, shared by [`ScopeReport::render`] and by
525    /// `mur doctor` so the two cannot word the same grant differently.
526    pub fn render(&self) -> String {
527        let artifact = &self.artifact;
528        let role = &self.role;
529        let surface = self.surface.summary();
530        let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
531        let runtime_dir = match self.runtime_dir_reach {
532            None => String::new(),
533            Some(RuntimeDirReach::MurmurRuntime) => {
534                format!("; runtime directory read-only at {guest}/")
535            }
536        };
537        let extra_roots = self.render_extra_roots();
538        match &self.scope {
539            Some(scope) => format!(
540                "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
541                 {scope}{runtime_dir}{extra_roots}"
542            ),
543            None => format!(
544                "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
545                 declared{runtime_dir}{extra_roots}"
546            ),
547        }
548    }
549
550    /// The `; …` fragment naming [`Self::extra_roots`], empty when there are none.
551    fn render_extra_roots(&self) -> String {
552        if self.extra_roots.is_empty() {
553            return String::new();
554        }
555        let guest = crate::guest_roots::HOST_ROOT_GUEST_PATH;
556        let whole_host = self
557            .extra_roots
558            .iter()
559            .any(|root| root.source == crate::guest_roots::GuestRootSource::Unconfined);
560        if whole_host {
561            return format!("; whole host read-write under {guest}/");
562        }
563        let folders: Vec<String> = self
564            .extra_roots
565            .iter()
566            .map(|root| format!("{} ({})", root.path, root.access))
567            .collect();
568        format!("; host folders under {guest}/: {}", folders.join(", "))
569    }
570}
571
572/// What the filesystem mechanism this session installs restricts, as a stable wire name.
573///
574/// Keyed on [`ContainmentDecision::applied_tier`], the tier
575/// [`crate::sandbox::ShellEnforcement::resolve`] installs the session's mechanisms from and the
576/// one [`ScopeReport::enforcement_tier`] names. Not on the achieved class, which `workdir_exec`
577/// and the runtime directory's placement can cap at `advisory` without changing what the
578/// mechanism restricts.
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
580#[serde(rename_all = "kebab-case")]
581pub enum FilesystemRestriction {
582    /// No kernel mechanism mediates the filesystem, so a path outside the workdir is reachable.
583    Advisory,
584    /// Landlock refuses the open, and the path outside the granted set still exists.
585    Enforced,
586    /// A composed root, where the path outside the capsule is not there to open.
587    Absent,
588    /// The unconfined stance: commands run as the operator and reach the host filesystem as the
589    /// operator does.
590    None,
591}
592
593/// What the filesystem mechanism this session installs does *not* protect, in words an operator
594/// can act on.
595///
596/// The counterpart to every grant beside it in a [`ScopeReport`]: those say what the capsule may
597/// reach, and this says which of the restrictions an operator would read into them are not there.
598///
599/// [`Self::not_protected`] is non-empty below [`FilesystemRestriction::Absent`], and empty at it
600/// unless the runtime directory lies inside the workdir. It is serialized either way — an absent
601/// key identifies a runtime that predates the field, never a capsule with nothing to disclaim.
602#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
603pub struct FilesystemBoundaryReport {
604    /// What the session's filesystem mechanism restricts.
605    pub restriction: FilesystemRestriction,
606    /// One statement per protection an operator would otherwise assume, the runtime directory's
607    /// [`RuntimeDirReport::not_protected_because`] among them when it is set. Rendered verbatim
608    /// under `Not protected here`, so the JSON and the human report cannot word the same
609    /// disclaimer differently.
610    pub not_protected: Vec<String>,
611}
612
613/// What a session with no kernel filesystem mechanism leaves unrestricted.
614///
615/// Names the mechanism rather than the class, because "advisory" is the word an operator has
616/// already read on the `achieved:` line and taken for a protection.
617const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
618    "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
619     outside the work folder, so capabilities.filesystem.scope bounds what the runtime hands a \
620     subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
621     ABI installs a Landlock domain instead, where the kernel refuses the open.";
622
623/// What a Landlock-scoped session leaves unrestricted.
624///
625/// Not the `advisory` sentence: Landlock really does mediate the filesystem here, and an operator
626/// told otherwise goes and hardens something the kernel already holds. What Landlock does not do is
627/// make a path absent — it mediates the operations that touch a file, not path resolution.
628const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
629    "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
630     stat, access and readlink on that path still succeed and still report the real file's \
631     metadata, because Landlock mediates the operations that touch a file and not path resolution. \
632     A probe that only asks whether a path exists therefore learns nothing about what was denied — \
633     read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
634     reaches the sealed tier composes a root the path is not in at all.";
635
636/// What `HOME` is on a tier with no kernel filesystem mechanism: moved, and nothing more.
637///
638/// Stated beside [`ADVISORY_FILESYSTEM_NOT_PROTECTED`] because the relocation is the one thing an
639/// operator sees working on such a tier, and it is easy to read as a boundary.
640const ADVISORY_HOME_NOT_PROTECTED: &str =
641    "HOME is relocated, not protected: it points at the session's home folder, and the real home \
642     directory stays readable and writable by absolute path, because no kernel mechanism mediates \
643     the filesystem on this tier.";
644
645/// What no mechanism protects, and the reason `--explain-scope` carries this block at all.
646///
647/// Identical wherever it prints, because the rewrite it describes is identical: `HOME` is replaced
648/// with the session's home folder on every tier and every platform (see `crate::shell`), so the
649/// obvious `~`-based containment probe answers *no such file* on a host that denies nothing.
650const PATH_REWRITING_NOT_PROTECTED: &str =
651    "Path rewriting is a convenience, not a boundary: HOME is set to the session's home folder, \
652     home/ in its records directory, on every tier, so `cat ~/.ssh/id_rsa` answers \"No such file \
653     or directory\" whether or not anything was denied — the tilde resolved into that folder and \
654     the host file was never looked for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
655     to a real file outside the workdir instead.";
656
657/// What an unconfined session leaves unrestricted: everything, which is the one statement made.
658pub(crate) const UNCONFINED_NOT_PROTECTED: &str =
659    "Nothing is contained: this session runs unconfined, so its shell commands, plan shell steps \
660     and process harness run as the operator, with the operator's HOME, environment \
661     (credential variables included), filesystem and network, and sudo works wherever the \
662     operator's sudoers allows it. The trace and the records directory are written as always but \
663     are not protected from those commands.";
664
665/// The filesystem boundary the tier a session applies actually provides, and the protections it
666/// does not.
667///
668/// Pure and derived from the applied tier alone, so `--explain-scope` and
669/// `session_start.effective_grants` cannot word the same disclaimer differently: both go through
670/// [`scope_report_for_tier`], which calls this once with the tier it has already resolved.
671///
672/// The argument is the *applied* tier, never the host tier and never the achieved class. A composed
673/// root is what makes a host path absent, and a session composes one only when the host reaches
674/// `KernelSealed` **and** the capsule declared a `sealed` floor; a Landlock domain is installed
675/// whenever the applied tier is `KernelFull` or `KernelSealed`, including for a capsule whose
676/// `capabilities.filesystem.workdir_exec` caps its achieved class at `advisory`.
677pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
678    // The path-rewriting statement rides along with the mechanism-specific one rather than standing
679    // on its own, so a composed root — where the paths outside it are absent, and the `~` rewrite
680    // lands inside the workdir — discloses nothing at all.
681    if applied == EnforcementTier::Unconfined {
682        // `HOME` is not rewritten on this tier, so the path-rewriting statement does not apply.
683        return FilesystemBoundaryReport {
684            restriction: FilesystemRestriction::None,
685            not_protected: vec![UNCONFINED_NOT_PROTECTED.to_string()],
686        };
687    }
688    // The `HOME` statement follows the path-rewriting one it qualifies, so that statement sits at
689    // the same place on every tier that carries it.
690    let not_protected: &[&str] = match applied {
691        EnforcementTier::KernelSealed => &[],
692        EnforcementTier::Unconfined => unreachable!("returned above"),
693        EnforcementTier::KernelFull => &[
694            ENFORCED_FILESYSTEM_NOT_PROTECTED,
695            PATH_REWRITING_NOT_PROTECTED,
696        ],
697        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => &[
698            ADVISORY_FILESYSTEM_NOT_PROTECTED,
699            PATH_REWRITING_NOT_PROTECTED,
700            ADVISORY_HOME_NOT_PROTECTED,
701        ],
702    };
703    let restriction = match applied {
704        EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
705        EnforcementTier::Unconfined => unreachable!("returned above"),
706        EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
707        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
708            FilesystemRestriction::Advisory
709        }
710    };
711    let not_protected = not_protected
712        .iter()
713        .map(|statement| (*statement).to_string())
714        .collect();
715
716    FilesystemBoundaryReport {
717        restriction,
718        not_protected,
719    }
720}
721
722/// What the session's runtime directory, or its records directory, is to the capsule's commands —
723/// `--explain-scope`'s `Runtime directory` and `Records directory` blocks,
724/// `session_start.effective_grants.runtime_dir` and `.records_dir` and `mur doctor`'s sections,
725/// all from [`runtime_dir_report`] and [`records_dir_report`].
726#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
727pub struct RuntimeDirReport {
728    /// `<home>/.murmur/sessions/<session-id>/runtime`, or
729    /// `<home>/.murmur/records/<workdir root>/<session-id>`, with the literal `<session-id>`
730    /// segment `runtime_writes` carries.
731    pub path: String,
732    /// Whether no capsule command can change a file in it.
733    pub read_only_to_commands: bool,
734    /// The mechanism that makes it read-only: `mountns+landlock` on the sealed tier, `landlock`
735    /// on the Landlock tier, `null` when nothing does.
736    pub enforced_by: Option<&'static str>,
737    /// Why a command can change it, in words an operator can act on; `null` when none can.
738    pub not_protected_because: Option<String>,
739}
740
741/// The runtime directory on the sealed tier: a read-only bind plus a Landlock rule.
742const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
743
744/// The runtime directory on the Landlock tier: a read-only Landlock rule.
745const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
746
747/// The lowest Landlock ABI that mediates `truncate(2)`.
748const LANDLOCK_TRUNCATE_ABI: u8 = 3;
749
750/// One of the two directories outside the workdir the runtime keeps out of the commands' reach,
751/// as [`protected_dir_report`] words its claims about it.
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753enum ProtectedDir {
754    Runtime,
755    Records,
756}
757
758impl ProtectedDir {
759    fn noun(self) -> &'static str {
760        match self {
761            Self::Runtime => "runtime directory",
762            Self::Records => "records directory",
763        }
764    }
765
766    /// What a command could empty there on a Landlock ABI that does not mediate `truncate(2)`.
767    fn file(self) -> &'static str {
768        match self {
769            Self::Runtime => "runtime file",
770            Self::Records => "record",
771        }
772    }
773
774    fn heading(self) -> &'static str {
775        match self {
776            Self::Runtime => "Runtime directory",
777            Self::Records => "Records directory",
778        }
779    }
780}
781
782/// What the session's runtime directory is to its commands, from the tier the session applies,
783/// the Landlock ABI its ruleset is built at and where the directory lies.
784///
785/// Pure and the one source of the claim: `--explain-scope`, `session_start.effective_grants` and
786/// `mur doctor` all print what this returns. It never claims protection on the seccomp-only tier,
787/// with no kernel containment, or when the directory lies inside the workdir, and on the Landlock
788/// tier below ABI 3 it says `truncate(2)` is not mediated.
789#[must_use]
790pub(crate) fn runtime_dir_report(
791    applied: EnforcementTier,
792    landlock_abi: Option<u8>,
793    placement: &RuntimeDirPlacement,
794) -> RuntimeDirReport {
795    protected_dir_report(
796        ProtectedDir::Runtime,
797        applied,
798        landlock_abi,
799        placement.report_path(),
800        placement.sessions_inside_workdir,
801        &placement.workdir,
802    )
803}
804
805/// [`runtime_dir_report`] for the session's records directory, which the same rule and bind
806/// protect on the same tiers.
807#[must_use]
808pub(crate) fn records_dir_report(
809    applied: EnforcementTier,
810    landlock_abi: Option<u8>,
811    placement: &RuntimeDirPlacement,
812) -> RuntimeDirReport {
813    protected_dir_report(
814        ProtectedDir::Records,
815        applied,
816        landlock_abi,
817        placement.records_report_path(),
818        placement.records_inside_workdir,
819        &placement.workdir,
820    )
821}
822
823fn protected_dir_report(
824    dir: ProtectedDir,
825    applied: EnforcementTier,
826    landlock_abi: Option<u8>,
827    path: String,
828    inside_workdir: bool,
829    workdir: &std::path::Path,
830) -> RuntimeDirReport {
831    let noun = dir.noun();
832    let not_protected = |reason: String| RuntimeDirReport {
833        path: path.clone(),
834        read_only_to_commands: false,
835        enforced_by: None,
836        not_protected_because: Some(reason),
837    };
838    match applied {
839        EnforcementTier::Unconfined => not_protected(format!(
840            "the capsule runs unconfined, so its commands run as the operator and can change the \
841             {noun}"
842        )),
843        EnforcementTier::EnvironmentOnly => not_protected(format!(
844            "this host has no kernel containment for capsule commands — macOS and every \
845             non-Linux host run them as the operator's own processes, so a command can change \
846             the {noun}"
847        )),
848        EnforcementTier::KernelSeccompOnly => not_protected(format!(
849            "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
850             {noun}"
851        )),
852        _ if inside_workdir => not_protected(format!(
853            "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
854             it; run with a --workdir that does not contain ~/.murmur",
855            workdir.display()
856        )),
857        EnforcementTier::KernelSealed => RuntimeDirReport {
858            path,
859            read_only_to_commands: true,
860            enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
861            not_protected_because: None,
862        },
863        EnforcementTier::KernelFull => {
864            let abi = landlock_abi.unwrap_or(1);
865            if abi < LANDLOCK_TRUNCATE_ABI {
866                not_protected(format!(
867                    "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
868                     {} though not replace it; kernel 6.2 or later refuses it",
869                    dir.file()
870                ))
871            } else {
872                RuntimeDirReport {
873                    path,
874                    read_only_to_commands: true,
875                    enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
876                    not_protected_because: None,
877                }
878            }
879        }
880    }
881}
882
883/// The `Runtime directory` block of `--explain-scope` and `mur doctor`: its path, and whether
884/// the capsule's commands can change it. Ends with a newline.
885#[must_use]
886pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
887    render_protected_dir(ProtectedDir::Runtime, report)
888}
889
890/// The `Records directory` block of `--explain-scope` and `mur doctor`, on
891/// [`render_runtime_dir`]'s terms. Ends with a newline.
892#[must_use]
893pub fn render_records_dir(report: &RuntimeDirReport) -> String {
894    render_protected_dir(ProtectedDir::Records, report)
895}
896
897fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
898    let commands = match (&report.not_protected_because, report.enforced_by) {
899        (Some(reason), _) => format!("can change it — {reason}"),
900        (None, Some(mechanism)) => format!("read only ({mechanism})"),
901        (None, None) => "read only".to_string(),
902    };
903    format!(
904        "{}\n  path:      {}\n  commands:  {commands}\n",
905        dir.heading(),
906        report.path
907    )
908}
909
910/// The session's temporary folder, and whether a literal `/tmp` reaches it —
911/// `--explain-scope`'s `Temporary folder` block, `session_start.effective_grants.temp_dir` and
912/// `mur doctor`'s section, all from [`temp_dir_report`]. Field names are the JSON keys.
913#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
914pub struct TempDirReport {
915    /// `<home>/.murmur/records/<workdir root>/<session-id>/tmp`, with the literal `<session-id>`
916    /// segment the records directory's path carries.
917    pub path: String,
918    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command: `/tmp` when mapped, [`Self::path`]
919    /// when not.
920    pub tmpdir: String,
921    /// Whether a literal `/tmp` is the folder.
922    pub tmp_mapped: bool,
923    /// The mechanism that maps it: `composed-root` on the sealed tier, `mount-namespace` on the
924    /// tiers below it, `null` when not mapped.
925    pub mapped_by: Option<&'static str>,
926    /// Why `/tmp` is not mapped and what a literal `/tmp` path reaches instead; `null` when
927    /// mapped.
928    pub not_mapped_because: Option<String>,
929}
930
931/// What the session's temporary folder is to its commands under `mapping`, with the folder's path
932/// from `placement`. Pure.
933#[must_use]
934pub(crate) fn temp_dir_report(
935    mapping: crate::temp_dir::TempMapping,
936    placement: &RuntimeDirPlacement,
937) -> TempDirReport {
938    let path = std::path::Path::new(&placement.records_report_path())
939        .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
940        .display()
941        .to_string();
942    TempDirReport {
943        tmpdir: mapping.tmpdir_value(std::path::Path::new(&path)),
944        path,
945        tmp_mapped: mapping.is_mapped(),
946        mapped_by: mapping.mapped_by(),
947        not_mapped_because: match mapping {
948            crate::temp_dir::TempMapping::NotMapped(reason) => Some(reason.describe().to_string()),
949            _ => None,
950        },
951    }
952}
953
954/// The `Temporary folder` block of `--explain-scope` and `mur doctor`: its path, the value of
955/// `TMPDIR`, and whether a literal `/tmp` reaches it. Ends with a newline.
956#[must_use]
957pub fn render_temp_dir(report: &TempDirReport) -> String {
958    let tmp = match (report.mapped_by, &report.not_mapped_because) {
959        (Some(mechanism), _) => format!("mapped ({mechanism})"),
960        (None, Some(reason)) => format!("not mapped — {reason}"),
961        (None, None) => "not mapped".to_string(),
962    };
963    format!(
964        "Temporary folder\n  path:      {}\n  TMPDIR:    {}\n  /tmp:      {tmp}\n",
965        report.path, report.tmpdir
966    )
967}
968
969/// One element of [`ScopeReport::declared_needs`]: what one artifact's needs for one program
970/// resolved to. Every key is always serialized.
971#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
972pub struct DeclaredNeedsReport {
973    /// The artifact as the capsule's manifest names it, scope included.
974    pub artifact: String,
975    /// The program key, as `capabilities.shell.allow` names it.
976    pub program: String,
977    pub granted: Vec<GrantedNeedReport>,
978    pub missing: Vec<MissingNeedReport>,
979}
980
981/// A granted need: its label (`library libm.so.6`, `program_folder .`) and each path, rendered
982/// `<path>` for a file and `<path> (list_dir)` for a directory.
983#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
984pub struct GrantedNeedReport {
985    pub need: String,
986    pub paths: Vec<String>,
987}
988
989/// A missing need: its label and why it is not granted.
990#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
991pub struct MissingNeedReport {
992    pub need: String,
993    pub reason: String,
994}
995
996impl From<&crate::declared_needs::ResolvedProgramNeeds> for DeclaredNeedsReport {
997    fn from(resolved: &crate::declared_needs::ResolvedProgramNeeds) -> Self {
998        Self {
999            artifact: resolved.artifact.clone(),
1000            program: resolved.program.clone(),
1001            granted: resolved
1002                .granted
1003                .iter()
1004                .map(|need| GrantedNeedReport {
1005                    need: need.need.form.to_string(),
1006                    paths: need
1007                        .paths
1008                        .iter()
1009                        .map(crate::declared_needs::NeedPath::render)
1010                        .collect(),
1011                })
1012                .collect(),
1013            missing: resolved
1014                .missing
1015                .iter()
1016                .map(|need| MissingNeedReport {
1017                    need: need.need.form.to_string(),
1018                    reason: need.reason.clone(),
1019                })
1020                .collect(),
1021        }
1022    }
1023}
1024
1025impl DeclaredNeedsReport {
1026    /// One line per need, as `--explain-scope` prints them under `declared needs:`:
1027    /// `<artifact> (<program>): <need> -> <path>[, <path>…]` for a granted need and
1028    /// `<artifact> (<program>): <need> -> missing (<reason>)` for a missing one.
1029    #[must_use]
1030    pub fn render_lines(&self) -> Vec<String> {
1031        let owner = format!("{} ({})", self.artifact, self.program);
1032        self.granted
1033            .iter()
1034            .map(|need| format!("{owner}: {} -> {}", need.need, need.paths.join(", ")))
1035            .chain(
1036                self.missing
1037                    .iter()
1038                    .map(|need| format!("{owner}: {} -> missing ({})", need.need, need.reason)),
1039            )
1040            .collect()
1041    }
1042}
1043
1044/// One element of [`ScopeReport::pinned_programs`]: which copy of a program a tool pins a
1045/// download of the session runs. `path` is absent for `missing`, `url` and `sha256` are present
1046/// only for `pinned`, and `reason` only for `missing`.
1047#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1048pub struct PinnedProgramReport {
1049    /// The artifact as the capsule's manifest names it, scope included.
1050    pub artifact: String,
1051    /// The program key, as `capabilities.shell.allow` names it.
1052    pub program: String,
1053    /// This host's platform, as the download is pinned for it (`linux-x86_64`).
1054    pub platform: String,
1055    /// `pinned` (the program store's copy), `host` (a program of that name on `PATH`) or
1056    /// `missing`.
1057    pub source: String,
1058    /// The file that runs, canonical.
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    pub path: Option<String>,
1061    #[serde(skip_serializing_if = "Option::is_none")]
1062    pub url: Option<String>,
1063    #[serde(skip_serializing_if = "Option::is_none")]
1064    pub sha256: Option<String>,
1065    #[serde(skip_serializing_if = "Option::is_none")]
1066    pub reason: Option<String>,
1067}
1068
1069impl From<&crate::pinned_programs::ResolvedPinnedProgram> for PinnedProgramReport {
1070    fn from(resolved: &crate::pinned_programs::ResolvedPinnedProgram) -> Self {
1071        use crate::pinned_programs::PinnedSource;
1072        let mut report = Self {
1073            artifact: resolved.artifact.clone(),
1074            program: resolved.program.clone(),
1075            platform: resolved.platform.clone(),
1076            source: resolved.source.label().to_string(),
1077            path: None,
1078            url: None,
1079            sha256: None,
1080            reason: None,
1081        };
1082        match &resolved.source {
1083            PinnedSource::Pinned {
1084                path, url, sha256, ..
1085            } => {
1086                report.path = Some(path.display().to_string());
1087                report.url = Some(url.clone());
1088                report.sha256 = Some(sha256.clone());
1089            }
1090            PinnedSource::Host { path } => report.path = Some(path.display().to_string()),
1091            PinnedSource::Missing { reason } => report.reason = Some(reason.clone()),
1092        }
1093        report
1094    }
1095}
1096
1097impl PinnedProgramReport {
1098    /// The line `--explain-scope` prints under `pinned programs:`:
1099    /// `<artifact> (<program>): pinned <path> (sha256 <sha>, from <url>)`,
1100    /// `<artifact> (<program>): host <path>` or `<artifact> (<program>): missing (<reason>)`.
1101    #[must_use]
1102    pub fn render_line(&self) -> String {
1103        let owner = format!("{} ({})", self.artifact, self.program);
1104        let path = self.path.as_deref().unwrap_or_default();
1105        match self.source.as_str() {
1106            "pinned" => format!(
1107                "{owner}: pinned {path} (sha256 {}, from {})",
1108                self.sha256.as_deref().unwrap_or_default(),
1109                self.url.as_deref().unwrap_or_default()
1110            ),
1111            "host" => format!("{owner}: host {path}"),
1112            _ => format!(
1113                "{owner}: missing ({})",
1114                self.reason.as_deref().unwrap_or_default()
1115            ),
1116        }
1117    }
1118}
1119
1120/// Read-only answer to "what would this capsule actually be allowed to do on this host?".
1121///
1122/// Built from the already-parsed [`CapabilityPolicy`] plus one [`ContainmentDecision`]. Nothing
1123/// here stages artifacts, touches a registry, creates a workdir or resolves DNS — it is a
1124/// diagnostic, so it reports even when [`Self::floor_met`] is `false`.
1125#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1126pub struct ScopeReport {
1127    /// Strongest class any source asked for (manifest / workspace config / `--containment`).
1128    pub declared_containment: ContainmentClass,
1129    /// The class this session's commands run under: the class of [`Self::enforcement_tier`],
1130    /// capped at `advisory` by `workdir_exec` or a runtime directory inside the workdir. From
1131    /// [`ContainmentDecision::achieved`].
1132    pub achieved_containment: ContainmentClass,
1133    /// The class this host could back for a capsule declaring `sealed`, never what this session
1134    /// runs on: a capsule declaring less runs on a weaker tier, and [`Self::achieved_containment`]
1135    /// says which. From [`ContainmentDecision::host_containment`]. Always serialized.
1136    pub host_containment: ContainmentClass,
1137    /// `achieved_containment >= declared_containment`. `false` means `mur run` would refuse.
1138    pub floor_met: bool,
1139    /// The missing mechanism when `floor_met` is `false`; absent otherwise.
1140    #[serde(skip_serializing_if = "Option::is_none")]
1141    pub shortfall_reason: Option<String>,
1142    /// The tier this session installs, as a stable wire name: the host's tier capped by the
1143    /// declared floor, from [`ContainmentDecision::enforcement_tier`].
1144    pub enforcement_tier: &'static str,
1145    /// Every route this capsule could run code on, and what holds each: one [`RouteReport`] row per
1146    /// [`Route::ALL`] entry, in that order, from [`route_table`]. A route the session's class
1147    /// covers names [`Self::enforcement_tier`]; one it does not names [`HOST_PROCESS_MECHANISM`]
1148    /// and says what it does run under.
1149    ///
1150    /// Always serialized, every row, on every tier and platform: an absent key identifies a
1151    /// runtime that predates it. Set again through [`Self::with_routes`] by the caller that knows
1152    /// the inference transport.
1153    pub routes: Vec<RouteReport>,
1154    /// Where this host's permission to create an unprivileged user namespace comes from, as
1155    /// [`UsernsGrant::wire_name`], or `null` off Linux, where AppArmor does not exist and the
1156    /// question has no answer.
1157    ///
1158    /// Always serialized, on the same terms as [`Self::workdir_exec`]: the key's absence
1159    /// identifies a runtime that predates it, not a host that was not asked. Reported because
1160    /// three very different hosts — no AppArmor, hardening switched off host-wide, and the shipped
1161    /// profile confining `mur` — otherwise produce byte-identical reports while differing enormously
1162    /// in what else on the machine can create a user namespace.
1163    pub userns_grant: Option<UsernsGrant>,
1164    /// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
1165    /// [`crate::sandbox::LANDLOCK_TARGET_ABI`], or `null` when no Landlock domain is installed —
1166    /// a tier below `landlock+seccomp`, or a kernel without Landlock.
1167    ///
1168    /// Keyed on the session's applied tier, like [`Self::filesystem_boundary`], and computed
1169    /// by the same [`crate::sandbox::ruleset_landlock_abi`] the shell child builds at, so the
1170    /// trace records the ruleset that was installed. Always serialized, on the same terms as
1171    /// [`Self::userns_grant`]. Below 2 a file cannot be moved or hard-linked between two folders
1172    /// of the workdir; below 3 truncating a host file outside it is not refused.
1173    pub landlock_abi: Option<u8>,
1174    /// `capabilities.filesystem.scope`, verbatim.
1175    pub filesystem_scope: Option<String>,
1176    /// `capabilities.filesystem.workdir_exec`. Always present (never skipped when `false`), so a
1177    /// consumer can tell "declared false" from "this runtime predates the key" by the field's
1178    /// presence rather than by its value.
1179    ///
1180    /// `true` is the only manifest declaration that lowers [`Self::achieved_containment`], which is
1181    /// why it is reported next to the grants rather than buried: reading `achieved: advisory` on a
1182    /// Landlock-capable host makes sense only alongside it.
1183    pub workdir_exec: bool,
1184    /// `capabilities.filesystem.read_only`, verbatim — the workdir subtrees the capsule may read
1185    /// but must not write. Empty when the capsule declares none, which is the whole workdir
1186    /// writable. Always serialized as an array (never skipped), on the same terms as
1187    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the field, not a
1188    /// capsule that declared nothing.
1189    ///
1190    /// The one field here that subtracts rather than grants, which is why it is reported beside
1191    /// [`Self::read_only_advisory_for`] rather than alone: a protection stated without its
1192    /// qualification reads as a boundary the capsule does not have.
1193    pub read_only_paths: Vec<String>,
1194    /// The entries of [`Self::shell_allow`] that [`Self::read_only_paths`] is only advisory
1195    /// against — the same set `W-SEC-017` fires for, resolved by the same
1196    /// [`read_only_advisory_for`] both this report and `mur doctor` call.
1197    ///
1198    /// Empty means the declaration is enforced for every readable call. Non-empty names the
1199    /// binaries whose own file I/O the dispatch-time write-intent analyser cannot read, so the
1200    /// report cannot claim more than `W-SEC-017` already qualifies. Always serialized as an array
1201    /// (never skipped), on the same terms as [`Self::read_only_paths`], and always empty when
1202    /// that list is: nothing is advisory against a protection that was never declared.
1203    pub read_only_advisory_for: Vec<String>,
1204    /// The filesystem surface every guest-bearing artifact in this capsule is preopened into, one
1205    /// entry per `runtime: tool`, `runtime: driver` and `runtime: hook` entry, in manifest order.
1206    /// Empty when the capsule declares only skills. Always serialized as an array (never skipped),
1207    /// on the same terms as [`Self::state_stores`]: an absent key identifies a runtime that
1208    /// predates the field, not a capsule with no guest artifacts.
1209    ///
1210    /// This is the field that answers "what can this tool read and write?".
1211    /// [`Self::filesystem_scope`] above does not: it is the capsule-wide declaration, and it is
1212    /// joined into no preopen root.
1213    ///
1214    /// Copied through untouched, exactly as the reports beside it are — resolving a preopen
1215    /// changes no class, no floor and no tier.
1216    pub preopens: Vec<PreopenReport>,
1217    /// `capabilities.network.allow`, verbatim — declared destinations, not resolved IPs.
1218    pub network_allow: Vec<String>,
1219    /// `capabilities.network.unix_sockets`.
1220    pub unix_sockets: bool,
1221    /// `capabilities.shell.allow`, verbatim.
1222    pub shell_allow: Vec<String>,
1223    /// `capabilities.spawn.allow`, verbatim.
1224    pub spawn_allow: Vec<String>,
1225    /// `capabilities.spawn.workdir_snapshot`, verbatim: the entries of [`Self::spawn_allow`] whose
1226    /// every delegation starts in a copy of this session's work folder. Always serialized, `[]`
1227    /// when undeclared.
1228    pub spawn_workdir_snapshot: Vec<String>,
1229    /// What this session's work folder started with, when a delegating parent copied its own
1230    /// into it, and what keeps this session's commands off the parent's real folder on its tier:
1231    /// `null` for every session but such a child. Always serialized.
1232    ///
1233    /// A report of what the launching parent did, from `mur run --parent-workdir-snapshot`, never
1234    /// a grant: it opens nothing and changes no class, floor or tier. Its `restriction` is
1235    /// [`Self::filesystem_boundary`]'s, keyed on the same applied tier.
1236    pub parent_workdir: Option<crate::workdir_snapshot::ParentWorkdirReport>,
1237    /// The `context.images` limits this session applies, and whether `keep` applies at all. Set
1238    /// by the caller that knows the transport, through [`Self::with_images`]; `null` until then.
1239    /// Always serialized.
1240    pub images: Option<ImagesReport>,
1241    /// `capabilities.env.allow`, verbatim.
1242    pub env_allow: Vec<String>,
1243    /// `capabilities.plan.submit` — whether the model is offered the `submit-plan` tool. Always
1244    /// serialized (never skipped when `false`), on the same terms as [`Self::workdir_exec`]: an
1245    /// absent key identifies a runtime that predates the grant, not a capsule that declined it.
1246    ///
1247    /// It opens no host resource of its own. It is reported beside the grants because it changes
1248    /// *how much* of every other grant one model turn can reach: a plan runs many steps against
1249    /// this capsule's own tools, shell allowlist and spawn grant without a turn in between.
1250    pub plan_submit: bool,
1251    /// `capabilities.install.skill`, verbatim: which skill artifacts a pull (`manage.pull`,
1252    /// `pull-artifact`) may install into the session. Always serialized, an empty list included,
1253    /// on [`Self::plan_submit`]'s terms: an absent key identifies a runtime that predates the
1254    /// grant.
1255    ///
1256    /// It opens no host resource of its own. A pulled skill adds text to what the model reads and
1257    /// gains no grant.
1258    pub install_skill: Vec<String>,
1259    /// `capabilities.install.tool`, verbatim: which tool artifacts a pull may install. Always
1260    /// serialized, on [`Self::install_skill`]'s terms. A pulled tool joins the dispatch allowlist,
1261    /// runs on this capsule's own grants and gains none of its own.
1262    pub install_tool: Vec<String>,
1263    /// `capabilities.install.from`: `["stores"]`, or `["stores", "sources"]` when a pull the
1264    /// stores cannot answer is fetched from the scope's GitHub source. `[]` for a capsule with no
1265    /// install grant, which pulls from nowhere. Always serialized, on [`Self::install_skill`]'s
1266    /// terms.
1267    pub install_from: Vec<String>,
1268    /// Every host directory a `capabilities.shell.interpreter_runtime` grant opens, rendered as
1269    /// `<binary>: <dir>[ (list_dir)]`. These are the paths outside the workdir that stay
1270    /// reachable even at `scoped`.
1271    pub interpreter_runtime_grants: Vec<String>,
1272    /// Every host path staging derived from a recognised `shell.allow` interpreter's own import
1273    /// path, rendered `<binary>: <dir> (list_dir)` for a directory and `<binary>: <path>` for a zip
1274    /// archive or an extension-module library. Each is readable and none is executable. Always
1275    /// serialized, `[]` when no allowlisted binary is a recognised interpreter.
1276    pub derived_interpreter_runtime: Vec<String>,
1277    /// Every host trust-store path staging found for a capsule whose commands reach the network,
1278    /// rendered `<path> (list_dir)` for a directory and `<path>` for a CA bundle file. Each is
1279    /// readable and none is executable. Always serialized, `[]` when `shell.allow` or
1280    /// `network.allow` is empty or the host has none of the locations
1281    /// [`crate::trust_store`] checks.
1282    pub trust_store: Vec<String>,
1283    /// What the host programs the capsule's tools run need, one element per (artifact, program)
1284    /// with at least one need, as [`crate::declared_needs`] resolved it: each granted need with
1285    /// its paths, each missing one with why. Always serialized, `[]` when no tool pinned with an
1286    /// `operator` lock origin declares a need for a program `shell.allow` grants.
1287    pub declared_needs: Vec<DeclaredNeedsReport>,
1288    /// Which copy of each program a tool pins a download of runs, one element per (artifact,
1289    /// program) gated as [`Self::declared_needs`] and with a download for this host's platform,
1290    /// as [`crate::pinned_programs`] chose it. Always serialized, `[]` when none applies.
1291    pub pinned_programs: Vec<PinnedProgramReport>,
1292    /// The host files a command reads to resolve `localhost` and the other names `/etc/hosts`
1293    /// carries, granted read-only by the tier: `/etc/hosts` and `/etc/nsswitch.conf` on the
1294    /// Landlock tiers (`scoped`, `sealed`), `[]` on every other. Always serialized.
1295    pub name_resolution: Vec<String>,
1296    /// Every host folder `~/.murmur/config.yaml`'s `host_paths:` lends this session, in config
1297    /// order, with what the applied tier does with it (see [`crate::host_paths`]). Always
1298    /// serialized, `[]` with none. A grant from the operator's global config, never the manifest,
1299    /// and it changes no class, floor or tier.
1300    pub host_paths: Vec<crate::host_paths::HostPathReport>,
1301    /// The read-only file surface `exports.files` declares, or `null` when the capsule declares
1302    /// none. Always serialized (never skipped), so a consumer can tell "this runtime predates
1303    /// exports" from "this capsule declined to export anything" by the field's presence rather
1304    /// than by its value — the same terms [`Self::workdir_exec`] is reported on.
1305    ///
1306    /// An export is a *disclosure*, not a grant: it never appears in
1307    /// [`Self::achieved_containment`], and declaring one cannot change any other field of this
1308    /// report. It is printed beside the grants because it is the other direction of the same
1309    /// question — what crosses the capsule boundary, and which way.
1310    pub exports_files: Option<ExportsFilesReport>,
1311    /// The peer-handoff surface `exports.peer_files` declares, or `null` when the capsule
1312    /// declares none. Always serialized, on the same terms as [`Self::exports_files`].
1313    ///
1314    /// A separate authoriser from `exports.files`, over a separate subtree: a capsule may declare
1315    /// either, both or neither, and this field says nothing about the other. Like every export it
1316    /// is a disclosure rather than a grant, and declaring it cannot change
1317    /// [`Self::achieved_containment`], [`Self::enforcement_tier`] or [`Self::floor_met`].
1318    pub peer_files: Option<PeerFilesReport>,
1319    /// Whether the door serves tasks from peer capsules: `exports.peer_tasks.accept`, with an
1320    /// absent block reported as `false`. Always serialized.
1321    ///
1322    /// Like every export it opens a surface rather than granting the guest anything, and it cannot
1323    /// change [`Self::achieved_containment`], [`Self::enforcement_tier`] or [`Self::floor_met`].
1324    pub peer_tasks: bool,
1325    /// `capabilities.peer_fetch.allow`, verbatim — the peers this capsule may redeem a handle
1326    /// against. Empty when the block is absent, which is deny.
1327    ///
1328    /// A separate list from [`Self::network_allow`] and never an alias for it: a destination
1329    /// reachable by `send` is not redeemable unless it also appears here, because ingesting a
1330    /// peer's bytes into this workdir is a different question from being allowed to talk to it.
1331    pub peer_fetch_allow: Vec<String>,
1332    /// Every durable state store this session's artifacts declare, one entry per declaring
1333    /// artifact, empty when none does. Always serialized as an array (never skipped), on the same
1334    /// terms as [`Self::exports_files`]: an absent key identifies a runtime that predates state
1335    /// stores, not a capsule that declined one.
1336    ///
1337    /// Declaring a store cannot change [`Self::achieved_containment`], [`Self::floor_met`] or
1338    /// [`Self::enforcement_tier`] — it is copied through untouched, exactly as the export reports
1339    /// beside it are. It is reported because it is the one grant that opens a path *outside* every
1340    /// workdir, which nothing else in this block would reveal.
1341    pub state_stores: Vec<StateStoreReport>,
1342    /// Every artifact whose manifest entry declares a `config:` block, in manifest order, empty
1343    /// when none does. Always serialized as an array (never skipped), on the same terms as
1344    /// [`Self::state_stores`]: an absent key identifies a runtime that predates artifact config,
1345    /// not a capsule that declined it.
1346    ///
1347    /// Names only. What an artifact is configured *with* is operator-authored plaintext that
1348    /// belongs in the manifest and in nothing this report — or `trace.jsonl`, which carries this
1349    /// report verbatim — writes out.
1350    ///
1351    /// Declaring config cannot change [`Self::achieved_containment`], [`Self::floor_met`] or
1352    /// [`Self::enforcement_tier`]: it adds one environment variable to one guest and reaches no
1353    /// grant. Reported because a variable an artifact silently depends on is otherwise invisible
1354    /// to an operator reading the capsule's scope.
1355    pub configured_artifacts: Vec<String>,
1356    /// Every `capabilities.shell.staged_runtime` grant, rendered as
1357    /// `<binary>: <source_path> (pin: <pin>)`. These are host runtime trees a `sealed` capsule
1358    /// asks to have bind-mounted read-only into its composed root.
1359    ///
1360    /// Reported whatever the floor and whatever this host can back — a capsule that declares a
1361    /// staged runtime and cannot run here is exactly the case an operator is inspecting when they
1362    /// reach for `--explain-scope`, so hiding the grant behind a met floor would blank out the
1363    /// diagnostic in the one case it is for.
1364    pub staged_runtime_grants: Vec<String>,
1365    /// Every path the runtime itself writes inside the capsule's workdir, so a consumer for whom
1366    /// that workdir is the deliverable can subtract them and be left with what the *capsule*
1367    /// changed. Always serialized as an array (never skipped), on the same terms as
1368    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the declaration,
1369    /// not a runtime that writes nothing.
1370    ///
1371    /// The one field here that describes what the runtime does rather than what the capsule may
1372    /// do. It is reported beside the grants because it answers the other half of "what is this
1373    /// workdir", and because a consumer that has to maintain its own list of runtime entries
1374    /// silently falls behind every time the set changes — enabling `sealed` being exactly that
1375    /// case.
1376    ///
1377    /// The same set whatever [`Self::enforcement_tier`] and [`Self::achieved_containment`] say:
1378    /// no row turns on the tier. See [`crate::workdir_writes`].
1379    pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1380    /// Every path the manifest names a file by — `inference.system_prompt_file`,
1381    /// `inference.compaction.system_prompt_file`, each local-source `artifacts[].source` — and
1382    /// where it is read from: the manifest folder, the installed package, or the host path itself.
1383    /// Always serialized as an array (never skipped), on the same terms as
1384    /// [`Self::state_stores`]; `[]` when the manifest names no file.
1385    ///
1386    /// Reports where a path resolves, not whether the file is there: nothing is read to build it.
1387    /// Changes no class and no floor. Set through [`Self::with_manifest_files`] from
1388    /// [`crate::manifest_files::manifest_file_reports`].
1389    pub manifest_files: Vec<crate::manifest_files::ManifestFileReport>,
1390    /// The declared `capabilities.resources.cgroup_io_bytes_per_sec` ceiling, and whether it is
1391    /// actually on this session's cgroup scope.
1392    ///
1393    /// Always serialized (never skipped), on the same terms as [`Self::workdir_exec`] and
1394    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the field, not a
1395    /// host that was not asked. [`IoMaxStatus::NotProbed`] is what "not asked" looks like, and it
1396    /// is a value in the report rather than a missing key.
1397    ///
1398    /// The one field here filled from a *performed* operation rather than from the policy: at
1399    /// launch it carries the outcome of the real `io.max` write, established before `session_start`
1400    /// is written so `trace.jsonl` cannot claim a ceiling the kernel refused; under
1401    /// `--explain-scope` it carries the outcome of the same write against a throwaway cgroup that
1402    /// is created and removed. Declaring a ceiling that does not apply changes no class and refuses
1403    /// no launch — `io.max` is the one non-fatal cgroup limit — so this field is the only place
1404    /// the shortfall appears.
1405    pub io_max: IoMaxReport,
1406    /// Whether this session's subprocesses get a delegated cgroup v2 scope.
1407    ///
1408    /// Always serialized. At launch it is `delegated` or `not-required`, from
1409    /// [`crate::cgroup::prepare_scope`]'s real outcome: a launch that cannot get a scope it needs
1410    /// is refused with `E-RUN-012` and writes no `session_start`. Under `--explain-scope` it is
1411    /// [`crate::cgroup::probe_scope`]'s answer, and `unavailable` there is that refusal predicted,
1412    /// with the reason it would print. Changes no class and no floor: a refused capsule runs under
1413    /// no class at all. Set through [`Self::with_cgroup_scope`]; `not-probed` until then.
1414    pub cgroup_scope: CgroupScopeReport,
1415    /// What this session's filesystem mechanism does not restrict — the block `--explain-scope`
1416    /// prints as `Not protected here`, directly under `Containment`.
1417    ///
1418    /// Always serialized (never skipped), on the same terms as [`Self::state_stores`]: an absent
1419    /// key identifies a runtime that predates the field, and an empty
1420    /// [`FilesystemBoundaryReport::not_protected`] identifies a session with nothing to disclaim.
1421    ///
1422    /// Derived in [`filesystem_boundary`] from the tier [`Self::enforcement_tier`] names, and
1423    /// from no [`CapabilityPolicy`] field: `workdir_exec` caps [`Self::achieved_containment`]
1424    /// without changing what the mechanism restricts. It stands beside the grants rather than
1425    /// among them because it qualifies every one at once.
1426    pub filesystem_boundary: FilesystemBoundaryReport,
1427    /// What the session's runtime directory is to its commands, from [`runtime_dir_report`] on
1428    /// the applied tier, [`Self::landlock_abi`] and where the directory lies. Always serialized.
1429    /// When it is not read-only, its reason is also a [`FilesystemBoundaryReport::not_protected`]
1430    /// statement.
1431    pub runtime_dir: RuntimeDirReport,
1432    /// What the session's records directory is to its commands, from [`records_dir_report`] on
1433    /// the same inputs. Always serialized; its reason, when set, is also a
1434    /// [`FilesystemBoundaryReport::not_protected`] statement.
1435    pub records_dir: RuntimeDirReport,
1436    /// The session's temporary folder and whether a literal `/tmp` reaches it, from
1437    /// [`temp_dir_report`] on the session's [`crate::temp_dir::temp_mapping`] answer — the one
1438    /// its enforcement installs. Always serialized.
1439    pub temp_dir: TempDirReport,
1440    /// Which `/proc` the session's commands see, from [`crate::proc_view::process_view_report`]
1441    /// on the view the session's probe decided ([`Self::with_process_view`]) — the one its
1442    /// enforcement installs. Always serialized.
1443    pub process_view: crate::proc_view::ProcessViewReport,
1444    /// Who the capsule's subprocesses run as when the session is unconfined; `null` on every
1445    /// contained session. Always serialized.
1446    pub unconfined: Option<UnconfinedReport>,
1447    /// What a `transport: process` capsule's harness needs beyond its class, and where the manifest
1448    /// allows it: the driver, the harness binary and interpreter, and each path, host and env need
1449    /// with its status. `null` on every capsule whose transport is not `process`. Always
1450    /// serialized. Set through [`Self::with_harness`] by the caller that resolved the driver's
1451    /// needs against the manifest; `session_start.effective_grants.harness` carries it byte for
1452    /// byte, and `--explain-scope` computes it the same way from the installed driver.
1453    pub harness: Option<crate::harness_needs::HarnessReport>,
1454}
1455
1456/// What an unconfined session's subprocesses run as: `ScopeReport::unconfined`.
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1458pub struct UnconfinedReport {
1459    /// The account name of the uid `mur` runs as, `null` when the account database has none.
1460    pub user: Option<String>,
1461    /// The uid `mur` runs as, which every unconfined subprocess runs as.
1462    pub uid: u32,
1463    /// The `HOME` `mur run` was started with, which every unconfined subprocess sees; `null` when
1464    /// it had none.
1465    pub home: Option<String>,
1466    /// The subprocess routes that run unconfined, as stable wire names.
1467    pub applies_to: Vec<&'static str>,
1468    /// The `capabilities.resources` fields the manifest declares, the only limits an unconfined
1469    /// session applies.
1470    pub resource_limits: Vec<&'static str>,
1471}
1472
1473/// The subprocess routes an unconfined session runs as the operator, as
1474/// [`UnconfinedReport::applies_to`] names them.
1475pub const UNCONFINED_APPLIES_TO: &[&str] = &[
1476    "shell",
1477    "plan-shell-step",
1478    "process-harness",
1479    "harness-tools",
1480];
1481
1482impl UnconfinedReport {
1483    /// The report for this process: its uid, that uid's account name, its `HOME`, and the fields
1484    /// `declared` names.
1485    pub(crate) fn for_this_process(declared: &murmur_artifact::ResourceCapabilities) -> Self {
1486        let uid = current_uid();
1487        Self {
1488            user: account_name(uid),
1489            uid,
1490            home: std::env::var_os("HOME").map(|home| home.to_string_lossy().into_owned()),
1491            applies_to: UNCONFINED_APPLIES_TO.to_vec(),
1492            resource_limits: crate::resources::declared_resource_fields(declared),
1493        }
1494    }
1495
1496    /// The `Unconfined` block of `--explain-scope`. Ends with a newline.
1497    #[must_use]
1498    pub fn render(&self) -> String {
1499        let applies_to = self
1500            .applies_to
1501            .iter()
1502            .map(|route| match *route {
1503                "shell" => "shell commands",
1504                "plan-shell-step" => "plan shell steps",
1505                "process-harness" => "process harness",
1506                "harness-tools" => "harness tools",
1507                other => other,
1508            })
1509            .collect::<Vec<_>>()
1510            .join(", ");
1511        let limits = if self.resource_limits.is_empty() {
1512            "none declared".to_string()
1513        } else {
1514            self.resource_limits.join(", ")
1515        };
1516        format!(
1517            "Unconfined\n  user:      {}\n  uid:       {}\n  HOME:      {}\n  applies to: \
1518             {applies_to}\n  resource limits: {limits}\n",
1519            self.user.as_deref().unwrap_or("<unknown>"),
1520            self.uid,
1521            self.home.as_deref().unwrap_or("<unset>"),
1522        )
1523    }
1524}
1525
1526/// The `W-SEC-036` message for an unconfined session running as `report` says.
1527pub(crate) fn unconfined_warning(report: &UnconfinedReport) -> String {
1528    let user = match &report.user {
1529        Some(user) => format!("{user} (uid {})", report.uid),
1530        None => format!("uid {}", report.uid),
1531    };
1532    let home = report.home.as_deref().unwrap_or("<unset>");
1533    format!(
1534        "this capsule runs unconfined: its shell commands, plan shell steps and process harness \
1535         run as {user} with HOME {home}, the whole environment mur run was \
1536         started with (credential variables included), the host filesystem and network as that \
1537         account has them, and no no_new_privs, so sudo works wherever that account's sudoers \
1538         allows it"
1539    )
1540}
1541
1542/// Prints `W-SEC-036` once, on stderr and in the bootstrap log. Called by `launch_session` after
1543/// staging succeeds, never by `--explain-scope`.
1544pub(crate) fn warn_unconfined_launch(
1545    records_root: &crate::workdir_fs::WorkdirRoot,
1546    report: &UnconfinedReport,
1547) {
1548    let code = murmur_artifact::security_warnings::W_SEC_036;
1549    let message = unconfined_warning(report);
1550    let link = murmur_artifact::security_warning_link(code);
1551    crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
1552    crate::agent::append_bootstrap_log(
1553        records_root,
1554        &format!("[capability-policy] warning[{code}]: {message} ({link})"),
1555    );
1556}
1557
1558/// The uid this process runs as.
1559#[cfg(unix)]
1560pub(crate) fn current_uid() -> u32 {
1561    rustix::process::getuid().as_raw()
1562}
1563
1564#[cfg(not(unix))]
1565pub(crate) fn current_uid() -> u32 {
1566    0
1567}
1568
1569/// The account name the account database holds for `uid`, `None` when it holds none.
1570#[cfg(unix)]
1571#[allow(unsafe_code)]
1572pub(crate) fn account_name(uid: u32) -> Option<String> {
1573    let mut buffer = vec![0 as libc::c_char; 16 * 1024];
1574    // SAFETY: an all-zero `passwd` is a valid value of a plain C struct of pointers and integers;
1575    // `getpwuid_r` overwrites it before it is read.
1576    let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
1577    let mut result: *mut libc::passwd = std::ptr::null_mut();
1578    // SAFETY: every pointer is to a live local that outlives the call, and `buffer.len()` is the
1579    // buffer's real length, so `getpwuid_r` writes only inside it.
1580    let rc = unsafe {
1581        libc::getpwuid_r(
1582            uid,
1583            &mut entry,
1584            buffer.as_mut_ptr(),
1585            buffer.len(),
1586            &mut result,
1587        )
1588    };
1589    if rc != 0 || result.is_null() || entry.pw_name.is_null() {
1590        return None;
1591    }
1592    // SAFETY: on success `pw_name` points at a NUL-terminated string inside `buffer`, which is
1593    // still alive.
1594    let name = unsafe { std::ffi::CStr::from_ptr(entry.pw_name) };
1595    Some(name.to_string_lossy().into_owned())
1596}
1597
1598#[cfg(not(unix))]
1599pub(crate) fn account_name(_uid: u32) -> Option<String> {
1600    None
1601}
1602
1603impl ScopeReport {
1604    /// Human-readable rendering used by `mur run --explain-scope` without `--json`.
1605    pub fn render(&self) -> String {
1606        let mut out = String::new();
1607        out.push_str("Containment\n");
1608        out.push_str(&format!("  declared:  {}\n", self.declared_containment));
1609        out.push_str(&format!("  achieved:  {}\n", self.achieved_containment));
1610        out.push_str(&format!("  host can back: {}\n", self.host_containment));
1611        out.push_str(&format!(
1612            "  floor met: {}\n",
1613            if self.floor_met { "yes" } else { "no" }
1614        ));
1615        if let Some(reason) = &self.shortfall_reason {
1616            out.push_str(&format!("  reason:    {reason}\n"));
1617        }
1618        out.push_str(&format!("  mechanism: {}\n", self.enforcement_tier));
1619        out.push_str(&format!(
1620            "  userns grant: {}\n",
1621            match self.userns_grant {
1622                Some(grant) => grant.wire_name(),
1623                None => "n/a",
1624            }
1625        ));
1626        out.push_str(&format!(
1627            "  landlock ABI: {}\n",
1628            match self.landlock_abi {
1629                Some(abi) => abi.to_string(),
1630                None => "n/a".to_string(),
1631            }
1632        ));
1633        out.push_str(&format!(
1634            "  {}\n",
1635            crate::cgroup::render_cgroup_scope(&self.cgroup_scope)
1636        ));
1637
1638        out.push('\n');
1639        out.push_str(&render_routes(&self.routes));
1640
1641        if let Some(harness) = &self.harness {
1642            out.push('\n');
1643            out.push_str(&crate::harness_needs::render_harness_block(harness));
1644        }
1645
1646        if let Some(unconfined) = &self.unconfined {
1647            out.push('\n');
1648            out.push_str(&unconfined.render());
1649        }
1650
1651        out.push('\n');
1652        out.push_str(&render_runtime_dir(&self.runtime_dir));
1653        out.push('\n');
1654        out.push_str(&render_records_dir(&self.records_dir));
1655        out.push('\n');
1656        out.push_str(&render_temp_dir(&self.temp_dir));
1657        out.push('\n');
1658        out.push_str(&crate::proc_view::render_process_view(&self.process_view));
1659
1660        out.push_str(&render_not_protected(&self.filesystem_boundary));
1661
1662        out.push_str("\nEffective grants\n");
1663        out.push_str(&format!(
1664            "  filesystem scope: {}\n",
1665            self.filesystem_scope.as_deref().unwrap_or("<none>")
1666        ));
1667        out.push_str(&format!("  workdir exec:     {}\n", self.workdir_exec));
1668        out.push_str(&render_read_only(
1669            &self.read_only_paths,
1670            &self.read_only_advisory_for,
1671        ));
1672        push_list(
1673            &mut out,
1674            "preopens",
1675            &self
1676                .preopens
1677                .iter()
1678                .map(PreopenReport::render)
1679                .collect::<Vec<_>>(),
1680        );
1681        push_list(&mut out, "network allow", &self.network_allow);
1682        out.push_str(&format!("  unix sockets:     {}\n", self.unix_sockets));
1683        push_list(&mut out, "shell allow", &self.shell_allow);
1684        push_list(&mut out, "spawn allow", &self.spawn_allow);
1685        push_list(
1686            &mut out,
1687            "spawn workdir snapshot",
1688            &self.spawn_workdir_snapshot,
1689        );
1690        push_list(&mut out, "env allow", &self.env_allow);
1691        out.push_str(&format!("  plan submit:      {}\n", self.plan_submit));
1692        out.push_str(&render_install(
1693            &self.install_skill,
1694            &self.install_tool,
1695            &self.install_from,
1696        ));
1697        push_list(
1698            &mut out,
1699            "interpreter runtime",
1700            &self.interpreter_runtime_grants,
1701        );
1702        push_list(
1703            &mut out,
1704            "derived interpreter runtime",
1705            &self.derived_interpreter_runtime,
1706        );
1707        push_list(&mut out, "trust store", &self.trust_store);
1708        push_list(
1709            &mut out,
1710            "declared needs",
1711            &self
1712                .declared_needs
1713                .iter()
1714                .flat_map(DeclaredNeedsReport::render_lines)
1715                .collect::<Vec<_>>(),
1716        );
1717        push_list(
1718            &mut out,
1719            "pinned programs",
1720            &self
1721                .pinned_programs
1722                .iter()
1723                .map(PinnedProgramReport::render_line)
1724                .collect::<Vec<_>>(),
1725        );
1726        push_list(&mut out, "name resolution", &self.name_resolution);
1727        push_list(
1728            &mut out,
1729            "host paths",
1730            &self
1731                .host_paths
1732                .iter()
1733                .map(crate::host_paths::HostPathReport::render)
1734                .collect::<Vec<_>>(),
1735        );
1736        push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1737        push_list(
1738            &mut out,
1739            "state stores",
1740            &self
1741                .state_stores
1742                .iter()
1743                .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1744                .collect::<Vec<_>>(),
1745        );
1746        push_list(&mut out, "artifact config", &self.configured_artifacts);
1747
1748        if let Some(parent) = &self.parent_workdir {
1749            out.push_str(&render_parent_workdir(parent));
1750        }
1751
1752        if let Some(images) = &self.images {
1753            out.push_str(&render_images(images));
1754        }
1755
1756        out.push_str("\nResource plane\n");
1757        match &self.exports_files {
1758            None => out.push_str("  exports.files: <none>\n"),
1759            Some(export) => {
1760                out.push_str(&format!("  exports.files root: {}\n", export.root));
1761                out.push_str(&format!("  mode:               {}\n", export.mode));
1762                out.push_str(&format!(
1763                    "  max bytes:          {} (per file)\n",
1764                    export.max_bytes
1765                ));
1766            }
1767        }
1768
1769        out.push_str(&format!(
1770            "  io.max:        {} ({} bytes/s declared)\n",
1771            match self.io_max.status {
1772                IoMaxStatus::Enforced => "enforced",
1773                IoMaxStatus::Unavailable => "unavailable",
1774                IoMaxStatus::NotRequired => "not-required",
1775                IoMaxStatus::NotProbed => "not-probed",
1776            },
1777            self.io_max.declared_bytes_per_sec
1778        ));
1779        if let Some(reason) = &self.io_max.reason {
1780            out.push_str(&format!("    {reason}\n"));
1781        }
1782
1783        out.push_str("\nPeer handoff\n");
1784        match &self.peer_files {
1785            None => out.push_str("  exports.peer_files: <none>\n"),
1786            Some(peer) => {
1787                out.push_str(&format!("  exports.peer_files root: {}\n", peer.root));
1788                out.push_str(&format!(
1789                    "  max handle ttl:          {}s\n",
1790                    peer.max_ttl_secs
1791                ));
1792                out.push_str(&format!(
1793                    "  max bytes:               {} (per file)\n",
1794                    peer.max_bytes
1795                ));
1796            }
1797        }
1798        out.push_str(if self.peer_tasks {
1799            "  exports.peer_tasks: accept\n"
1800        } else {
1801            "  exports.peer_tasks: refuse\n"
1802        });
1803        push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1804
1805        out.push_str("\nRuntime writes\n");
1806        out.push_str(&crate::workdir_writes::render_runtime_writes(
1807            &self.runtime_writes,
1808        ));
1809        out.push_str(&crate::manifest_files::render_manifest_files(
1810            &self.manifest_files,
1811        ));
1812
1813        if !self.floor_met {
1814            out.push_str(
1815                "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1816                 launch here.\n",
1817            );
1818        } else if self.cgroup_scope.status == CgroupScopeStatus::Unavailable {
1819            out.push_str(
1820                "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1821                 launch here with E-RUN-012: this capsule can run a subprocess and no \
1822                 cgroup scope can be created to bound it (see `cgroup scope:` above).\n",
1823            );
1824        }
1825        out
1826    }
1827}
1828
1829/// The `Images` block of the scope report: the limits on images a tool returns or an A2A message
1830/// carries.
1831#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1832pub struct ImagesReport {
1833    /// `context.images.keep`: the newest images the driver receives as bytes.
1834    pub keep: u32,
1835    /// `context.images.max_bytes`: the largest image accepted.
1836    pub max_bytes: u64,
1837    /// `context.images.max_per_turn`: images accepted per model turn, or per tool call on
1838    /// `inference.transport: process`.
1839    pub max_per_turn: u32,
1840    /// Whether `keep` does anything: `false` on `inference.transport: process`, whose harness
1841    /// holds the conversation, and where `max_per_turn` counts the images of one tool call.
1842    pub keep_applies: bool,
1843}
1844
1845impl ScopeReport {
1846    /// This report with [`Self::images`] set from `images`, for a capsule on `transport`; `None`
1847    /// is a capsule with no `inference:` block.
1848    #[must_use]
1849    pub fn with_images(
1850        mut self,
1851        images: murmur_artifact::ContextImagesConfig,
1852        transport: Option<&str>,
1853    ) -> Self {
1854        self.images = Some(ImagesReport {
1855            keep: images.keep,
1856            max_bytes: images.max_bytes,
1857            max_per_turn: images.max_per_turn,
1858            keep_applies: transport != Some("process"),
1859        });
1860        self
1861    }
1862
1863    /// This report with [`Self::routes`] built again by [`route_table`], now that the caller
1864    /// knows the inference `transport` (`None` for a capsule with no `inference:` block).
1865    ///
1866    /// Reads [`Self::preopens`] for the capsule's tool, driver and hook entries, and the grants
1867    /// beside them, so the table and the rest of the report describe one capsule.
1868    #[must_use]
1869    pub fn with_routes(mut self, transport: Option<&str>) -> Self {
1870        let inputs = RouteInputs::new(
1871            &self.shell_allow,
1872            self.plan_submit,
1873            &self.install_tool,
1874            &self.preopens,
1875            transport,
1876        );
1877        self.routes = route_table(&inputs, self.enforcement_tier);
1878        self
1879    }
1880
1881    /// This report with [`Self::harness`] set to `report`, the process harness's resolved needs.
1882    /// `None` leaves it `null`, for a capsule whose transport is not `process`.
1883    #[must_use]
1884    pub fn with_harness(mut self, report: Option<crate::harness_needs::HarnessReport>) -> Self {
1885        self.harness = report;
1886        self
1887    }
1888
1889    /// This report with [`Self::declared_needs`] built from `needs`: what staging derives after
1890    /// it has read every tool's manifest, which is after the report is first built.
1891    #[must_use]
1892    pub fn with_declared_needs(
1893        mut self,
1894        needs: &[crate::declared_needs::ResolvedProgramNeeds],
1895    ) -> Self {
1896        self.declared_needs = needs.iter().map(DeclaredNeedsReport::from).collect();
1897        self
1898    }
1899
1900    /// This report with [`Self::pinned_programs`] built from `programs`, staging's source choice
1901    /// for each pinned program download.
1902    #[must_use]
1903    pub fn with_pinned_programs(
1904        mut self,
1905        programs: &[crate::pinned_programs::ResolvedPinnedProgram],
1906    ) -> Self {
1907        self.pinned_programs = programs.iter().map(PinnedProgramReport::from).collect();
1908        self
1909    }
1910
1911    /// This report with [`Self::manifest_files`] set to `rows`.
1912    #[must_use]
1913    pub fn with_manifest_files(
1914        mut self,
1915        rows: Vec<crate::manifest_files::ManifestFileReport>,
1916    ) -> Self {
1917        self.manifest_files = rows;
1918        self
1919    }
1920
1921    /// This report with [`Self::process_view`] stating `view`, the `/proc` the session's probe
1922    /// decided its commands see.
1923    #[must_use]
1924    pub fn with_process_view(mut self, view: &crate::proc_view::ProcView) -> Self {
1925        self.process_view = crate::proc_view::process_view_report(view);
1926        self
1927    }
1928
1929    /// This report with [`Self::cgroup_scope`] set from `cgroup_scope`.
1930    #[must_use]
1931    pub fn with_cgroup_scope(mut self, cgroup_scope: CgroupScopeReport) -> Self {
1932        self.cgroup_scope = cgroup_scope;
1933        self
1934    }
1935
1936    /// This report with [`Self::parent_workdir`] set from `snapshot`, the restriction read off
1937    /// [`Self::filesystem_boundary`] so the two cannot disagree about one tier. `None` leaves it
1938    /// `null`.
1939    #[must_use]
1940    pub fn with_parent_workdir(
1941        mut self,
1942        snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
1943    ) -> Self {
1944        let restriction = self.filesystem_boundary.restriction;
1945        self.parent_workdir =
1946            snapshot.map(|snapshot| crate::workdir_snapshot::ParentWorkdirReport {
1947                snapshot,
1948                restriction,
1949            });
1950        self
1951    }
1952}
1953
1954/// The `Images` block of `mur run --explain-scope`. Starts with a blank line and ends with a newline.
1955#[must_use]
1956pub fn render_images(images: &ImagesReport) -> String {
1957    let mut out = String::from("\nImages\n");
1958    out.push_str(&format!("  max_bytes:     {}\n", images.max_bytes));
1959    if images.keep_applies {
1960        out.push_str(&format!("  keep:          {}\n", images.keep));
1961        out.push_str(&format!("  max_per_turn:  {}\n", images.max_per_turn));
1962    } else {
1963        out.push_str(&format!(
1964            "  keep:          {} (does nothing here: the harness holds the conversation)\n",
1965            images.keep
1966        ));
1967        out.push_str(&format!(
1968            "  max_per_turn:  {} (counts the images of one tool call here)\n",
1969            images.max_per_turn
1970        ));
1971    }
1972    out
1973}
1974
1975/// The `Parent work folder` block of `mur run --explain-scope`, for a delegated child whose work
1976/// folder started as a copy of its parent's. Starts with a blank line and ends with a newline.
1977#[must_use]
1978pub fn render_parent_workdir(parent: &crate::workdir_snapshot::ParentWorkdirReport) -> String {
1979    let snapshot = &parent.snapshot;
1980    let mut out = String::from("\nParent work folder\n");
1981    out.push_str(&format!("  source:      {}\n", snapshot.source));
1982    out.push_str(&format!(
1983        "  copied:      {} files, {} directories, {} symlinks ({} re-pointed), {} bytes\n",
1984        snapshot.files,
1985        snapshot.dirs,
1986        snapshot.symlinks,
1987        snapshot.repointed_symlinks,
1988        snapshot.bytes
1989    ));
1990    if snapshot.skipped_count == 0 {
1991        out.push_str("  skipped:     <none>\n");
1992    } else {
1993        out.push_str(&format!("  skipped:     {}\n", snapshot.skipped_count));
1994        for entry in &snapshot.skipped {
1995            let reason = serde_json::to_value(entry.reason)
1996                .ok()
1997                .and_then(|value| value.as_str().map(str::to_string))
1998                .unwrap_or_default();
1999            out.push_str(&format!("    - {} ({reason})\n", entry.path));
2000        }
2001    }
2002    out.push_str(&format!(
2003        "  restriction: {}\n",
2004        parent.restriction_sentence()
2005    ));
2006    out
2007}
2008
2009/// The allowlisted binaries a declared `capabilities.filesystem.read_only` is only advisory
2010/// against: those whose own file I/O the dispatch-time write-intent analyser cannot read, in
2011/// `shell_allow` order.
2012///
2013/// Exactly the set `W-SEC-017` fires one line for, resolved here so `--explain-scope`, `trace.jsonl`
2014/// and `mur doctor` cannot disagree with the warning printed beside them. Empty when nothing is
2015/// declared read-only: an interpreter is not advisory against a protection that does not exist.
2016#[must_use]
2017pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
2018    if read_only_paths.is_empty() {
2019        return Vec::new();
2020    }
2021    shell_allow
2022        .iter()
2023        .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
2024        .cloned()
2025        .collect()
2026}
2027
2028/// The `read_only` block of the scope report: the declared paths, then one line saying whether the
2029/// protection is enforced for every readable call or advisory against a named binary.
2030///
2031/// Shared by [`ScopeReport::render`] and by `mur doctor` so the two cannot word the same grant
2032/// differently — the precedent [`PreopenReport::render`] sets. Ends with a newline; emits nothing
2033/// beyond the `<none>` line when no path is declared, because there is no enforcement question to
2034/// answer about a protection nobody asked for.
2035#[must_use]
2036pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
2037    let mut out = String::new();
2038    push_list(&mut out, "read_only", read_only_paths);
2039    if read_only_paths.is_empty() {
2040        return out;
2041    }
2042    if advisory_for.is_empty() {
2043        out.push_str(
2044            "  read_only enforcement: enforced for every tool call and every shell command the \
2045             dispatch check can read\n",
2046        );
2047    } else {
2048        out.push_str(&format!(
2049            "  read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
2050             something the dispatch check can read; enforced for every tool call and for every \
2051             shell command it can read\n",
2052            advisory_for.join(", ")
2053        ));
2054    }
2055    out
2056}
2057
2058/// The `install skill`, `install tool` and `install from` lines of the scope report: which
2059/// skills and tools a pull may install, `<none>` for a list the capsule left empty, and the
2060/// places it may install them from (`install from: stores` or `install from: stores, sources`),
2061/// printed only for a capsule holding an install grant.
2062///
2063/// Shared by [`ScopeReport::render`] and by `mur doctor`, on [`render_read_only`]'s terms. Ends
2064/// with a newline.
2065#[must_use]
2066pub fn render_install(
2067    install_skill: &[String],
2068    install_tool: &[String],
2069    install_from: &[String],
2070) -> String {
2071    let mut out = String::new();
2072    push_list(&mut out, "install skill", install_skill);
2073    push_list(&mut out, "install tool", install_tool);
2074    if !install_from.is_empty() {
2075        out.push_str(&format!("  install from: {}\n", install_from.join(", ")));
2076    }
2077    out
2078}
2079
2080/// [`ScopeReport::install_from`] for `policy`: empty without an install grant.
2081#[must_use]
2082pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
2083    if !crate::install_grant::is_granted(policy) {
2084        return Vec::new();
2085    }
2086    let mut places = vec!["stores".to_string()];
2087    if policy.install_from_sources {
2088        places.push("sources".to_string());
2089    }
2090    places
2091}
2092
2093/// The `Not protected here` block: one line per statement in
2094/// [`FilesystemBoundaryReport::not_protected`], and nothing at all when that list is empty.
2095///
2096/// A heading with no entries under it would read as a disclaimer the reader had to interpret, so a
2097/// composed root prints no heading rather than an empty one. Every line is a statement from the report
2098/// verbatim, so the human rendering and `--explain-scope --json` say the same thing in the same
2099/// words.
2100fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
2101    if boundary.not_protected.is_empty() {
2102        return String::new();
2103    }
2104    let mut out = String::from("\nNot protected here\n");
2105    for statement in &boundary.not_protected {
2106        out.push_str(&format!("  - {statement}\n"));
2107    }
2108    out
2109}
2110
2111fn push_list(out: &mut String, label: &str, values: &[String]) {
2112    if values.is_empty() {
2113        out.push_str(&format!("  {label}: <none>\n"));
2114        return;
2115    }
2116    out.push_str(&format!("  {label}:\n"));
2117    for value in values {
2118        out.push_str(&format!("    - {value}\n"));
2119    }
2120}
2121
2122/// Builds a [`ScopeReport`] for `policy` against this host, with `declared` as the already-
2123/// combined floor. Takes one [`crate::sandbox::HostProbe`], so the tier, the shortfall and the
2124/// grant it reports all describe one host at one moment.
2125///
2126/// `state_stores`, `configured_artifacts` and `preopens` are resolved by the caller (from the
2127/// manifest's artifact entries, via [`crate::state_store::state_store_reports`],
2128/// [`crate::artifact_config::configured_artifact_names`] and
2129/// [`crate::network_policy::preopen_reports`]) rather than derived here, because all three are
2130/// per-artifact and `policy` is capsule-wide. No resolution creates anything, so this stays a
2131/// read-only diagnostic.
2132///
2133/// `workdir` is the workdir root the launch would use — `--workdir`, or `<manifest dir>/workdir`
2134/// without one — which names the records group and decides whether the runtime or records
2135/// directory lies inside it. Nothing is created to answer that; a `HOME` that cannot hold either
2136/// is refused, as the launch would be.
2137#[allow(clippy::too_many_arguments)]
2138pub fn explain_scope(
2139    policy: &CapabilityPolicy,
2140    declared: ContainmentClass,
2141    exports: Option<&Exports>,
2142    state_stores: Vec<StateStoreReport>,
2143    configured_artifacts: Vec<String>,
2144    preopens: Vec<PreopenReport>,
2145    io_max: IoMaxReport,
2146    workdir: &std::path::Path,
2147) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
2148    let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
2149    let probe = HostProbe::probe();
2150    let decision = ContainmentDecision::decide(
2151        probe.tier(),
2152        declared,
2153        policy.workdir_exec_allowed,
2154        placement.inside_workdir(),
2155    );
2156    let temp_mapping = crate::temp_dir::temp_mapping(
2157        decision.applied_tier(),
2158        probe.mount_namespace_usable(),
2159        &placement.workdir,
2160    );
2161    Ok(scope_report_for_tier(
2162        policy,
2163        &decision,
2164        probe.sealed_shortfall().as_ref(),
2165        probe.userns_grant(),
2166        probe.landlock_kernel_abi(),
2167        exports,
2168        state_stores,
2169        configured_artifacts,
2170        preopens,
2171        &placement,
2172        temp_mapping,
2173        io_max,
2174    )
2175    .with_process_view(&crate::proc_view::proc_view(
2176        decision.applied_tier(),
2177        probe.namespaces(),
2178    )))
2179}
2180
2181/// `mur doctor`'s `Runtime directory`, `Records directory` and `Temporary folder` sections for a
2182/// capsule declaring `declared`, run from `workdir`: the same [`runtime_dir_report`],
2183/// [`records_dir_report`] and [`temp_dir_report`] on the tier `probe`'s [`ContainmentDecision`]
2184/// applies, its ruleset ABI and mount namespace, or why `HOME` cannot hold them.
2185///
2186/// `probe` is the reading the rest of the doctor run reports from, so every section describes one
2187/// host at one moment.
2188pub fn doctor_runtime_dir(
2189    probe: &HostProbe,
2190    declared: ContainmentClass,
2191    workdir: &std::path::Path,
2192) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
2193    let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
2194    // `workdir_exec` and the placement cap the class, never the tier, and only the tier is read.
2195    let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
2196    let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
2197    let temp_mapping =
2198        crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
2199    Ok(DoctorDirReports {
2200        runtime_dir: runtime_dir_report(applied, abi, &placement),
2201        records_dir: records_dir_report(applied, abi, &placement),
2202        temp_dir: temp_dir_report(temp_mapping, &placement),
2203        process_view: crate::proc_view::process_view_report(&crate::proc_view::proc_view(
2204            applied,
2205            probe.namespaces(),
2206        )),
2207        placement,
2208    })
2209}
2210
2211/// What [`doctor_runtime_dir`] reports.
2212#[derive(Debug, Clone, PartialEq, Eq)]
2213pub struct DoctorDirReports {
2214    pub runtime_dir: RuntimeDirReport,
2215    pub records_dir: RuntimeDirReport,
2216    pub temp_dir: TempDirReport,
2217    pub process_view: crate::proc_view::ProcessViewReport,
2218    pub placement: RuntimeDirPlacement,
2219}
2220
2221/// [`explain_scope`] with the [`ContainmentDecision`] and the sealed shortfall injected — the
2222/// seam `runtime::stage_session` builds the session's report through, and every test, through
2223/// [`report_for`], so no test depends on the host it happens to run on.
2224///
2225/// `decision` must have been decided with `policy.workdir_exec_allowed` and
2226/// `runtime_dir_placement.inside_workdir()`: the report states both beside the class they cap.
2227#[allow(clippy::too_many_arguments)]
2228pub(crate) fn scope_report_for_tier(
2229    policy: &CapabilityPolicy,
2230    decision: &ContainmentDecision,
2231    sealed_shortfall: Option<&SealedShortfall>,
2232    userns_grant: Option<UsernsGrant>,
2233    landlock_kernel_abi: Option<u8>,
2234    exports: Option<&Exports>,
2235    state_stores: Vec<StateStoreReport>,
2236    configured_artifacts: Vec<String>,
2237    preopens: Vec<PreopenReport>,
2238    runtime_dir_placement: &RuntimeDirPlacement,
2239    temp_mapping: crate::temp_dir::TempMapping,
2240    io_max: IoMaxReport,
2241) -> ScopeReport {
2242    debug_assert_eq!(
2243        *decision,
2244        ContainmentDecision::decide(
2245            decision.host_tier,
2246            decision.declared,
2247            policy.workdir_exec_allowed,
2248            runtime_dir_placement.inside_workdir(),
2249        ),
2250        "the decision must be made from the policy and placement this report states"
2251    );
2252    let declared = decision.declared();
2253    let achieved = decision.achieved();
2254    // The tier `ShellEnforcement::resolve` installs from the same decision. The mechanism fields
2255    // below are keyed on it rather than on `achieved`, which `workdir_exec` and the placement can
2256    // cap without changing what the mechanism restricts.
2257    let applied = decision.applied_tier();
2258    let shortfall_reason = containment_shortfall_reason(
2259        declared,
2260        achieved,
2261        sealed_shortfall,
2262        policy.workdir_exec_allowed,
2263        runtime_dir_placement.inside(),
2264    );
2265    let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
2266    let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
2267    let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
2268    let mut filesystem_boundary = filesystem_boundary(applied);
2269    let enforcement_tier = decision.enforcement_tier();
2270    // Built again by `with_routes` once the caller knows the transport.
2271    let routes = route_table(
2272        &RouteInputs::new(
2273            &policy.shell_allow,
2274            policy.plan_submit,
2275            &policy.install_tool,
2276            &preopens,
2277            None,
2278        ),
2279        enforcement_tier,
2280    );
2281    // On the unconfined tier the boundary's one statement already covers both directories.
2282    if applied != EnforcementTier::Unconfined {
2283        for report in [&runtime_dir, &records_dir] {
2284            if let Some(reason) = &report.not_protected_because {
2285                filesystem_boundary.not_protected.push(reason.clone());
2286            }
2287        }
2288    }
2289    let unconfined = (applied == EnforcementTier::Unconfined)
2290        .then(|| UnconfinedReport::for_this_process(&policy.declared_resources));
2291
2292    ScopeReport {
2293        declared_containment: declared,
2294        achieved_containment: achieved,
2295        host_containment: decision.host_containment(),
2296        floor_met: shortfall_reason.is_none(),
2297        shortfall_reason,
2298        enforcement_tier,
2299        routes,
2300        userns_grant,
2301        landlock_abi,
2302        // Copied straight through and never consulted above: `achieved` is already computed, and
2303        // no export or peer grant may reach it.
2304        exports_files: exports
2305            .and_then(|exports| exports.files.as_ref())
2306            .map(ExportsFilesReport::from),
2307        peer_files: exports
2308            .and_then(|exports| exports.peer_files.as_ref())
2309            .map(PeerFilesReport::from),
2310        peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
2311        peer_fetch_allow: policy.peer_fetch_allow.clone(),
2312        // Resolved by `state_store::state_store_reports` before this call and copied through on
2313        // the same terms as the export reports above: a grant that opens a path outside the
2314        // workdir still gets no say in what class the session is reported to achieve.
2315        state_stores,
2316        // Resolved by `artifact_config::configured_artifact_names` before this call and copied
2317        // through on the same terms: one environment variable delivered to one guest gets no say
2318        // in what class the session is reported to achieve.
2319        configured_artifacts,
2320        filesystem_scope: policy.filesystem_scope.clone(),
2321        workdir_exec: policy.workdir_exec_allowed,
2322        read_only_paths: policy.read_only_paths.clone(),
2323        read_only_advisory_for: read_only_advisory_for(
2324            &policy.read_only_paths,
2325            &policy.shell_allow,
2326        ),
2327        // Resolved by `network_policy::preopen_reports` before this call and copied through on the
2328        // same terms as the reports above: what one guest is preopened into gets no say in what
2329        // class the session is reported to achieve.
2330        preopens,
2331        network_allow: policy.network_allow.clone(),
2332        unix_sockets: policy.unix_sockets_allowed,
2333        shell_allow: policy.shell_allow.clone(),
2334        spawn_allow: policy.spawn_allow.clone(),
2335        spawn_workdir_snapshot: policy.spawn_workdir_snapshot.clone(),
2336        // Set by the caller that knows it, through `with_parent_workdir`: the snapshot is what
2337        // the launching parent did, not something the policy declares.
2338        parent_workdir: None,
2339        // Set through `with_images` by the caller that holds the manifest's transport.
2340        images: None,
2341        env_allow: policy.env_allow.clone(),
2342        plan_submit: policy.plan_submit,
2343        install_skill: policy.install_skill.clone(),
2344        install_tool: policy.install_tool.clone(),
2345        install_from: install_from_places(policy),
2346        interpreter_runtime_grants: policy
2347            .shell_interpreter_runtime
2348            .iter()
2349            .flat_map(|grant| {
2350                grant.dirs.iter().map(move |dir| {
2351                    let listable = if dir.list_dir { " (list_dir)" } else { "" };
2352                    format!("{}: {}{}", grant.binary, dir.path, listable)
2353                })
2354            })
2355            .collect(),
2356        derived_interpreter_runtime: policy
2357            .shell_derived_interpreter_runtime
2358            .iter()
2359            .flat_map(|runtime| {
2360                let binary = runtime.binary.display();
2361                runtime
2362                    .entries()
2363                    .map(|(path, is_dir)| {
2364                        let listable = if is_dir { " (list_dir)" } else { "" };
2365                        format!("{binary}: {}{listable}", path.display())
2366                    })
2367                    .chain(
2368                        runtime
2369                            .libraries
2370                            .iter()
2371                            .map(|library| format!("{binary}: {}", library.display())),
2372                    )
2373                    .collect::<Vec<_>>()
2374            })
2375            .collect(),
2376        // Keyed on the applied tier: the Landlock tiers grant these files whatever the manifest
2377        // says, and every other tier grants nothing.
2378        name_resolution: match applied {
2379            EnforcementTier::KernelFull | EnforcementTier::KernelSealed => {
2380                crate::sandbox::SCOPED_NAME_RESOLUTION_PATHS
2381                    .iter()
2382                    .map(|path| (*path).to_string())
2383                    .collect()
2384            }
2385            _ => Vec::new(),
2386        },
2387        declared_needs: policy
2388            .shell_declared_needs
2389            .iter()
2390            .map(DeclaredNeedsReport::from)
2391            .collect(),
2392        pinned_programs: policy
2393            .shell_pinned_programs
2394            .iter()
2395            .map(PinnedProgramReport::from)
2396            .collect(),
2397        trust_store: policy
2398            .shell_trust_store
2399            .iter()
2400            .map(crate::trust_store::TrustStorePath::render)
2401            .collect(),
2402        // Keyed on the applied tier, like `filesystem_boundary`: what scopes a host path is the
2403        // mechanism this session installs, not the class it achieves.
2404        host_paths: crate::host_paths::host_path_reports(
2405            &policy.host_paths,
2406            crate::host_paths::HostPathMechanism::for_enforcement_tier(decision.enforcement_tier()),
2407        ),
2408        staged_runtime_grants: policy
2409            .shell_staged_runtime
2410            .iter()
2411            .map(|grant| {
2412                format!(
2413                    "{}: {} (pin: {})",
2414                    grant.binary, grant.source_path, grant.pin
2415                )
2416            })
2417            .collect(),
2418        // Nothing here touches the filesystem: `--explain-scope` returns before a workdir exists,
2419        // and a diagnostic that created the directory it describes would be answering a question
2420        // it had changed.
2421        runtime_writes: crate::workdir_writes::runtime_writes(),
2422        // Set through `with_manifest_files` by the caller that knows where the manifest's
2423        // relative paths resolve.
2424        manifest_files: Vec::new(),
2425        // Established by performing the `io.max` write, never inferred: `prepare_scope` fills it
2426        // at launch and `cgroup::probe_scope` fills it under `--explain-scope`. Like every field
2427        // above it, it reaches no class and no floor.
2428        io_max,
2429        // Set by the caller that performed the operation, through `with_cgroup_scope`: the launch
2430        // from `prepare_scope`, `--explain-scope` from `probe_scope`.
2431        cgroup_scope: CgroupScopeReport::default(),
2432        // Derived here, from the `applied` tier already resolved above, rather than filled by
2433        // either caller: `--explain-scope --json` and `session_start.effective_grants` are the
2434        // same object, and a field either of them worded for itself would make them differ.
2435        filesystem_boundary,
2436        runtime_dir,
2437        records_dir,
2438        temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
2439        // Until a caller holding the session's probe states it through `with_process_view`, the
2440        // report claims no private view.
2441        process_view: crate::proc_view::process_view_report(&crate::proc_view::tier_proc_view(
2442            applied,
2443        )),
2444        unconfined,
2445        // Filled by `with_harness` once the caller has resolved the driver's needs against the
2446        // manifest; `null` for a capsule whose transport is not `process`.
2447        harness: None,
2448    }
2449}
2450
2451/// [`scope_report_for_tier`] for a capsule declaring `declared` on a host whose tier is `tier`,
2452/// deciding from `policy` and `runtime_dir_placement` as `runtime::stage_session` does.
2453#[cfg(test)]
2454#[allow(clippy::too_many_arguments)]
2455pub(crate) fn report_for(
2456    policy: &CapabilityPolicy,
2457    declared: ContainmentClass,
2458    tier: EnforcementTier,
2459    sealed_shortfall: Option<&SealedShortfall>,
2460    userns_grant: Option<UsernsGrant>,
2461    landlock_kernel_abi: Option<u8>,
2462    exports: Option<&Exports>,
2463    state_stores: Vec<StateStoreReport>,
2464    configured_artifacts: Vec<String>,
2465    preopens: Vec<PreopenReport>,
2466    runtime_dir_placement: &RuntimeDirPlacement,
2467    temp_mapping: crate::temp_dir::TempMapping,
2468    io_max: IoMaxReport,
2469) -> ScopeReport {
2470    let decision = ContainmentDecision::decide(
2471        tier,
2472        declared,
2473        policy.workdir_exec_allowed,
2474        runtime_dir_placement.inside_workdir(),
2475    );
2476    scope_report_for_tier(
2477        policy,
2478        &decision,
2479        sealed_shortfall,
2480        userns_grant,
2481        landlock_kernel_abi,
2482        exports,
2483        state_stores,
2484        configured_artifacts,
2485        preopens,
2486        runtime_dir_placement,
2487        temp_mapping,
2488        io_max,
2489    )
2490}
2491
2492/// The `mechanism` of a [`RouteReport`] whose route the session's class does not cover: the code
2493/// runs as part of the `mur` process, or as a child it starts outside the class.
2494pub const HOST_PROCESS_MECHANISM: &str = "host-process";
2495
2496/// The `note` of a [`RouteReport`] for a route [`Route::runs_unconfined`] on the unconfined tier.
2497const UNCONFINED_ROUTE_NOTE: &str = "Runs as the operator, with their HOME, whole environment, \
2498     filesystem, network and sudo; nothing holds it.";
2499
2500/// One route a capsule can run code on, as [`RouteReport::route`] names it.
2501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2502#[serde(rename_all = "kebab-case")]
2503pub enum Route {
2504    /// A `capabilities.shell.allow` command the model runs.
2505    Shell,
2506    /// A plan's shell step.
2507    PlanShellSteps,
2508    /// A WASM `runtime: tool` artifact, declared or pulled.
2509    WasmTools,
2510    /// A `runtime: driver` artifact.
2511    Drivers,
2512    /// A `runtime: hook` artifact.
2513    Hooks,
2514    /// The coding harness `inference.transport: process` runs.
2515    ProcessHarness,
2516    /// The tools that harness runs on its own.
2517    HarnessTools,
2518}
2519
2520impl Route {
2521    /// Every route, in the order [`route_table`] reports them.
2522    pub const ALL: [Self; 7] = [
2523        Self::Shell,
2524        Self::PlanShellSteps,
2525        Self::WasmTools,
2526        Self::Drivers,
2527        Self::Hooks,
2528        Self::ProcessHarness,
2529        Self::HarnessTools,
2530    ];
2531
2532    /// The route's name in `--explain-scope`'s text, `mur doctor` and `W-SEC-034`.
2533    #[must_use]
2534    pub fn label(self) -> &'static str {
2535        match self {
2536            Self::Shell => "shell commands",
2537            Self::PlanShellSteps => "plan shell steps",
2538            Self::WasmTools => "WASM tools",
2539            Self::Drivers => "drivers",
2540            Self::Hooks => "hooks",
2541            Self::ProcessHarness => "the process harness",
2542            Self::HarnessTools => "harness tools",
2543        }
2544    }
2545
2546    /// Whether the session's class covers this route: exactly the routes spawned through
2547    /// `sandbox::prepare_enforcement`, the one path that installs the session's tier. Shell
2548    /// commands and plan shell steps are; so are the process harness and the tools it runs, and
2549    /// WASM tools, drivers and hooks, whose guest workers (`guest_worker::launch::spawn_process`)
2550    /// are spawned through it under each worker's grant closure. This follows where each route is spawned, not a policy: confining
2551    /// another route means flipping its arm here.
2552    #[must_use]
2553    pub fn under_class(self) -> bool {
2554        match self {
2555            Self::Shell
2556            | Self::PlanShellSteps
2557            | Self::ProcessHarness
2558            | Self::HarnessTools
2559            | Self::WasmTools
2560            | Self::Drivers
2561            | Self::Hooks => true,
2562        }
2563    }
2564
2565    /// Whether an unconfined session runs this route as the operator: the routes
2566    /// [`UNCONFINED_APPLIES_TO`] names.
2567    #[must_use]
2568    pub fn runs_unconfined(self) -> bool {
2569        match self {
2570            Self::Shell | Self::PlanShellSteps | Self::ProcessHarness | Self::HarnessTools => true,
2571            Self::WasmTools | Self::Drivers | Self::Hooks => false,
2572        }
2573    }
2574
2575    /// What an uncovered route does run under, as [`RouteReport::note`] states it; `None` for a
2576    /// covered route, which the tier's mechanism holds.
2577    #[must_use]
2578    pub fn note(self) -> Option<&'static str> {
2579        match self {
2580            Self::Shell
2581            | Self::PlanShellSteps
2582            | Self::ProcessHarness
2583            | Self::HarnessTools
2584            | Self::WasmTools
2585            | Self::Drivers
2586            | Self::Hooks => None,
2587        }
2588    }
2589}
2590
2591/// One row of [`ScopeReport::routes`]: whether this capsule has the route, and what holds it.
2592#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2593pub struct RouteReport {
2594    /// Which route, as a kebab-case wire name.
2595    pub route: Route,
2596    /// Whether this capsule has the route.
2597    pub present: bool,
2598    /// Whether the session's class covers the route, from [`Route::under_class`].
2599    pub under_class: bool,
2600    /// [`ScopeReport::enforcement_tier`]'s wire name when [`Self::under_class`], else
2601    /// [`HOST_PROCESS_MECHANISM`]. `unconfined` on the unconfined tier for every route
2602    /// [`Route::runs_unconfined`].
2603    pub mechanism: &'static str,
2604    /// What an uncovered route runs under, from [`Route::note`]; `null` when covered, except on
2605    /// the unconfined tier, where a route [`Route::runs_unconfined`] says nothing holds it.
2606    /// Always serialized.
2607    pub note: Option<&'static str>,
2608    /// The artifacts on the route, in manifest order: the tool, driver or hook entries it runs.
2609    /// Empty for a route no artifact names. Always serialized.
2610    pub artifacts: Vec<String>,
2611}
2612
2613/// What [`route_table`] decides presence from, gathered by [`RouteInputs::new`].
2614#[derive(Debug, Clone, Default, PartialEq, Eq)]
2615pub struct RouteInputs {
2616    /// `capabilities.shell.allow` is non-empty.
2617    pub shell_allow: bool,
2618    /// `capabilities.plan.submit`.
2619    pub plan_submit: bool,
2620    /// `capabilities.install.tool` is non-empty, so a pull may install a tool.
2621    pub install_tool: bool,
2622    /// Every `runtime: tool` entry.
2623    pub tools: Vec<String>,
2624    /// Every `runtime: driver` entry.
2625    pub drivers: Vec<String>,
2626    /// Every `runtime: hook` entry.
2627    pub hooks: Vec<String>,
2628    /// `inference.transport` is `process`.
2629    pub process_transport: bool,
2630}
2631
2632impl RouteInputs {
2633    /// The inputs for a capsule with these grants, these resolved `preopens` (one per tool,
2634    /// driver and hook entry) and the inference `transport`.
2635    #[must_use]
2636    pub fn new(
2637        shell_allow: &[String],
2638        plan_submit: bool,
2639        install_tool: &[String],
2640        preopens: &[PreopenReport],
2641        transport: Option<&str>,
2642    ) -> Self {
2643        let named = |role: murmur_artifact::ArtifactRuntime| {
2644            preopens
2645                .iter()
2646                .filter(|preopen| preopen.role == role.as_str())
2647                .map(|preopen| preopen.artifact.clone())
2648                .collect::<Vec<_>>()
2649        };
2650        Self {
2651            shell_allow: !shell_allow.is_empty(),
2652            plan_submit,
2653            install_tool: !install_tool.is_empty(),
2654            tools: named(murmur_artifact::ArtifactRuntime::Tool),
2655            drivers: named(murmur_artifact::ArtifactRuntime::Driver),
2656            hooks: named(murmur_artifact::ArtifactRuntime::Hook),
2657            process_transport: transport == Some("process"),
2658        }
2659    }
2660}
2661
2662/// The route table: one [`RouteReport`] per [`Route::ALL`] entry, in that order, for a capsule
2663/// with `inputs` whose session installs the tier named `enforcement_tier`.
2664///
2665/// The only place a route's presence, coverage and mechanism are decided. `--explain-scope`,
2666/// `session_start.effective_grants` and `mur doctor` all read its rows, and `W-SEC-034` decides
2667/// from them.
2668#[must_use]
2669pub fn route_table(inputs: &RouteInputs, enforcement_tier: &'static str) -> Vec<RouteReport> {
2670    let unconfined = enforcement_tier == enforcement_tier_name(EnforcementTier::Unconfined);
2671    Route::ALL
2672        .into_iter()
2673        .map(|route| {
2674            let (present, artifacts) = match route {
2675                Route::Shell => (inputs.shell_allow, Vec::new()),
2676                // A plan's shell step runs only a binary `shell.allow` names (see `plan.rs`).
2677                Route::PlanShellSteps => (inputs.plan_submit && inputs.shell_allow, Vec::new()),
2678                Route::WasmTools => (
2679                    !inputs.tools.is_empty() || inputs.install_tool,
2680                    inputs.tools.clone(),
2681                ),
2682                Route::Drivers => (!inputs.drivers.is_empty(), inputs.drivers.clone()),
2683                Route::Hooks => (!inputs.hooks.is_empty(), inputs.hooks.clone()),
2684                Route::ProcessHarness | Route::HarnessTools => {
2685                    (inputs.process_transport, Vec::new())
2686                }
2687            };
2688            if unconfined && route.runs_unconfined() {
2689                return RouteReport {
2690                    route,
2691                    present,
2692                    under_class: true,
2693                    mechanism: enforcement_tier,
2694                    note: Some(UNCONFINED_ROUTE_NOTE),
2695                    artifacts,
2696                };
2697            }
2698            let under_class = route.under_class();
2699            RouteReport {
2700                route,
2701                present,
2702                under_class,
2703                mechanism: if under_class {
2704                    enforcement_tier
2705                } else {
2706                    HOST_PROCESS_MECHANISM
2707                },
2708                note: route.note(),
2709                artifacts,
2710            }
2711        })
2712        .collect()
2713}
2714
2715/// The `Routes` block `--explain-scope` and `mur doctor` print: one line per route, saying
2716/// whether the capsule has it and what holds it, with an uncovered present route's note under
2717/// it. Starts with `Routes` and ends with a newline.
2718#[must_use]
2719pub fn render_routes(routes: &[RouteReport]) -> String {
2720    let mut out = String::from("Routes\n");
2721    for row in routes {
2722        let label = match row.route {
2723            Route::ProcessHarness => "process harness",
2724            route => route.label(),
2725        };
2726        let presence = if row.present { "present" } else { "absent" };
2727        let mechanism = if row.under_class {
2728            row.mechanism
2729        } else {
2730            "host process, not confined by the class"
2731        };
2732        let artifacts = if row.artifacts.is_empty() {
2733            String::new()
2734        } else {
2735            format!(" ({})", row.artifacts.join(", "))
2736        };
2737        out.push_str(&format!(
2738            "  {label:<17} {presence:<8} {mechanism}{artifacts}\n"
2739        ));
2740        if let (true, Some(note)) = (row.present, row.note) {
2741            out.push_str(&format!("    {note}\n"));
2742        }
2743    }
2744    out
2745}
2746
2747/// The `W-SEC-034` message for a capsule declaring `declared` with `routes`, or `None` when it
2748/// does not fire: below a `scoped` floor, or when no present route lies outside the class.
2749///
2750/// Pure, split from [`warn_on_uncovered_routes`] on [`crate::sandbox::tier_warning`]'s terms.
2751#[must_use]
2752pub fn uncovered_route_warning(
2753    declared: ContainmentClass,
2754    routes: &[RouteReport],
2755) -> Option<String> {
2756    if declared < ContainmentClass::Scoped {
2757        return None;
2758    }
2759    let uncovered: Vec<String> = routes
2760        .iter()
2761        .filter(|row| row.present && !row.under_class)
2762        .map(|row| {
2763            if row.artifacts.is_empty() {
2764                row.route.label().to_string()
2765            } else {
2766                format!("{} ({})", row.route.label(), row.artifacts.join(", "))
2767            }
2768        })
2769        .collect();
2770    let named = match uncovered.as_slice() {
2771        [] => return None,
2772        [only] => only.clone(),
2773        [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2774    };
2775    Some(format!(
2776        "the declared {declared} floor does not confine every route this capsule runs code on: \
2777         {named} run outside the class, with none of its Landlock, seccomp or network namespace \
2778         around them (`mur run --explain-scope` lists what holds each under Routes). The class \
2779         confines shell commands, plan shell steps and the guest workers that run WASM tools, \
2780         drivers and hooks; nothing is refused"
2781    ))
2782}
2783
2784/// Prints `W-SEC-034` when [`uncovered_route_warning`] fires: to stderr, and to the session's
2785/// bootstrap log when `bootstrap_log` names a records directory. The one emitter the launch and
2786/// `mur doctor` (with `None`) both call.
2787pub fn warn_on_uncovered_routes(
2788    declared: ContainmentClass,
2789    routes: &[RouteReport],
2790    bootstrap_log: Option<&crate::workdir_fs::WorkdirRoot>,
2791) {
2792    let Some(message) = uncovered_route_warning(declared, routes) else {
2793        return;
2794    };
2795    let code = murmur_artifact::W_SEC_034;
2796    let link = murmur_artifact::security_warning_link(code);
2797    crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2798    if let Some(records) = bootstrap_log {
2799        crate::agent::append_bootstrap_log(
2800            records,
2801            &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2802        );
2803    }
2804}
2805
2806/// The wire name of the tier a shell command of a capsule declaring `declared` runs under on the
2807/// host `probe` read: [`ContainmentDecision::enforcement_tier`], which is what
2808/// `ShellEnforcement::resolve` installs for every subprocess. Spelled as
2809/// [`ScopeReport::enforcement_tier`] spells a tier.
2810pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
2811    // `workdir_exec` and the placement cap the class, never the tier, and only the tier is read.
2812    ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
2813}
2814
2815/// Stable wire name for a tier, so `--explain-scope --json` consumers see the mechanism rather
2816/// than a `Debug` rendering that is free to change.
2817pub(crate) fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
2818    match tier {
2819        EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
2820        EnforcementTier::KernelFull => "landlock+seccomp",
2821        EnforcementTier::KernelSeccompOnly => "seccomp-only",
2822        EnforcementTier::EnvironmentOnly => "none",
2823        EnforcementTier::Unconfined => "unconfined",
2824    }
2825}
2826
2827#[cfg(test)]
2828#[allow(clippy::print_stdout, clippy::print_stderr)]
2829mod tests {
2830    use super::*;
2831    use crate::sealed::SealedBlocker;
2832
2833    /// A shortfall of `blocker` with no failed attempt, whose reason is the class sentence alone.
2834    fn blocked(blocker: SealedBlocker) -> SealedShortfall {
2835        SealedShortfall {
2836            blocker,
2837            failed_call: None,
2838            errno: None,
2839            userns_grant: UsernsGrant::Withheld,
2840            attachment: crate::sealed::ProfileAttachment::NotReported,
2841            profile_gap: crate::sealed::ProfileGap::Undetermined,
2842        }
2843    }
2844    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
2845
2846    /// Every tier, so a new variant cannot quietly escape a test that iterates "all of them".
2847    const ALL_TIERS: &[EnforcementTier] = &[
2848        EnforcementTier::KernelSealed,
2849        EnforcementTier::KernelFull,
2850        EnforcementTier::KernelSeccompOnly,
2851        EnforcementTier::EnvironmentOnly,
2852    ];
2853
2854    #[test]
2855    fn kernel_full_achieves_scoped() {
2856        assert_eq!(
2857            achieved_class_for_tier(EnforcementTier::KernelFull),
2858            ContainmentClass::Scoped
2859        );
2860    }
2861
2862    /// The sealed tier, and only the sealed tier, reports `sealed`.
2863    #[test]
2864    fn only_the_sealed_tier_achieves_sealed() {
2865        assert_eq!(
2866            achieved_class_for_tier(EnforcementTier::KernelSealed),
2867            ContainmentClass::Sealed
2868        );
2869        for tier in ALL_TIERS
2870            .iter()
2871            .filter(|t| **t != EnforcementTier::KernelSealed)
2872        {
2873            assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
2874        }
2875    }
2876
2877    #[test]
2878    fn tiers_without_landlock_achieve_only_advisory() {
2879        assert_eq!(
2880            achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
2881            ContainmentClass::Advisory
2882        );
2883        assert_eq!(
2884            achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
2885            ContainmentClass::Advisory
2886        );
2887    }
2888
2889    /// The whole tier × `workdir_exec` truth table, with both inputs supplied by the test and no
2890    /// kernel consulted — the seam `achieved_class_for_tier`/`achieved_containment_class` exists
2891    /// for. This is emphatically *not* a test of the security property (that a workdir binary
2892    /// cannot execute); it tests only the class arithmetic, which is all that can honestly be
2893    /// tested without a Landlock-capable host.
2894    #[test]
2895    fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
2896        // Without the declaration: the host ceiling, unchanged.
2897        assert_eq!(
2898            achieved_containment_class(EnforcementTier::KernelSealed, false, false),
2899            ContainmentClass::Sealed
2900        );
2901        assert_eq!(
2902            achieved_containment_class(EnforcementTier::KernelFull, false, false),
2903            ContainmentClass::Scoped
2904        );
2905        assert_eq!(
2906            achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
2907            ContainmentClass::Advisory
2908        );
2909        assert_eq!(
2910            achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
2911            ContainmentClass::Advisory
2912        );
2913
2914        // With it: advisory, whatever the host could otherwise back.
2915        for tier in ALL_TIERS {
2916            assert_eq!(
2917                achieved_containment_class(*tier, true, false),
2918                ContainmentClass::Advisory,
2919                "workdir_exec must cap tier {tier:?} at advisory"
2920            );
2921        }
2922    }
2923
2924    /// The cap goes in the wrapper, never in the tier-only function — a regression here would mean
2925    /// a manifest key had leaked into the one mapping that must stay host-only.
2926    #[test]
2927    fn the_tier_only_mapping_is_unaffected_by_the_cap() {
2928        for tier in ALL_TIERS {
2929            assert_eq!(
2930                achieved_class_for_tier(*tier),
2931                achieved_containment_class(*tier, false, false),
2932                "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
2933            );
2934        }
2935    }
2936
2937    /// The refusal an operator actually hits: `workdir_exec: true` plus
2938    /// `capabilities.containment: scoped`, on a host that could otherwise back `scoped`. It must
2939    /// refuse, and the reason must send them to their manifest rather than to another machine.
2940    #[test]
2941    fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
2942        let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
2943        assert_eq!(achieved, ContainmentClass::Advisory);
2944
2945        let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
2946            .expect_err("workdir_exec + scoped must refuse");
2947        match error {
2948            RuntimeError::ContainmentFloorUnmet {
2949                declared,
2950                achieved,
2951                reason,
2952                ..
2953            } => {
2954                assert_eq!(declared, ContainmentClass::Scoped);
2955                assert_eq!(achieved, ContainmentClass::Advisory);
2956                assert!(
2957                    reason.contains("workdir_exec"),
2958                    "the reason must name the manifest key, got: {reason}"
2959                );
2960                assert!(
2961                    !reason.contains("this host provides no kernel filesystem mediation"),
2962                    "the reason must not blame the host for a manifest declaration: {reason}"
2963                );
2964            }
2965            other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2966        }
2967    }
2968
2969    /// `sealed` gets the same manifest-side reason rather than a `SealedBlocker` one: a probed
2970    /// blocker is real but is not what is stopping *this* capsule, and reporting it would send the
2971    /// operator to install an AppArmor profile that changes nothing.
2972    #[test]
2973    fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
2974        let reason = containment_shortfall_reason(
2975            ContainmentClass::Sealed,
2976            ContainmentClass::Advisory,
2977            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2978            true,
2979            None,
2980        )
2981        .expect("refusal");
2982        assert!(reason.contains("workdir_exec"));
2983        assert!(!reason.contains("apparmor_parser"));
2984    }
2985
2986    /// The declaration is not itself a refusal: a capsule that declares `workdir_exec` and asks
2987    /// for nothing above `advisory` launches normally on every host.
2988    #[test]
2989    fn workdir_exec_alone_refuses_nothing() {
2990        for tier in ALL_TIERS {
2991            let achieved = achieved_containment_class(*tier, true, false);
2992            assert!(
2993                check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
2994                    .is_ok(),
2995                "workdir_exec at an advisory floor must launch on tier {tier:?}"
2996            );
2997        }
2998    }
2999
3000    #[test]
3001    fn advisory_floor_is_met_on_every_tier() {
3002        for tier in ALL_TIERS {
3003            let achieved = achieved_class_for_tier(*tier);
3004            assert!(check_containment_floor(
3005                ContainmentClass::Advisory,
3006                achieved,
3007                None,
3008                false,
3009                None
3010            )
3011            .is_ok());
3012        }
3013    }
3014
3015    #[test]
3016    fn scoped_floor_is_met_on_every_landlock_capable_tier() {
3017        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
3018            let achieved = achieved_class_for_tier(tier);
3019            assert!(
3020                check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
3021                    .is_ok()
3022            );
3023        }
3024
3025        for tier in [
3026            EnforcementTier::KernelSeccompOnly,
3027            EnforcementTier::EnvironmentOnly,
3028        ] {
3029            let achieved = achieved_class_for_tier(tier);
3030            let error =
3031                check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
3032                    .expect_err("scoped must refuse without Landlock");
3033            match error {
3034                RuntimeError::ContainmentFloorUnmet {
3035                    declared,
3036                    achieved,
3037                    reason,
3038                    ..
3039                } => {
3040                    assert_eq!(declared, ContainmentClass::Scoped);
3041                    assert_eq!(achieved, ContainmentClass::Advisory);
3042                    assert!(
3043                        reason.contains("Landlock"),
3044                        "reason must name the missing mechanism, got: {reason}"
3045                    );
3046                }
3047                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3048            }
3049        }
3050    }
3051
3052    /// The edge case the sealed tier makes possible: a capsule declaring a *weaker* class on a
3053    /// sealed-capable host. It runs on `landlock+seccomp` and achieves `scoped` — never the
3054    /// `sealed` the host could back — and that still meets its floor.
3055    #[test]
3056    fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
3057        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
3058            let decision =
3059                ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
3060            assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
3061            assert_eq!(decision.achieved(), ContainmentClass::Scoped);
3062            assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
3063            assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
3064            assert!(
3065                check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
3066            );
3067            assert_eq!(
3068                containment_shortfall_reason(declared, decision.achieved(), None, false, None),
3069                None
3070            );
3071        }
3072    }
3073
3074    const ALL_CLASSES: &[ContainmentClass] = &[
3075        ContainmentClass::Advisory,
3076        ContainmentClass::Scoped,
3077        ContainmentClass::Sealed,
3078    ];
3079
3080    /// Every host tier × declared class × `workdir_exec` × runtime directory inside the workdir:
3081    /// the tier the session installs, the class read off it, the host's ceiling and the wire name
3082    /// of the installed tier, from a table written out here rather than recomputed.
3083    #[test]
3084    fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
3085        use ContainmentClass::{Advisory, Scoped, Sealed};
3086        use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
3087        // (host tier, declared) → (applied tier, uncapped class, wire name)
3088        let table = [
3089            (
3090                (KernelSealed, Advisory),
3091                (KernelFull, Scoped, "landlock+seccomp"),
3092            ),
3093            (
3094                (KernelSealed, Scoped),
3095                (KernelFull, Scoped, "landlock+seccomp"),
3096            ),
3097            (
3098                (KernelSealed, Sealed),
3099                (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
3100            ),
3101            (
3102                (KernelFull, Advisory),
3103                (KernelFull, Scoped, "landlock+seccomp"),
3104            ),
3105            (
3106                (KernelFull, Scoped),
3107                (KernelFull, Scoped, "landlock+seccomp"),
3108            ),
3109            (
3110                (KernelFull, Sealed),
3111                (KernelFull, Scoped, "landlock+seccomp"),
3112            ),
3113            (
3114                (KernelSeccompOnly, Advisory),
3115                (KernelSeccompOnly, Advisory, "seccomp-only"),
3116            ),
3117            (
3118                (KernelSeccompOnly, Scoped),
3119                (KernelSeccompOnly, Advisory, "seccomp-only"),
3120            ),
3121            (
3122                (KernelSeccompOnly, Sealed),
3123                (KernelSeccompOnly, Advisory, "seccomp-only"),
3124            ),
3125            (
3126                (EnvironmentOnly, Advisory),
3127                (EnvironmentOnly, Advisory, "none"),
3128            ),
3129            (
3130                (EnvironmentOnly, Scoped),
3131                (EnvironmentOnly, Advisory, "none"),
3132            ),
3133            (
3134                (EnvironmentOnly, Sealed),
3135                (EnvironmentOnly, Advisory, "none"),
3136            ),
3137        ];
3138        assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
3139        for ((host_tier, declared), (applied, class, wire)) in table {
3140            let host_class = match host_tier {
3141                KernelSealed => Sealed,
3142                KernelFull => Scoped,
3143                KernelSeccompOnly | EnvironmentOnly => Advisory,
3144                EnforcementTier::Unconfined => unreachable!("never a host tier"),
3145            };
3146            for workdir_exec in [false, true] {
3147                for inside in [false, true] {
3148                    let decision =
3149                        ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
3150                    let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
3151                    assert_eq!(decision.declared(), declared, "{what}");
3152                    assert_eq!(decision.applied_tier(), applied, "{what}");
3153                    assert_eq!(decision.enforcement_tier(), wire, "{what}");
3154                    assert_eq!(decision.host_containment(), host_class, "{what}");
3155                    let expected = if workdir_exec || inside {
3156                        Advisory
3157                    } else {
3158                        class
3159                    };
3160                    assert_eq!(decision.achieved(), expected, "{what}");
3161                    // No declaration raises a class above the host's, and only a `sealed`
3162                    // declaration is ever reported `sealed`.
3163                    assert!(decision.achieved() <= decision.host_containment(), "{what}");
3164                    assert!(
3165                        decision.achieved() != Sealed || declared == Sealed,
3166                        "{what}"
3167                    );
3168                }
3169            }
3170        }
3171
3172        // The three rows a sealed-capable host turns on, by name.
3173        let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
3174        assert_eq!(
3175            (
3176                scoped.achieved(),
3177                scoped.enforcement_tier(),
3178                scoped.host_containment()
3179            ),
3180            (Scoped, "landlock+seccomp", Sealed)
3181        );
3182        let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
3183        assert_eq!(
3184            (sealed.achieved(), sealed.enforcement_tier()),
3185            (Sealed, "mountns+pivot_root+landlock+seccomp")
3186        );
3187        let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
3188        assert_eq!(
3189            (advisory.achieved(), advisory.enforcement_tier()),
3190            (Scoped, "landlock+seccomp")
3191        );
3192    }
3193
3194    /// The floor refusal decides from the decision's class exactly as it did from the host tier's
3195    /// class: the two differ only where the host is sealed-capable and the declaration is below
3196    /// `sealed`, and there both meet the floor (or, capped, both refuse with the same words).
3197    #[test]
3198    fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
3199        for &host_tier in ALL_TIERS {
3200            for &declared in ALL_CLASSES {
3201                for workdir_exec in [false, true] {
3202                    for inside in [None, Some(inside_placement())] {
3203                        let in_workdir = inside.is_some();
3204                        let decision = ContainmentDecision::decide(
3205                            host_tier,
3206                            declared,
3207                            workdir_exec,
3208                            in_workdir,
3209                        );
3210                        let host_class =
3211                            achieved_containment_class(host_tier, workdir_exec, in_workdir);
3212                        let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
3213                        assert_eq!(
3214                            containment_shortfall_reason(
3215                                declared,
3216                                decision.achieved(),
3217                                Some(&shortfall),
3218                                workdir_exec,
3219                                inside.as_ref(),
3220                            ),
3221                            containment_shortfall_reason(
3222                                declared,
3223                                host_class,
3224                                Some(&shortfall),
3225                                workdir_exec,
3226                                inside.as_ref(),
3227                            ),
3228                            "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
3229                        );
3230                    }
3231                }
3232            }
3233        }
3234    }
3235
3236    #[test]
3237    fn sealed_floor_is_met_only_on_the_sealed_tier() {
3238        assert!(check_containment_floor(
3239            ContainmentClass::Sealed,
3240            achieved_class_for_tier(EnforcementTier::KernelSealed),
3241            None,
3242            false,
3243            None
3244        )
3245        .is_ok());
3246
3247        for tier in ALL_TIERS
3248            .iter()
3249            .filter(|t| **t != EnforcementTier::KernelSealed)
3250        {
3251            let achieved = achieved_class_for_tier(*tier);
3252            let error = check_containment_floor(
3253                ContainmentClass::Sealed,
3254                achieved,
3255                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3256                false,
3257                None,
3258            )
3259            .expect_err("sealed must refuse on a host that cannot back it");
3260            match error {
3261                RuntimeError::ContainmentFloorUnmet {
3262                    declared, reason, ..
3263                } => {
3264                    assert_eq!(declared, ContainmentClass::Sealed);
3265                    assert!(
3266                        reason.contains("seccomp profile") && !reason.contains("--cap-add"),
3267                        "reason must name the blocking mechanism's remediation, got: {reason}"
3268                    );
3269                }
3270                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3271            }
3272        }
3273    }
3274
3275    /// The refusal text is mechanism-specific, not one fixed sentence: the same
3276    /// declared/achieved pair produces different, individually actionable reasons.
3277    #[test]
3278    fn the_sealed_refusal_names_the_specific_blocker() {
3279        let achieved = ContainmentClass::Scoped;
3280
3281        let apparmor = containment_shortfall_reason(
3282            ContainmentClass::Sealed,
3283            achieved,
3284            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3285            false,
3286            None,
3287        )
3288        .expect("refusal");
3289        assert!(apparmor.contains("mur-sealed"));
3290        assert!(apparmor.contains("apparmor_parser -r"));
3291        assert!(!apparmor.contains("--cap-add"));
3292
3293        let container = containment_shortfall_reason(
3294            ContainmentClass::Sealed,
3295            achieved,
3296            Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3297            false,
3298            None,
3299        )
3300        .expect("refusal");
3301        assert!(!container.contains("--cap-add"), "{container}");
3302        assert!(container.contains("seccomp profile"), "{container}");
3303        assert!(container.contains("unshare(CLONE_NEWUSER)"), "{container}");
3304        assert!(container.contains("--security-opt seccomp="), "{container}");
3305        assert!(
3306            container.contains("seccompProfile: Localhost"),
3307            "{container}"
3308        );
3309        assert!(container.contains("outside the container"));
3310        assert!(!container.contains("apparmor_parser"));
3311
3312        // A host that mounts and will not pivot is refused at launch with its own text, rather
3313        // than at the first subprocess with `E-RUN-014`.
3314        let pivot = containment_shortfall_reason(
3315            ContainmentClass::Sealed,
3316            ContainmentClass::Advisory,
3317            Some(&blocked(SealedBlocker::PivotRootDenied)),
3318            false,
3319            None,
3320        )
3321        .expect("refusal");
3322        assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
3323        assert_ne!(
3324            pivot,
3325            containment_shortfall_reason(
3326                ContainmentClass::Sealed,
3327                ContainmentClass::Advisory,
3328                Some(&blocked(SealedBlocker::MountDenied)),
3329                false,
3330                None,
3331            )
3332            .expect("refusal")
3333        );
3334
3335        // Never the pre-mechanism blanket text again, under any blocker.
3336        for blocker in [
3337            SealedBlocker::NotLinux,
3338            SealedBlocker::AppArmorProfileMissing,
3339            SealedBlocker::NamespaceCreationDenied,
3340            SealedBlocker::MountDenied,
3341            SealedBlocker::PivotRootDenied,
3342            SealedBlocker::KernelUnsupported,
3343            SealedBlocker::LandlockUnavailable,
3344        ] {
3345            let reason = containment_shortfall_reason(
3346                ContainmentClass::Sealed,
3347                achieved,
3348                Some(&blocked(blocker)),
3349                false,
3350                None,
3351            )
3352            .expect("refusal");
3353            assert!(
3354                !reason.contains("no host can provide it today"),
3355                "got: {reason}"
3356            );
3357        }
3358    }
3359
3360    /// A caller with no probe result still gets a reason naming the mechanism rather than an
3361    /// empty or misleading one.
3362    #[test]
3363    fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
3364        let reason = containment_shortfall_reason(
3365            ContainmentClass::Sealed,
3366            ContainmentClass::Advisory,
3367            None,
3368            false,
3369            None,
3370        )
3371        .expect("refusal");
3372        assert!(reason.contains("pivot_root"));
3373        assert!(reason.contains("CLONE_NEWNS"));
3374    }
3375
3376    #[test]
3377    fn shortfall_reason_is_absent_when_the_floor_is_met() {
3378        assert_eq!(
3379            containment_shortfall_reason(
3380                ContainmentClass::Advisory,
3381                ContainmentClass::Scoped,
3382                None,
3383                false,
3384                None
3385            ),
3386            None
3387        );
3388        assert_eq!(
3389            containment_shortfall_reason(
3390                ContainmentClass::Scoped,
3391                ContainmentClass::Scoped,
3392                None,
3393                false,
3394                None
3395            ),
3396            None
3397        );
3398        assert_eq!(
3399            containment_shortfall_reason(
3400                ContainmentClass::Sealed,
3401                ContainmentClass::Sealed,
3402                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3403                false,
3404                None
3405            ),
3406            None,
3407            "a met floor must ignore a stale blocker rather than manufacture a refusal"
3408        );
3409    }
3410
3411    fn sample_policy() -> CapabilityPolicy {
3412        CapabilityPolicy {
3413            network_allow: vec!["https://api.example.com".to_string()],
3414            unix_sockets_allowed: false,
3415            filesystem_scope: Some("workdir".to_string()),
3416            shell_allow: vec!["python3".to_string()],
3417            spawn_allow: vec!["helper".to_string()],
3418            env_allow: vec!["TZ".to_string()],
3419            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3420                binary: "python3".to_string(),
3421                dirs: vec![InterpreterRuntimeDir {
3422                    path: "/usr/lib/python3.11".to_string(),
3423                    list_dir: true,
3424                }],
3425            }],
3426            ..CapabilityPolicy::default()
3427        }
3428    }
3429
3430    /// The unconfined stance is decided to the unconfined tier and class on every host, meets its
3431    /// own floor, and every tier-keyed field of its report says what is true: no mechanism, no
3432    /// Landlock ABI, no filesystem restriction, nothing read-only and `/tmp` not mapped.
3433    #[test]
3434    fn an_unconfined_report_names_no_protection_it_does_not_install() {
3435        for host_tier in [
3436            EnforcementTier::KernelSealed,
3437            EnforcementTier::KernelFull,
3438            EnforcementTier::KernelSeccompOnly,
3439            EnforcementTier::EnvironmentOnly,
3440        ] {
3441            let decision =
3442                ContainmentDecision::decide(host_tier, ContainmentClass::Unconfined, true, true);
3443            assert_eq!(decision.applied_tier(), EnforcementTier::Unconfined);
3444            assert_eq!(decision.achieved(), ContainmentClass::Unconfined);
3445            assert_eq!(decision.enforcement_tier(), "unconfined");
3446            assert_eq!(
3447                decision.host_containment(),
3448                achieved_class_for_tier(host_tier)
3449            );
3450        }
3451        let policy = CapabilityPolicy {
3452            containment_floor: ContainmentClass::Unconfined,
3453            declared_resources: murmur_artifact::ResourceCapabilities {
3454                max_open_files: Some(256),
3455                ..Default::default()
3456            },
3457            ..sample_policy()
3458        };
3459        let report = report_for(
3460            &policy,
3461            ContainmentClass::Unconfined,
3462            EnforcementTier::KernelFull,
3463            None,
3464            None,
3465            Some(4),
3466            None,
3467            Vec::new(),
3468            Vec::new(),
3469            Vec::new(),
3470            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3471            crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
3472            IoMaxReport::default(),
3473        );
3474        assert!(report.floor_met);
3475        assert_eq!(report.achieved_containment, ContainmentClass::Unconfined);
3476        assert_eq!(report.enforcement_tier, "unconfined");
3477        // The routes the stance runs as the operator say so; the in-process ones keep their notes.
3478        for row in &report.routes {
3479            if row.route.runs_unconfined() {
3480                assert!(row.under_class, "{row:?}");
3481                assert_eq!(row.mechanism, "unconfined", "{row:?}");
3482                assert_eq!(row.note, Some(UNCONFINED_ROUTE_NOTE), "{row:?}");
3483            } else {
3484                assert_eq!(row.note, row.route.note(), "{row:?}");
3485            }
3486        }
3487        assert_eq!(
3488            uncovered_route_warning(report.declared_containment, &report.routes),
3489            None,
3490            "W-SEC-034 keys on a scoped or sealed floor"
3491        );
3492        assert_eq!(report.landlock_abi, None);
3493        assert_eq!(
3494            report.filesystem_boundary.restriction,
3495            FilesystemRestriction::None
3496        );
3497        assert_eq!(
3498            report.filesystem_boundary.not_protected,
3499            vec![UNCONFINED_NOT_PROTECTED.to_string()]
3500        );
3501        for dir in [&report.runtime_dir, &report.records_dir] {
3502            assert!(!dir.read_only_to_commands);
3503            assert_eq!(dir.enforced_by, None);
3504            assert!(dir
3505                .not_protected_because
3506                .as_deref()
3507                .unwrap()
3508                .contains("unconfined"));
3509        }
3510        assert!(!report.temp_dir.tmp_mapped);
3511        assert_eq!(
3512            report.temp_dir.not_mapped_because.as_deref(),
3513            Some(crate::temp_dir::NotMappedReason::Unconfined.describe())
3514        );
3515        let unconfined = report.unconfined.as_ref().expect("an unconfined report");
3516        assert_eq!(unconfined.uid, current_uid());
3517        assert_eq!(unconfined.applies_to, UNCONFINED_APPLIES_TO.to_vec());
3518        assert_eq!(unconfined.resource_limits, vec!["max_open_files"]);
3519        let json = serde_json::to_value(&report).unwrap();
3520        assert_eq!(json["enforcement_tier"], "unconfined");
3521        assert_eq!(json["achieved_containment"], "unconfined");
3522        assert_eq!(json["filesystem_boundary"]["restriction"], "none");
3523        assert!(json["unconfined"].is_object(), "{json}");
3524        let rendered = report.render();
3525        assert!(rendered.contains("  achieved:  unconfined\n"), "{rendered}");
3526        assert!(rendered.contains("\nUnconfined\n  user:"), "{rendered}");
3527        assert!(rendered.contains("  uid:       "), "{rendered}");
3528        assert!(rendered.contains("  HOME:      "), "{rendered}");
3529        assert!(
3530            rendered.contains(
3531                "  applies to: shell commands, plan shell steps, process harness, harness tools\n"
3532            ),
3533            "{rendered}"
3534        );
3535        assert!(unconfined_warning(unconfined).contains("sudo"));
3536
3537        // A contained report carries the key, `null`.
3538        let contained = report_for(
3539            &sample_policy(),
3540            ContainmentClass::Scoped,
3541            EnforcementTier::KernelFull,
3542            None,
3543            None,
3544            None,
3545            None,
3546            Vec::new(),
3547            Vec::new(),
3548            Vec::new(),
3549            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3550            crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3551            IoMaxReport::default(),
3552        );
3553        let json = serde_json::to_value(&contained).unwrap();
3554        assert!(json["unconfined"].is_null(), "{json}");
3555        assert!(json.as_object().unwrap().contains_key("unconfined"));
3556    }
3557
3558    #[test]
3559    fn scope_report_mirrors_the_policy_and_the_probed_tier() {
3560        let report = report_for(
3561            &sample_policy(),
3562            ContainmentClass::Scoped,
3563            EnforcementTier::KernelFull,
3564            None,
3565            None,
3566            None,
3567            None,
3568            Vec::new(),
3569            Vec::new(),
3570            Vec::new(),
3571            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3572            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3573                EnforcementTier::KernelFull,
3574                ContainmentClass::Scoped,
3575            )),
3576            IoMaxReport::default(),
3577        );
3578
3579        assert_eq!(report.declared_containment, ContainmentClass::Scoped);
3580        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3581        assert!(report.floor_met);
3582        assert_eq!(report.shortfall_reason, None);
3583        assert_eq!(report.enforcement_tier, "landlock+seccomp");
3584        assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
3585        assert_eq!(report.network_allow, vec!["https://api.example.com"]);
3586        assert!(!report.unix_sockets);
3587        assert_eq!(report.shell_allow, vec!["python3"]);
3588        assert_eq!(report.spawn_allow, vec!["helper"]);
3589        assert_eq!(report.env_allow, vec!["TZ"]);
3590        assert!(!report.workdir_exec);
3591        assert_eq!(
3592            report.interpreter_runtime_grants,
3593            vec!["python3: /usr/lib/python3.11 (list_dir)"]
3594        );
3595    }
3596
3597    /// `--explain-scope` is where an operator finds out *why* a Landlock-capable host is reporting
3598    /// `advisory`, so the declaration and the class it produced have to appear together — in the
3599    /// struct, in the JSON, and in the rendered text.
3600    #[test]
3601    fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
3602        let policy = CapabilityPolicy {
3603            shell_allow: vec!["gcc".to_string()],
3604            workdir_exec_allowed: true,
3605            ..CapabilityPolicy::default()
3606        };
3607
3608        let report = report_for(
3609            &policy,
3610            ContainmentClass::Scoped,
3611            EnforcementTier::KernelFull,
3612            None,
3613            None,
3614            None,
3615            None,
3616            Vec::new(),
3617            Vec::new(),
3618            Vec::new(),
3619            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3620            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3621                EnforcementTier::KernelFull,
3622                ContainmentClass::Scoped,
3623            )),
3624            IoMaxReport::default(),
3625        );
3626
3627        assert!(report.workdir_exec);
3628        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3629        assert!(!report.floor_met);
3630        // The host is fully capable; the mechanism line must keep saying so, so the report does
3631        // not read as a broken host.
3632        assert_eq!(report.enforcement_tier, "landlock+seccomp");
3633        assert!(report
3634            .shortfall_reason
3635            .as_deref()
3636            .expect("refusal")
3637            .contains("workdir_exec"));
3638
3639        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3640        assert_eq!(value["workdir_exec"], true);
3641
3642        let rendered = report.render();
3643        assert!(rendered.contains("workdir exec:     true"));
3644        assert!(rendered.contains("achieved:  advisory"));
3645    }
3646
3647    /// The key is always written, on the same terms `workdir_exec` and `state_stores` are: an
3648    /// absent `io_max` identifies a runtime that predates the field, not a host that was not
3649    /// asked. "Not asked" is a value — `not-probed` — inside the object.
3650    #[test]
3651    fn scope_report_json_always_carries_io_max() {
3652        let unprobed: serde_json::Value = serde_json::to_value(report_for(
3653            &sample_policy(),
3654            ContainmentClass::Scoped,
3655            EnforcementTier::KernelFull,
3656            None,
3657            None,
3658            None,
3659            None,
3660            Vec::new(),
3661            Vec::new(),
3662            Vec::new(),
3663            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3664            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3665                EnforcementTier::KernelFull,
3666                ContainmentClass::Scoped,
3667            )),
3668            IoMaxReport::default(),
3669        ))
3670        .unwrap();
3671        assert_eq!(unprobed["io_max"]["status"], "not-probed");
3672        assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
3673        assert!(unprobed["io_max"].get("reason").is_none());
3674
3675        let enforced: serde_json::Value = serde_json::to_value(report_for(
3676            &sample_policy(),
3677            ContainmentClass::Scoped,
3678            EnforcementTier::KernelFull,
3679            None,
3680            None,
3681            None,
3682            None,
3683            Vec::new(),
3684            Vec::new(),
3685            Vec::new(),
3686            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3687            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3688                EnforcementTier::KernelFull,
3689                ContainmentClass::Scoped,
3690            )),
3691            IoMaxReport {
3692                declared_bytes_per_sec: 52_428_800,
3693                status: IoMaxStatus::Enforced,
3694                reason: None,
3695            },
3696        ))
3697        .unwrap();
3698        assert_eq!(enforced["io_max"]["status"], "enforced");
3699        assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
3700    }
3701
3702    /// A ceiling that did not apply has to be visible in the rendering an operator reads, not
3703    /// only in the JSON: this report is where the claim of an I/O bound would otherwise stand
3704    /// uncontradicted.
3705    #[test]
3706    fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
3707        let rendered = report_for(
3708            &sample_policy(),
3709            ContainmentClass::Scoped,
3710            EnforcementTier::KernelFull,
3711            None,
3712            None,
3713            None,
3714            None,
3715            Vec::new(),
3716            Vec::new(),
3717            Vec::new(),
3718            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3719            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3720                EnforcementTier::KernelFull,
3721                ContainmentClass::Scoped,
3722            )),
3723            IoMaxReport {
3724                declared_bytes_per_sec: 52_428_800,
3725                status: IoMaxStatus::Unavailable,
3726                reason: Some("the io controller is not delegated".to_string()),
3727            },
3728        )
3729        .render();
3730
3731        assert!(rendered.contains("Resource plane"), "{rendered}");
3732        assert!(rendered.contains("io.max:"), "{rendered}");
3733        assert!(rendered.contains("unavailable"), "{rendered}");
3734        assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
3735        assert!(
3736            rendered.contains("the io controller is not delegated"),
3737            "{rendered}"
3738        );
3739    }
3740
3741    /// The field is always written, including `false`, so a consumer can distinguish "this capsule
3742    /// declared nothing" from "this runtime does not know the key".
3743    #[test]
3744    fn scope_report_json_always_carries_workdir_exec() {
3745        let value: serde_json::Value = serde_json::to_value(report_for(
3746            &sample_policy(),
3747            ContainmentClass::Scoped,
3748            EnforcementTier::KernelFull,
3749            None,
3750            None,
3751            None,
3752            None,
3753            Vec::new(),
3754            Vec::new(),
3755            Vec::new(),
3756            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3757            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3758                EnforcementTier::KernelFull,
3759                ContainmentClass::Scoped,
3760            )),
3761            IoMaxReport::default(),
3762        ))
3763        .unwrap();
3764        assert_eq!(value["workdir_exec"], false);
3765    }
3766
3767    /// `--explain-scope` is a diagnostic, not a launch: a capsule declaring `staged_runtime` on a
3768    /// host that cannot back `sealed` is exactly the case an operator inspects with it, so the
3769    /// grant must be reported on every tier — including the ones where `mur run` would refuse.
3770    #[test]
3771    fn staged_runtime_grants_are_reported_on_every_tier() {
3772        let policy = CapabilityPolicy {
3773            shell_allow: vec!["python3".to_string()],
3774            shell_staged_runtime: vec![StagedRuntimeGrant {
3775                binary: "python3".to_string(),
3776                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3777                pin: "conda-4.10.3/python-3.9.19".to_string(),
3778            }],
3779            ..CapabilityPolicy::default()
3780        };
3781
3782        for tier in ALL_TIERS {
3783            let report = report_for(
3784                &policy,
3785                ContainmentClass::Sealed,
3786                *tier,
3787                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3788                None,
3789                None,
3790                None,
3791                Vec::new(),
3792                Vec::new(),
3793                Vec::new(),
3794                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3795                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3796                    *tier,
3797                    ContainmentClass::Sealed,
3798                )),
3799                IoMaxReport::default(),
3800            );
3801            assert_eq!(
3802                report.staged_runtime_grants,
3803                vec![
3804                    "python3: /opt/testbed/conda/envs/django__django \
3805                     (pin: conda-4.10.3/python-3.9.19)"
3806                ],
3807                "tier {tier:?} must still report the declared staged runtime"
3808            );
3809            assert!(
3810                report.render().contains("staged runtime"),
3811                "the rendered report must carry a staged runtime section on tier {tier:?}"
3812            );
3813        }
3814    }
3815
3816    /// The absent case: no `staged_runtime` in the manifest leaves the section empty rather than
3817    /// inventing one, and `render` still names it as `<none>` like every other empty list.
3818    #[test]
3819    fn absent_staged_runtime_reports_nothing() {
3820        let report = report_for(
3821            &sample_policy(),
3822            ContainmentClass::Scoped,
3823            EnforcementTier::KernelFull,
3824            None,
3825            None,
3826            None,
3827            None,
3828            Vec::new(),
3829            Vec::new(),
3830            Vec::new(),
3831            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3832            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3833                EnforcementTier::KernelFull,
3834                ContainmentClass::Scoped,
3835            )),
3836            IoMaxReport::default(),
3837        );
3838        assert!(report.staged_runtime_grants.is_empty());
3839        assert!(report.render().contains("staged runtime: <none>"));
3840    }
3841
3842    /// A capsule that declares `scoped` *and* opens host paths through `interpreter_runtime`
3843    /// still achieves only `scoped` — the grant set is reported, never consulted to upgrade
3844    /// the achieved class.
3845    #[test]
3846    fn grant_set_never_raises_the_achieved_class() {
3847        let report = report_for(
3848            &sample_policy(),
3849            ContainmentClass::Sealed,
3850            EnforcementTier::KernelFull,
3851            Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3852            None,
3853            None,
3854            None,
3855            Vec::new(),
3856            Vec::new(),
3857            Vec::new(),
3858            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3859            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3860                EnforcementTier::KernelFull,
3861                ContainmentClass::Sealed,
3862            )),
3863            IoMaxReport::default(),
3864        );
3865        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3866        assert!(!report.floor_met);
3867    }
3868
3869    #[test]
3870    fn scope_report_still_reports_when_the_floor_is_unmet() {
3871        let report = report_for(
3872            &sample_policy(),
3873            ContainmentClass::Sealed,
3874            EnforcementTier::EnvironmentOnly,
3875            Some(&blocked(SealedBlocker::NotLinux)),
3876            None,
3877            None,
3878            None,
3879            Vec::new(),
3880            Vec::new(),
3881            Vec::new(),
3882            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3883            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3884                EnforcementTier::EnvironmentOnly,
3885                ContainmentClass::Sealed,
3886            )),
3887            IoMaxReport::default(),
3888        );
3889
3890        assert_eq!(report.declared_containment, ContainmentClass::Sealed);
3891        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3892        assert!(!report.floor_met);
3893        assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
3894        assert_eq!(report.enforcement_tier, "none");
3895    }
3896
3897    #[test]
3898    fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
3899        let report = report_for(
3900            &sample_policy(),
3901            ContainmentClass::Sealed,
3902            EnforcementTier::KernelSealed,
3903            None,
3904            None,
3905            None,
3906            None,
3907            Vec::new(),
3908            Vec::new(),
3909            Vec::new(),
3910            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3911            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3912                EnforcementTier::KernelSealed,
3913                ContainmentClass::Sealed,
3914            )),
3915            IoMaxReport::default(),
3916        );
3917        assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
3918        assert_eq!(report.host_containment, ContainmentClass::Sealed);
3919        assert!(report.floor_met);
3920        assert_eq!(
3921            report.enforcement_tier,
3922            "mountns+pivot_root+landlock+seccomp"
3923        );
3924        assert_eq!(
3925            report.filesystem_boundary.restriction,
3926            FilesystemRestriction::Absent
3927        );
3928    }
3929
3930    /// Every host tier × declared class: the class, the tier, the filesystem boundary, the ruleset
3931    /// ABI and the host's ceiling a report names all follow the one decision, so no report can
3932    /// pair `mountns+pivot_root+landlock+seccomp` with `restriction: enforced`.
3933    #[test]
3934    fn every_report_field_follows_the_decision() {
3935        for &host_tier in ALL_TIERS {
3936            for &declared in ALL_CLASSES {
3937                let decision = ContainmentDecision::decide(host_tier, declared, false, false);
3938                let report = scope_report_for_tier(
3939                    &sample_policy(),
3940                    &decision,
3941                    None,
3942                    None,
3943                    Some(6),
3944                    None,
3945                    Vec::new(),
3946                    Vec::new(),
3947                    Vec::new(),
3948                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3949                    crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3950                    IoMaxReport::default(),
3951                );
3952                let what = format!("{host_tier:?} {declared}");
3953                assert_eq!(report.declared_containment, declared, "{what}");
3954                assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
3955                assert_eq!(
3956                    report.enforcement_tier,
3957                    decision.enforcement_tier(),
3958                    "{what}"
3959                );
3960                assert_eq!(
3961                    report.host_containment,
3962                    decision.host_containment(),
3963                    "{what}"
3964                );
3965                assert_eq!(
3966                    report.landlock_abi,
3967                    crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
3968                    "{what}"
3969                );
3970                let restriction = filesystem_boundary(decision.applied_tier()).restriction;
3971                assert_eq!(
3972                    report.filesystem_boundary.restriction, restriction,
3973                    "{what}"
3974                );
3975                assert_eq!(
3976                    restriction,
3977                    match report.enforcement_tier {
3978                        "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
3979                        "landlock+seccomp" => FilesystemRestriction::Enforced,
3980                        _ => FilesystemRestriction::Advisory,
3981                    },
3982                    "{what}"
3983                );
3984                assert_eq!(
3985                    report.temp_dir,
3986                    temp_dir_report(
3987                        crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3988                        &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3989                    ),
3990                    "{what}"
3991                );
3992            }
3993        }
3994    }
3995
3996    /// The host's ceiling is its own field and its own line, directly under the session's class,
3997    /// so a reader sees both and cannot take one for the other.
3998    #[test]
3999    fn host_containment_is_reported_beside_the_achieved_class() {
4000        let report = report_for(
4001            &sample_policy(),
4002            ContainmentClass::Scoped,
4003            EnforcementTier::KernelSealed,
4004            None,
4005            None,
4006            None,
4007            None,
4008            Vec::new(),
4009            Vec::new(),
4010            Vec::new(),
4011            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4012            crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
4013            IoMaxReport::default(),
4014        );
4015        let value = serde_json::to_value(&report).unwrap();
4016        assert_eq!(value["achieved_containment"], "scoped");
4017        assert_eq!(value["host_containment"], "sealed");
4018        assert_eq!(value["enforcement_tier"], "landlock+seccomp");
4019        assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
4020
4021        let rendered = report.render();
4022        assert!(
4023            rendered.contains("  achieved:  scoped\n  host can back: sealed\n  floor met: yes\n"),
4024            "{rendered}"
4025        );
4026        assert!(
4027            rendered.contains("  mechanism: landlock+seccomp\n"),
4028            "{rendered}"
4029        );
4030    }
4031
4032    #[test]
4033    fn scope_report_json_uses_wire_names() {
4034        let report = report_for(
4035            &CapabilityPolicy::default(),
4036            ContainmentClass::Advisory,
4037            EnforcementTier::KernelSeccompOnly,
4038            None,
4039            None,
4040            None,
4041            None,
4042            Vec::new(),
4043            Vec::new(),
4044            Vec::new(),
4045            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4046            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4047                EnforcementTier::KernelSeccompOnly,
4048                ContainmentClass::Advisory,
4049            )),
4050            IoMaxReport::default(),
4051        );
4052        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
4053
4054        assert_eq!(value["declared_containment"], "advisory");
4055        assert_eq!(value["achieved_containment"], "advisory");
4056        assert_eq!(value["host_containment"], "advisory");
4057        assert_eq!(value["floor_met"], true);
4058        assert_eq!(value["enforcement_tier"], "seccomp-only");
4059        // Absent rather than null when the floor is met.
4060        assert!(value.get("shortfall_reason").is_none());
4061    }
4062
4063    /// `landlock_abi` is the ruleset ABI of the *applied* tier: the kernel's capped at 3 on a
4064    /// Landlock tier, `null` below one, and the key is present either way.
4065    #[test]
4066    fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
4067        let report = |tier, kernel| {
4068            report_for(
4069                &sample_policy(),
4070                ContainmentClass::Scoped,
4071                tier,
4072                None,
4073                None,
4074                kernel,
4075                None,
4076                Vec::new(),
4077                Vec::new(),
4078                Vec::new(),
4079                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4080                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4081                    tier,
4082                    ContainmentClass::Scoped,
4083                )),
4084                IoMaxReport::default(),
4085            )
4086        };
4087
4088        for (tier, kernel, expected) in [
4089            (EnforcementTier::KernelFull, Some(8), Some(3)),
4090            (EnforcementTier::KernelSealed, Some(8), Some(3)),
4091            (EnforcementTier::KernelFull, Some(2), Some(2)),
4092            (EnforcementTier::KernelFull, Some(1), Some(1)),
4093            (EnforcementTier::KernelFull, None, None),
4094            (EnforcementTier::KernelSeccompOnly, Some(8), None),
4095            (EnforcementTier::EnvironmentOnly, None, None),
4096        ] {
4097            let scope = report(tier, kernel);
4098            assert_eq!(
4099                scope.landlock_abi, expected,
4100                "{tier:?} with kernel {kernel:?}"
4101            );
4102
4103            let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
4104            assert!(value.as_object().unwrap().contains_key("landlock_abi"));
4105            match expected {
4106                Some(abi) => assert_eq!(value["landlock_abi"], abi),
4107                None => assert!(value["landlock_abi"].is_null()),
4108            }
4109
4110            let rendered = scope.render();
4111            let line = match expected {
4112                Some(abi) => format!("  landlock ABI: {abi}\n"),
4113                None => "  landlock ABI: n/a\n".to_string(),
4114            };
4115            assert!(rendered.contains(&line), "{rendered}");
4116            assert!(
4117                rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
4118            );
4119        }
4120    }
4121
4122    /// The grant is reported, and it never moves a class, a floor or a mechanism: the same tier
4123    /// with four different grants produces four reports that differ in exactly one field.
4124    #[test]
4125    fn the_userns_grant_is_reported_without_changing_any_verdict() {
4126        let baseline = report_for(
4127            &sample_policy(),
4128            ContainmentClass::Advisory,
4129            EnforcementTier::KernelSealed,
4130            None,
4131            None,
4132            None,
4133            None,
4134            Vec::new(),
4135            Vec::new(),
4136            Vec::new(),
4137            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4138            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4139                EnforcementTier::KernelSealed,
4140                ContainmentClass::Advisory,
4141            )),
4142            IoMaxReport::default(),
4143        );
4144
4145        for grant in UsernsGrant::ALL {
4146            let report = report_for(
4147                &sample_policy(),
4148                ContainmentClass::Advisory,
4149                EnforcementTier::KernelSealed,
4150                None,
4151                Some(*grant),
4152                None,
4153                None,
4154                Vec::new(),
4155                Vec::new(),
4156                Vec::new(),
4157                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4158                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4159                    EnforcementTier::KernelSealed,
4160                    ContainmentClass::Advisory,
4161                )),
4162                IoMaxReport::default(),
4163            );
4164            assert_eq!(report.achieved_containment, baseline.achieved_containment);
4165            assert_eq!(report.floor_met, baseline.floor_met);
4166            assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
4167            assert_eq!(report.userns_grant, Some(*grant));
4168
4169            let value: serde_json::Value = serde_json::to_value(&report).unwrap();
4170            assert_eq!(value["userns_grant"], grant.wire_name());
4171            assert!(report
4172                .render()
4173                .contains(&format!("userns grant: {}", grant.wire_name())));
4174        }
4175
4176        // Off Linux the key is present and null — AppArmor does not exist there, which is not the
4177        // same statement as "the grant was withheld".
4178        let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
4179        assert!(value["userns_grant"].is_null());
4180        assert!(baseline.render().contains("userns grant: n/a"));
4181    }
4182
4183    #[test]
4184    fn rendered_report_names_both_classes_and_the_refusal() {
4185        let rendered = report_for(
4186            &sample_policy(),
4187            ContainmentClass::Sealed,
4188            EnforcementTier::KernelFull,
4189            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
4190            None,
4191            None,
4192            None,
4193            Vec::new(),
4194            Vec::new(),
4195            Vec::new(),
4196            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4197            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4198                EnforcementTier::KernelFull,
4199                ContainmentClass::Sealed,
4200            )),
4201            IoMaxReport::default(),
4202        )
4203        .render();
4204
4205        assert!(rendered.contains("declared:  sealed"));
4206        assert!(rendered.contains("achieved:  scoped"));
4207        assert!(rendered.contains("floor met: no"));
4208        assert!(rendered.contains("mur-sealed"));
4209        assert!(rendered.contains("would refuse to launch"));
4210    }
4211
4212    /// The invariant the whole design rests on: an export is a disclosure, not a grant. Declaring
4213    /// one must leave the achieved class — and everything else the report says about what the
4214    /// guest can reach — byte-identical.
4215    #[test]
4216    fn an_export_never_changes_what_the_report_says_about_containment() {
4217        let export = FileExport {
4218            root: "out/".to_string(),
4219            mode: ExportMode::ReadOnly,
4220            max_bytes: 10 * 1024 * 1024,
4221        };
4222        for tier in ALL_TIERS {
4223            for workdir_exec in [false, true] {
4224                let policy = CapabilityPolicy {
4225                    workdir_exec_allowed: workdir_exec,
4226                    ..sample_policy()
4227                };
4228                let without = report_for(
4229                    &policy,
4230                    ContainmentClass::Scoped,
4231                    *tier,
4232                    None,
4233                    None,
4234                    None,
4235                    None,
4236                    Vec::new(),
4237                    Vec::new(),
4238                    Vec::new(),
4239                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4240                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4241                        *tier,
4242                        ContainmentClass::Scoped,
4243                    )),
4244                    IoMaxReport::default(),
4245                );
4246                let with = report_for(
4247                    &policy,
4248                    ContainmentClass::Scoped,
4249                    *tier,
4250                    None,
4251                    None,
4252                    None,
4253                    Some(&Exports {
4254                        files: Some(export.clone()),
4255                        peer_files: None,
4256                        peer_tasks: None,
4257                    }),
4258                    Vec::new(),
4259                    Vec::new(),
4260                    Vec::new(),
4261                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4262                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4263                        *tier,
4264                        ContainmentClass::Scoped,
4265                    )),
4266                    IoMaxReport::default(),
4267                );
4268                assert_eq!(
4269                    with.achieved_containment, without.achieved_containment,
4270                    "tier {tier:?}, workdir_exec {workdir_exec}"
4271                );
4272                assert_eq!(with.enforcement_tier, without.enforcement_tier);
4273                assert_eq!(with.floor_met, without.floor_met);
4274                assert_eq!(with.shortfall_reason, without.shortfall_reason);
4275                // Everything but the one new field is identical, which is the strongest form of
4276                // "an export changes nothing else" this report can state.
4277                assert_eq!(
4278                    ScopeReport {
4279                        exports_files: None,
4280                        ..with.clone()
4281                    },
4282                    without
4283                );
4284                assert_eq!(
4285                    with.exports_files,
4286                    Some(ExportsFilesReport {
4287                        root: "out/".to_string(),
4288                        mode: ExportMode::ReadOnly,
4289                        max_bytes: 10 * 1024 * 1024,
4290                    })
4291                );
4292            }
4293        }
4294    }
4295
4296    /// The same invariant for the peer-handoff grants, and stated separately because they are a
4297    /// separate authoriser: declaring either half must leave the achieved class, the tier and the
4298    /// floor verdict byte-identical, and must change no field of the report but its own.
4299    #[test]
4300    fn peer_handoff_never_changes_what_the_report_says_about_containment() {
4301        let peer_files = PeerFilesExport {
4302            root: "out/".to_string(),
4303            max_ttl_secs: Some(900),
4304            max_bytes: 10 * 1024 * 1024,
4305        };
4306        for tier in ALL_TIERS {
4307            for workdir_exec in [false, true] {
4308                let without_policy = CapabilityPolicy {
4309                    workdir_exec_allowed: workdir_exec,
4310                    ..sample_policy()
4311                };
4312                let with_policy = CapabilityPolicy {
4313                    peer_fetch_allow: vec!["localhost:41234".to_string()],
4314                    ..without_policy.clone()
4315                };
4316                let without = report_for(
4317                    &without_policy,
4318                    ContainmentClass::Scoped,
4319                    *tier,
4320                    None,
4321                    None,
4322                    None,
4323                    None,
4324                    Vec::new(),
4325                    Vec::new(),
4326                    Vec::new(),
4327                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4328                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4329                        *tier,
4330                        ContainmentClass::Scoped,
4331                    )),
4332                    IoMaxReport::default(),
4333                );
4334                let with = report_for(
4335                    &with_policy,
4336                    ContainmentClass::Scoped,
4337                    *tier,
4338                    None,
4339                    None,
4340                    None,
4341                    Some(&Exports {
4342                        files: None,
4343                        peer_files: Some(peer_files.clone()),
4344                        peer_tasks: None,
4345                    }),
4346                    Vec::new(),
4347                    Vec::new(),
4348                    Vec::new(),
4349                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4350                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4351                        *tier,
4352                        ContainmentClass::Scoped,
4353                    )),
4354                    IoMaxReport::default(),
4355                );
4356                assert_eq!(
4357                    with.achieved_containment, without.achieved_containment,
4358                    "tier {tier:?}, workdir_exec {workdir_exec}"
4359                );
4360                assert_eq!(with.enforcement_tier, without.enforcement_tier);
4361                assert_eq!(with.floor_met, without.floor_met);
4362                assert_eq!(with.shortfall_reason, without.shortfall_reason);
4363                assert_eq!(
4364                    ScopeReport {
4365                        peer_files: None,
4366                        peer_fetch_allow: Vec::new(),
4367                        ..with.clone()
4368                    },
4369                    without
4370                );
4371                assert_eq!(
4372                    with.peer_files,
4373                    Some(PeerFilesReport {
4374                        root: "out/".to_string(),
4375                        max_ttl_secs: 900,
4376                        max_bytes: 10 * 1024 * 1024,
4377                    })
4378                );
4379                assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
4380                // Declaring `peer_fetch` does not widen `network.allow`, on either side of the
4381                // report: the two lists are carried separately and neither is derived from the
4382                // other.
4383                assert_eq!(with.network_allow, without.network_allow);
4384            }
4385        }
4386    }
4387
4388    /// Both peer fields are written whether or not they were declared, on the same terms as
4389    /// `exports_files`: an absent key identifies an older runtime, a `null`/`[]` identifies a
4390    /// capsule that declined.
4391    #[test]
4392    fn the_peer_fields_are_always_serialized() {
4393        let undeclared: serde_json::Value = serde_json::to_value(report_for(
4394            &sample_policy(),
4395            ContainmentClass::Advisory,
4396            EnforcementTier::KernelSealed,
4397            None,
4398            None,
4399            None,
4400            None,
4401            Vec::new(),
4402            Vec::new(),
4403            Vec::new(),
4404            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4405            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4406                EnforcementTier::KernelSealed,
4407                ContainmentClass::Advisory,
4408            )),
4409            IoMaxReport::default(),
4410        ))
4411        .unwrap();
4412        assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
4413        assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
4414
4415        let declared: serde_json::Value = serde_json::to_value(report_for(
4416            &CapabilityPolicy {
4417                peer_fetch_allow: vec!["localhost:41234".to_string()],
4418                ..sample_policy()
4419            },
4420            ContainmentClass::Advisory,
4421            EnforcementTier::KernelSealed,
4422            None,
4423            None,
4424            None,
4425            Some(&Exports {
4426                files: None,
4427                peer_files: Some(PeerFilesExport {
4428                    root: "out/".to_string(),
4429                    max_ttl_secs: None,
4430                    max_bytes: 10 * 1024 * 1024,
4431                }),
4432                peer_tasks: None,
4433            }),
4434            Vec::new(),
4435            Vec::new(),
4436            Vec::new(),
4437            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4438            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4439                EnforcementTier::KernelSealed,
4440                ContainmentClass::Advisory,
4441            )),
4442            IoMaxReport::default(),
4443        ))
4444        .unwrap();
4445        assert_eq!(
4446            declared["peer_files"],
4447            serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
4448        );
4449        assert_eq!(
4450            declared["peer_fetch_allow"],
4451            serde_json::json!(["localhost:41234"])
4452        );
4453    }
4454
4455    #[test]
4456    fn render_names_peer_handoff_in_both_directions() {
4457        let undeclared = report_for(
4458            &sample_policy(),
4459            ContainmentClass::Advisory,
4460            EnforcementTier::KernelSealed,
4461            None,
4462            None,
4463            None,
4464            None,
4465            Vec::new(),
4466            Vec::new(),
4467            Vec::new(),
4468            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4469            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4470                EnforcementTier::KernelSealed,
4471                ContainmentClass::Advisory,
4472            )),
4473            IoMaxReport::default(),
4474        )
4475        .render();
4476        assert!(undeclared.contains("Peer handoff"));
4477        assert!(undeclared.contains("exports.peer_files: <none>"));
4478        assert!(undeclared.contains("peer_fetch allow: <none>"));
4479
4480        let declared = report_for(
4481            &CapabilityPolicy {
4482                peer_fetch_allow: vec!["localhost:41234".to_string()],
4483                ..sample_policy()
4484            },
4485            ContainmentClass::Advisory,
4486            EnforcementTier::KernelSealed,
4487            None,
4488            None,
4489            None,
4490            Some(&Exports {
4491                files: None,
4492                peer_files: Some(PeerFilesExport {
4493                    root: "out/".to_string(),
4494                    max_ttl_secs: Some(900),
4495                    max_bytes: 4096,
4496                }),
4497                peer_tasks: None,
4498            }),
4499            Vec::new(),
4500            Vec::new(),
4501            Vec::new(),
4502            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4503            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4504                EnforcementTier::KernelSealed,
4505                ContainmentClass::Advisory,
4506            )),
4507            IoMaxReport::default(),
4508        )
4509        .render();
4510        assert!(declared.contains("exports.peer_files root: out/"));
4511        assert!(declared.contains("max handle ttl:          900s"));
4512        assert!(declared.contains("max bytes:               4096 (per file)"));
4513        assert!(declared.contains("- localhost:41234"));
4514    }
4515
4516    fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
4517        report_for(
4518            &sample_policy(),
4519            ContainmentClass::Scoped,
4520            tier,
4521            None,
4522            None,
4523            None,
4524            peer_tasks
4525                .map(|accept| Exports {
4526                    files: None,
4527                    peer_files: None,
4528                    peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
4529                })
4530                .as_ref(),
4531            Vec::new(),
4532            Vec::new(),
4533            Vec::new(),
4534            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4535            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4536                tier,
4537                ContainmentClass::Scoped,
4538            )),
4539            IoMaxReport::default(),
4540        )
4541    }
4542
4543    /// Consenting to peer tasks is an export like the others: it moves no containment field and
4544    /// no other field of the report but its own. Absent and `accept: false` are one report.
4545    #[test]
4546    fn peer_tasks_is_reported_and_moves_no_other_field() {
4547        for tier in ALL_TIERS {
4548            let absent = report_with_peer_tasks(*tier, None);
4549            let refused = report_with_peer_tasks(*tier, Some(false));
4550            let accepted = report_with_peer_tasks(*tier, Some(true));
4551            assert!(!absent.peer_tasks);
4552            assert_eq!(refused, absent, "tier {tier:?}");
4553            assert!(accepted.peer_tasks);
4554            assert_eq!(accepted.achieved_containment, absent.achieved_containment);
4555            assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
4556            assert_eq!(accepted.floor_met, absent.floor_met);
4557            assert_eq!(
4558                ScopeReport {
4559                    peer_tasks: false,
4560                    ..accepted.clone()
4561                },
4562                absent
4563            );
4564        }
4565    }
4566
4567    /// `peer_tasks` is always written, as a boolean, so an absent key identifies an older runtime.
4568    #[test]
4569    fn peer_tasks_is_always_serialized() {
4570        let tier = EnforcementTier::KernelFull;
4571        let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
4572        assert_eq!(absent["peer_tasks"], serde_json::json!(false));
4573        let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
4574        assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
4575    }
4576
4577    #[test]
4578    fn render_names_the_peer_task_posture_under_peer_handoff() {
4579        let tier = EnforcementTier::KernelFull;
4580        for (declared, line) in [
4581            (None, "  exports.peer_tasks: refuse\n"),
4582            (Some(false), "  exports.peer_tasks: refuse\n"),
4583            (Some(true), "  exports.peer_tasks: accept\n"),
4584        ] {
4585            let rendered = report_with_peer_tasks(tier, declared).render();
4586            let section = rendered
4587                .split("\nPeer handoff\n")
4588                .nth(1)
4589                .and_then(|rest| rest.split("\n\n").next())
4590                .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
4591            assert!(
4592                section.contains(line.trim_start_matches(' ').trim_end()),
4593                "{declared:?}:\n{section}"
4594            );
4595            assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
4596        }
4597    }
4598
4599    /// A declared state store is reported, and moves nothing else. Same discipline the export
4600    /// reports are held to: it is copied through, never consulted, so a durable directory grant
4601    /// cannot talk a host into reporting a class it does not back.
4602    #[test]
4603    fn a_state_store_is_reported_and_moves_no_other_field() {
4604        let policy = sample_policy();
4605        let stores = vec![StateStoreReport {
4606            artifact: "murmur/tool-corpus".to_string(),
4607            store: "shey".to_string(),
4608            host_path: "/home/dev/.murmur/state/shey".to_string(),
4609        }];
4610
4611        let without = report_for(
4612            &policy,
4613            ContainmentClass::Scoped,
4614            EnforcementTier::KernelFull,
4615            None,
4616            None,
4617            None,
4618            None,
4619            Vec::new(),
4620            Vec::new(),
4621            Vec::new(),
4622            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4623            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4624                EnforcementTier::KernelFull,
4625                ContainmentClass::Scoped,
4626            )),
4627            IoMaxReport::default(),
4628        );
4629        let with = report_for(
4630            &policy,
4631            ContainmentClass::Scoped,
4632            EnforcementTier::KernelFull,
4633            None,
4634            None,
4635            None,
4636            None,
4637            stores.clone(),
4638            Vec::new(),
4639            Vec::new(),
4640            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4641            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4642                EnforcementTier::KernelFull,
4643                ContainmentClass::Scoped,
4644            )),
4645            IoMaxReport::default(),
4646        );
4647
4648        assert_eq!(with.state_stores, stores);
4649        assert!(without.state_stores.is_empty());
4650        assert_eq!(with.achieved_containment, without.achieved_containment);
4651        assert_eq!(with.floor_met, without.floor_met);
4652        assert_eq!(with.enforcement_tier, without.enforcement_tier);
4653        assert_eq!(with.shortfall_reason, without.shortfall_reason);
4654        // The only field that moved is its own.
4655        assert_eq!(
4656            ScopeReport {
4657                state_stores: Vec::new(),
4658                ..with.clone()
4659            },
4660            without
4661        );
4662
4663        // Rendered under the grants, one line per declaring artifact; `<none>` when empty.
4664        assert!(with
4665            .render()
4666            .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
4667        assert!(without.render().contains("state stores: <none>"));
4668
4669        // Always an array in JSON, never skipped: an absent key means an older runtime, not a
4670        // capsule that declined a store.
4671        let json = serde_json::to_value(&without).unwrap();
4672        assert_eq!(json["state_stores"], serde_json::json!([]));
4673        assert_eq!(
4674            serde_json::to_value(&with).unwrap()["state_stores"],
4675            serde_json::json!([{
4676                "artifact": "murmur/tool-corpus",
4677                "store": "shey",
4678                "host_path": "/home/dev/.murmur/state/shey",
4679            }])
4680        );
4681    }
4682
4683    fn extra_roots_preopen(artifact: &str, role: &str, scope: Option<&str>) -> PreopenReport {
4684        PreopenReport {
4685            artifact: artifact.to_string(),
4686            role: role.to_string(),
4687            scope: scope.map(str::to_string),
4688            surface: if scope.is_some() {
4689                PreopenSurface::ScopedSubtree
4690            } else {
4691                PreopenSurface::WholeWorkdir
4692            },
4693            runtime_dir_reach: (role == "tool" && scope.is_none())
4694                .then_some(RuntimeDirReach::MurmurRuntime),
4695            extra_roots: Vec::new(),
4696        }
4697    }
4698
4699    fn two_host_folders() -> Vec<crate::guest_roots::GuestRoot> {
4700        let host_path = |path: &str, access| crate::host_paths::HostPath {
4701            declared: path.to_string(),
4702            path: std::path::PathBuf::from(path),
4703            access,
4704            credential_locations: Vec::new(),
4705        };
4706        crate::guest_roots::extra_guest_roots_for(
4707            ContainmentClass::Advisory,
4708            &[
4709                host_path("/data/ref", crate::host_paths::HostPathAccess::Read),
4710                host_path("/data/out", crate::host_paths::HostPathAccess::ReadWrite),
4711            ],
4712        )
4713    }
4714
4715    /// Every extra root serializes with its four keys, and an entry holding none serializes `[]`.
4716    #[test]
4717    fn extra_roots_serialize_with_four_keys_always_present() {
4718        let mut reports = vec![
4719            extra_roots_preopen("probe", "tool", None),
4720            extra_roots_preopen("cache-tool", "tool", Some("cache")),
4721            extra_roots_preopen("driver", "driver", None),
4722        ];
4723        attach_extra_roots(&mut reports, &two_host_folders());
4724        let json = serde_json::to_value(&reports).unwrap();
4725        assert_eq!(
4726            json[0]["extra_roots"],
4727            serde_json::json!([
4728                {
4729                    "guest_path": "murmur-host/data/ref",
4730                    "path": "/data/ref",
4731                    "access": "read",
4732                    "source": "host_paths",
4733                },
4734                {
4735                    "guest_path": "murmur-host/data/out",
4736                    "path": "/data/out",
4737                    "access": "read-write",
4738                    "source": "host_paths",
4739                },
4740            ])
4741        );
4742        assert_eq!(json[1]["extra_roots"], serde_json::json!([]));
4743        assert_eq!(json[2]["extra_roots"], serde_json::json!([]));
4744
4745        let mut reports = vec![extra_roots_preopen("probe", "tool", None)];
4746        attach_extra_roots(
4747            &mut reports,
4748            &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4749        );
4750        assert_eq!(
4751            serde_json::to_value(&reports[0].extra_roots).unwrap(),
4752            serde_json::json!([{
4753                "guest_path": "murmur-host",
4754                "path": "/",
4755                "access": "read-write",
4756                "source": "unconfined",
4757            }])
4758        );
4759    }
4760
4761    /// A tool's extra roots render after its runtime directory: each host folder with its access,
4762    /// or the whole host when the session is unconfined.
4763    #[test]
4764    fn extra_roots_render_after_the_runtime_directory() {
4765        let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4766        attach_extra_roots(&mut reports, &two_host_folders());
4767        assert_eq!(reports[0].extra_roots.len(), 2);
4768        assert!(
4769            reports[0].render().ends_with(
4770                "; runtime directory read-only at murmur-runtime/; host folders under \
4771                 murmur-host/: /data/ref (read), /data/out (read-write)"
4772            ),
4773            "{}",
4774            reports[0].render()
4775        );
4776
4777        let mut whole = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4778        attach_extra_roots(
4779            &mut whole,
4780            &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4781        );
4782        assert!(whole[0].render().ends_with(
4783            "; runtime directory read-only at murmur-runtime/; whole host read-write \
4784             under murmur-host/"
4785        ));
4786    }
4787
4788    /// Without roots the line is exactly the one a session without extra roots renders.
4789    #[test]
4790    fn extra_roots_absent_leave_the_line_unchanged() {
4791        let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4792        attach_extra_roots(&mut reports, &[]);
4793        assert_eq!(
4794            reports[0].render(),
4795            "wasm-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4796             declared; runtime directory read-only at murmur-runtime/"
4797        );
4798    }
4799
4800    /// The preopen set is reported and moves nothing else, on the same terms the state-store
4801    /// report beside it is held to. It is also the field that answers what `filesystem_scope`
4802    /// does not: a capsule-wide scope is joined into no guest preopen root, so both can be
4803    /// present and disagree without either being wrong.
4804    #[test]
4805    fn preopens_are_reported_and_move_no_other_field() {
4806        let policy = sample_policy();
4807        let preopens = vec![
4808            PreopenReport {
4809                artifact: "notes-tool".to_string(),
4810                role: "tool".to_string(),
4811                scope: None,
4812                surface: PreopenSurface::WholeWorkdir,
4813                runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
4814                extra_roots: Vec::new(),
4815            },
4816            PreopenReport {
4817                artifact: "cache-tool".to_string(),
4818                role: "tool".to_string(),
4819                scope: Some("cache".to_string()),
4820                surface: PreopenSurface::ScopedSubtree,
4821                runtime_dir_reach: None,
4822                extra_roots: Vec::new(),
4823            },
4824            PreopenReport {
4825                artifact: "telemetry-hook".to_string(),
4826                role: "hook".to_string(),
4827                scope: None,
4828                surface: PreopenSurface::Nothing,
4829                runtime_dir_reach: None,
4830                extra_roots: Vec::new(),
4831            },
4832        ];
4833
4834        let without = report_for(
4835            &policy,
4836            ContainmentClass::Scoped,
4837            EnforcementTier::KernelFull,
4838            None,
4839            None,
4840            None,
4841            None,
4842            Vec::new(),
4843            Vec::new(),
4844            Vec::new(),
4845            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4846            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4847                EnforcementTier::KernelFull,
4848                ContainmentClass::Scoped,
4849            )),
4850            IoMaxReport::default(),
4851        );
4852        let with = report_for(
4853            &policy,
4854            ContainmentClass::Scoped,
4855            EnforcementTier::KernelFull,
4856            None,
4857            None,
4858            None,
4859            None,
4860            Vec::new(),
4861            Vec::new(),
4862            preopens.clone(),
4863            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4864            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4865                EnforcementTier::KernelFull,
4866                ContainmentClass::Scoped,
4867            )),
4868            IoMaxReport::default(),
4869        );
4870
4871        assert_eq!(with.preopens, preopens);
4872        assert!(without.preopens.is_empty());
4873        // The only fields that moved are its own and the route table, which reads each entry's
4874        // role off the preopens.
4875        assert_eq!(
4876            ScopeReport {
4877                preopens: Vec::new(),
4878                routes: without.routes.clone(),
4879                ..with.clone()
4880            },
4881            without
4882        );
4883
4884        // Three visibly different lines under `Effective grants`; `<none>` when empty.
4885        let rendered = with.render();
4886        assert!(rendered.contains(
4887            "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4888             declared; runtime directory read-only at murmur-runtime/"
4889        ));
4890        assert!(rendered.contains(
4891            "- cache-tool (tool): one subtree of the accessible workdir — \
4892             capabilities.filesystem.scope: cache"
4893        ));
4894        assert!(rendered.contains(
4895            "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
4896        ));
4897        assert!(without.render().contains("preopens: <none>"));
4898
4899        // Always an array in JSON, never skipped, with an undeclared scope as `null`.
4900        assert_eq!(
4901            serde_json::to_value(&without).unwrap()["preopens"],
4902            serde_json::json!([])
4903        );
4904        assert_eq!(
4905            serde_json::to_value(&with).unwrap()["preopens"],
4906            serde_json::json!([
4907                {
4908                    "artifact": "notes-tool",
4909                    "role": "tool",
4910                    "scope": null,
4911                    "surface": "whole-workdir",
4912                    "runtime_dir_reach": "murmur-runtime",
4913                    "extra_roots": [],
4914                },
4915                {
4916                    "artifact": "cache-tool",
4917                    "role": "tool",
4918                    "scope": "cache",
4919                    "surface": "scoped-subtree",
4920                    "runtime_dir_reach": null,
4921                    "extra_roots": [],
4922                },
4923                {
4924                    "artifact": "telemetry-hook",
4925                    "role": "hook",
4926                    "scope": null,
4927                    "surface": "nothing",
4928                    "runtime_dir_reach": null,
4929                    "extra_roots": [],
4930                },
4931            ])
4932        );
4933    }
4934
4935    /// `exports_files` is written whether or not it was declared, on the same terms as
4936    /// `workdir_exec`: an absent key identifies an older runtime, a `null` a capsule that
4937    /// exported nothing.
4938    #[test]
4939    fn exports_files_is_always_serialized() {
4940        let undeclared = serde_json::to_value(report_for(
4941            &sample_policy(),
4942            ContainmentClass::Advisory,
4943            EnforcementTier::KernelFull,
4944            None,
4945            None,
4946            None,
4947            None,
4948            Vec::new(),
4949            Vec::new(),
4950            Vec::new(),
4951            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4952            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4953                EnforcementTier::KernelFull,
4954                ContainmentClass::Advisory,
4955            )),
4956            IoMaxReport::default(),
4957        ))
4958        .unwrap();
4959        assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
4960
4961        let declared = serde_json::to_value(report_for(
4962            &sample_policy(),
4963            ContainmentClass::Advisory,
4964            EnforcementTier::KernelFull,
4965            None,
4966            None,
4967            None,
4968            Some(&Exports {
4969                files: Some(FileExport {
4970                    root: "out/".to_string(),
4971                    mode: ExportMode::ReadOnly,
4972                    max_bytes: 10_485_760,
4973                }),
4974                peer_files: None,
4975                peer_tasks: None,
4976            }),
4977            Vec::new(),
4978            Vec::new(),
4979            Vec::new(),
4980            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4981            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4982                EnforcementTier::KernelFull,
4983                ContainmentClass::Advisory,
4984            )),
4985            IoMaxReport::default(),
4986        ))
4987        .unwrap();
4988        assert_eq!(
4989            declared["exports_files"],
4990            serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
4991        );
4992    }
4993
4994    #[test]
4995    fn render_names_the_resource_plane_in_both_directions() {
4996        let undeclared = report_for(
4997            &sample_policy(),
4998            ContainmentClass::Advisory,
4999            EnforcementTier::KernelFull,
5000            None,
5001            None,
5002            None,
5003            None,
5004            Vec::new(),
5005            Vec::new(),
5006            Vec::new(),
5007            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5008            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5009                EnforcementTier::KernelFull,
5010                ContainmentClass::Advisory,
5011            )),
5012            IoMaxReport::default(),
5013        )
5014        .render();
5015        assert!(
5016            undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
5017            "{undeclared}"
5018        );
5019
5020        let declared = report_for(
5021            &sample_policy(),
5022            ContainmentClass::Advisory,
5023            EnforcementTier::KernelFull,
5024            None,
5025            None,
5026            None,
5027            Some(&Exports {
5028                files: Some(FileExport {
5029                    root: "out/".to_string(),
5030                    mode: ExportMode::ReadOnly,
5031                    max_bytes: 10_485_760,
5032                }),
5033                peer_files: None,
5034                peer_tasks: None,
5035            }),
5036            Vec::new(),
5037            Vec::new(),
5038            Vec::new(),
5039            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5040            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5041                EnforcementTier::KernelFull,
5042                ContainmentClass::Advisory,
5043            )),
5044            IoMaxReport::default(),
5045        )
5046        .render();
5047        assert!(declared.contains("exports.files root: out/"), "{declared}");
5048        assert!(
5049            declared.contains("mode:               read-only"),
5050            "{declared}"
5051        );
5052        assert!(
5053            declared.contains("max bytes:          10485760 (per file)"),
5054            "{declared}"
5055        );
5056    }
5057
5058    // ── read_only reporting ───────────────────────────────────────────────────
5059
5060    fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
5061        let policy = CapabilityPolicy {
5062            read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
5063            shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
5064            ..CapabilityPolicy::default()
5065        };
5066        report_for(
5067            &policy,
5068            ContainmentClass::Scoped,
5069            EnforcementTier::KernelFull,
5070            None,
5071            None,
5072            None,
5073            None,
5074            Vec::new(),
5075            Vec::new(),
5076            Vec::new(),
5077            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5078            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5079                EnforcementTier::KernelFull,
5080                ContainmentClass::Scoped,
5081            )),
5082            IoMaxReport::default(),
5083        )
5084    }
5085
5086    /// With no allowlisted interpreter the declaration holds everywhere the check can reach, and
5087    /// the report says exactly that and nothing stronger.
5088    #[test]
5089    fn declared_read_only_with_no_interpreter_reports_as_enforced() {
5090        let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
5091        assert_eq!(
5092            report.read_only_paths,
5093            vec!["tests".to_string(), "bench/fixtures".to_string()]
5094        );
5095        assert!(report.read_only_advisory_for.is_empty());
5096
5097        let rendered = report.render();
5098        assert!(rendered.contains("  read_only:\n    - tests\n    - bench/fixtures\n"));
5099        assert!(rendered.contains(
5100            "read_only enforcement: enforced for every tool call and every shell command the \
5101             dispatch check can read"
5102        ));
5103        assert!(!rendered.contains("advisory against"));
5104    }
5105
5106    /// An allowlisted interpreter is named, and the enforcement line is qualified — the report
5107    /// must never claim more than `W-SEC-017` already withdraws.
5108    #[test]
5109    fn an_allowlisted_interpreter_is_named_as_advisory() {
5110        let report = read_only_report(&["tests"], &["git", "python3"]);
5111        assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
5112
5113        let rendered = report.render();
5114        assert!(rendered.contains("read_only enforcement: advisory against python3"));
5115        assert!(rendered
5116            .contains("enforced for every tool call and for every shell command it can read"));
5117    }
5118
5119    /// The report's advisory set is the set `W-SEC-017` fires for, because both call one
5120    /// resolver. Asserted through the resolver rather than by re-listing interpreters, so adding
5121    /// one to `protected_paths::ADVISORY_INTERPRETERS` cannot make the two disagree.
5122    #[test]
5123    fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
5124        let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
5125            .iter()
5126            .map(|b| (*b).to_string())
5127            .collect();
5128        let declared = vec!["tests".to_string()];
5129        assert_eq!(
5130            read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
5131                .read_only_advisory_for,
5132            read_only_advisory_for(&declared, &shell_allow)
5133        );
5134        // Nothing is advisory against a protection that was never declared, whatever is allowed.
5135        assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
5136    }
5137
5138    /// Both keys are always arrays, including for a capsule with no `capabilities.filesystem`
5139    /// block at all: an absent key must identify a runtime that predates the field, never a
5140    /// capsule that declared nothing.
5141    #[test]
5142    fn both_read_only_keys_are_always_serialized_arrays() {
5143        let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
5144        assert_eq!(json["read_only_paths"], serde_json::json!([]));
5145        assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
5146
5147        let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
5148        assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
5149        assert_eq!(
5150            json["read_only_advisory_for"],
5151            serde_json::json!(["python3"])
5152        );
5153    }
5154
5155    /// An undeclared `read_only` renders one `<none>` line and asks no enforcement question,
5156    /// because there is no protection to qualify.
5157    #[test]
5158    fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
5159        let rendered = read_only_report(&[], &["python3"]).render();
5160        assert!(rendered.contains("  read_only: <none>\n"));
5161        assert!(!rendered.contains("read_only enforcement:"));
5162    }
5163
5164    // ── Grant coverage ────────────────────────────────────────────────────────
5165
5166    /// Every `pub` field of [`CapabilityPolicy`] is either reported by [`ScopeReport`] or
5167    /// exempted here with a written reason.
5168    ///
5169    /// The field list is derived from `types.rs`'s own source text rather than written out, so a
5170    /// grant added later fails this test until someone decides which it is.
5171    ///
5172    /// The mapping is checked in both directions — every policy field is accounted for, *and*
5173    /// every report key the mapping names is really present in a serialized report — so the
5174    /// table cannot claim coverage it does not have.
5175    #[test]
5176    fn every_capability_policy_field_is_reported_or_exempted() {
5177        /// Policy fields deliberately absent from the scope report, each with the reason.
5178        const EXEMPT: &[(&str, &str)] = &[
5179            (
5180                "shell_strip_env",
5181                "Names host variables removed from the shell child's environment on top of the \
5182                 credential patterns the runtime always strips. It subtracts from what the \
5183                 capsule sees and can open nothing, so reporting it beside the grants would pad \
5184                 the list an operator audits for reach.",
5185            ),
5186            (
5187                "shell_baseline_env",
5188                "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
5189                 environment of a subprocess the capsule was already granted, and names no host \
5190                 resource `shell allow` and `env allow` do not already account for.",
5191            ),
5192            (
5193                "limits",
5194                "Execution ceilings for a WASM guest — memory, table elements, instance count, \
5195                 deadline. A bound on how much the capsule may consume, never a reach it gains.",
5196            ),
5197            (
5198                "resources",
5199                "The same, for subprocesses: rlimits, the Linux cgroup v2 scope and the \
5200                 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
5201            ),
5202            (
5203                "guest_workers",
5204                "Derived from the artifacts and `install.tool` the report already shows as the \
5205                 `wasm-tools`, `drivers` and `hooks` route rows, and grants nothing: it only makes \
5206                 such a capsule need the namespace and cgroup scope the report's `cgroup_scope` \
5207                 states.",
5208            ),
5209            (
5210                "declared_deadline_seconds",
5211                "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
5212                 can apply a lower default. Reporting it would report one ceiling twice.",
5213            ),
5214            (
5215                "process_transport",
5216                "Records that `inference.transport` is `process`, which decides the subprocess \
5217                 route and that `capabilities.harness.paths` is valid. Not a grant: the harness's \
5218                 reach is in the `harness` key, whose presence already says the transport is \
5219                 process.",
5220            ),
5221            (
5222                "state_declared",
5223                "Records that a capsule-wide `capabilities.state` block was declared where \
5224                 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
5225                 exist are reported per artifact in `state_stores`.",
5226            ),
5227            (
5228                "conversation_declared",
5229                "Records a capsule-wide `capabilities.conversation` block that reaches no \
5230                 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
5231                 and the grant itself is per artifact.",
5232            ),
5233        ];
5234
5235        /// Policy field → the key it is reported under in a serialized [`ScopeReport`].
5236        const REPORTED: &[(&str, &str)] = &[
5237            ("network_allow", "network_allow"),
5238            ("peer_fetch_allow", "peer_fetch_allow"),
5239            ("unix_sockets_allowed", "unix_sockets"),
5240            ("filesystem_scope", "filesystem_scope"),
5241            ("workdir_exec_allowed", "workdir_exec"),
5242            ("read_only_paths", "read_only_paths"),
5243            ("shell_allow", "shell_allow"),
5244            ("spawn_allow", "spawn_allow"),
5245            ("spawn_workdir_snapshot", "spawn_workdir_snapshot"),
5246            ("shell_interpreter_runtime", "interpreter_runtime_grants"),
5247            (
5248                "shell_derived_interpreter_runtime",
5249                "derived_interpreter_runtime",
5250            ),
5251            ("shell_trust_store", "trust_store"),
5252            ("shell_declared_needs", "declared_needs"),
5253            ("shell_pinned_programs", "pinned_programs"),
5254            ("host_paths", "host_paths"),
5255            ("harness_paths", "harness"),
5256            ("shell_staged_runtime", "staged_runtime_grants"),
5257            ("env_allow", "env_allow"),
5258            ("plan_submit", "plan_submit"),
5259            ("install_skill", "install_skill"),
5260            ("install_tool", "install_tool"),
5261            ("install_from_sources", "install_from"),
5262            ("containment_floor", "declared_containment"),
5263            ("declared_resources", "unconfined"),
5264        ];
5265
5266        let declared = capability_policy_field_names(include_str!("types.rs"));
5267        assert!(
5268            declared.len() > 10,
5269            "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
5270        );
5271
5272        for field in &declared {
5273            let reported = REPORTED.iter().any(|(policy, _)| policy == field);
5274            let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
5275            assert!(
5276                reported || exempt,
5277                "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
5278                 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
5279                 with a written reason for why an operator does not need to see it."
5280            );
5281            assert!(
5282                !(reported && exempt),
5283                "CapabilityPolicy::{field} is listed as both reported and exempt"
5284            );
5285        }
5286
5287        for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
5288            assert!(
5289                declared.iter().any(|field| field == policy_field),
5290                "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
5291                 declares — remove the stale row"
5292            );
5293        }
5294
5295        // The other direction: a report key the table claims must really be in the JSON, so no
5296        // row can cover a field with a key that does not exist.
5297        let json = serde_json::to_value(report_for(
5298            &sample_policy(),
5299            ContainmentClass::Scoped,
5300            EnforcementTier::KernelFull,
5301            None,
5302            None,
5303            None,
5304            None,
5305            Vec::new(),
5306            Vec::new(),
5307            Vec::new(),
5308            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5309            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5310                EnforcementTier::KernelFull,
5311                ContainmentClass::Scoped,
5312            )),
5313            IoMaxReport::default(),
5314        ))
5315        .unwrap();
5316        let object = json.as_object().expect("a scope report is a JSON object");
5317        for (policy_field, report_key) in REPORTED {
5318            assert!(
5319                object.contains_key(*report_key),
5320                "the table maps CapabilityPolicy::{policy_field} to a report key \
5321                 '{report_key}' that a serialized ScopeReport does not carry"
5322            );
5323        }
5324    }
5325
5326    /// A report off [`sample_policy`] at a chosen host tier and declared floor, so the mechanism
5327    /// this session would install is the only thing that varies between the cases below.
5328    fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
5329        report_for(
5330            &sample_policy(),
5331            declared,
5332            tier,
5333            None,
5334            None,
5335            // A kernel that mediates `truncate(2)`, so a Landlock tier's runtime directory is
5336            // read-only and adds no statement of its own.
5337            Some(6),
5338            None,
5339            Vec::new(),
5340            Vec::new(),
5341            Vec::new(),
5342            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5343            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5344            IoMaxReport::default(),
5345        )
5346    }
5347
5348    fn sample_snapshot() -> crate::workdir_snapshot::WorkdirSnapshot {
5349        crate::workdir_snapshot::WorkdirSnapshot {
5350            source: "/home/you/project".to_string(),
5351            files: 12,
5352            dirs: 3,
5353            symlinks: 1,
5354            repointed_symlinks: 0,
5355            bytes: 40960,
5356            skipped_count: 1,
5357            skipped: vec![crate::workdir_snapshot::SkippedEntry {
5358                path: "run/pipe".to_string(),
5359                reason: crate::workdir_snapshot::SkipReason::Fifo,
5360            }],
5361        }
5362    }
5363
5364    /// Both keys are always serialized: `[]` and `null` for a session that declares no snapshot
5365    /// grant and was started in no copy, and the human report lists the grant beside `spawn allow`.
5366    #[test]
5367    fn spawn_workdir_snapshot_and_parent_workdir_are_always_reported() {
5368        let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5369        let json = serde_json::to_value(&report).unwrap();
5370        assert_eq!(json["spawn_workdir_snapshot"], serde_json::json!([]));
5371        assert!(json.as_object().unwrap().contains_key("parent_workdir"));
5372        assert_eq!(json["parent_workdir"], serde_json::Value::Null);
5373        let rendered = report.render();
5374        let snapshot_line = rendered
5375            .find("\n  spawn workdir snapshot: <none>\n")
5376            .unwrap_or_else(|| panic!("{rendered}"));
5377        assert!(
5378            rendered.find("\n  spawn allow:").unwrap() < snapshot_line,
5379            "{rendered}"
5380        );
5381
5382        assert!(!rendered.contains("Parent work folder"), "{rendered}");
5383
5384        let mut policy = sample_policy();
5385        policy.spawn_allow = vec!["local/reviewer".to_string(), "local/worker".to_string()];
5386        policy.spawn_workdir_snapshot = vec!["local/reviewer".to_string()];
5387        let report = report_for(
5388            &policy,
5389            ContainmentClass::Scoped,
5390            EnforcementTier::KernelFull,
5391            None,
5392            None,
5393            Some(6),
5394            None,
5395            Vec::new(),
5396            Vec::new(),
5397            Vec::new(),
5398            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5399            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5400                EnforcementTier::KernelFull,
5401                ContainmentClass::Scoped,
5402            )),
5403            IoMaxReport::default(),
5404        );
5405        assert_eq!(report.spawn_workdir_snapshot, vec!["local/reviewer"]);
5406        assert_eq!(
5407            serde_json::to_value(&report).unwrap()["spawn_workdir_snapshot"],
5408            serde_json::json!(["local/reviewer"])
5409        );
5410        assert!(
5411            report
5412                .render()
5413                .contains("  spawn workdir snapshot:\n    - local/reviewer\n"),
5414            "{}",
5415            report.render()
5416        );
5417    }
5418
5419    /// A child's `parent_workdir.restriction` is its own filesystem restriction on the tier it
5420    /// applies — never the achieved class — and the human report says it in words.
5421    #[test]
5422    fn parent_workdir_restriction_follows_the_applied_tier() {
5423        for (declared, tier, restriction) in [
5424            (
5425                ContainmentClass::Sealed,
5426                EnforcementTier::KernelSealed,
5427                "absent",
5428            ),
5429            (
5430                ContainmentClass::Scoped,
5431                EnforcementTier::KernelFull,
5432                "enforced",
5433            ),
5434            (
5435                ContainmentClass::Advisory,
5436                EnforcementTier::KernelSeccompOnly,
5437                "advisory",
5438            ),
5439            (
5440                ContainmentClass::Advisory,
5441                EnforcementTier::EnvironmentOnly,
5442                "advisory",
5443            ),
5444        ] {
5445            let report =
5446                boundary_report(declared, tier).with_parent_workdir(Some(sample_snapshot()));
5447            let json = serde_json::to_value(&report).unwrap();
5448            let parent = &json["parent_workdir"];
5449            assert_eq!(parent["restriction"], restriction, "{tier:?}");
5450            assert_eq!(
5451                parent["restriction"],
5452                json["filesystem_boundary"]["restriction"]
5453            );
5454            assert_eq!(parent["source"], "/home/you/project");
5455            assert_eq!(parent["files"], 12);
5456            assert_eq!(parent["skipped"][0]["reason"], "fifo");
5457
5458            let rendered = report.render();
5459            assert!(
5460                rendered.contains(
5461                    "\nParent work folder\n  source:      /home/you/project\n  copied:      \
5462                     12 files, 3 directories, 1 symlinks (0 re-pointed), 40960 bytes\n  \
5463                     skipped:     1\n    - run/pipe (fifo)\n  restriction: "
5464                ),
5465                "{rendered}"
5466            );
5467            assert!(
5468                rendered.contains(&format!("  restriction: {restriction}: ")),
5469                "{rendered}"
5470            );
5471        }
5472        assert_eq!(
5473            boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull)
5474                .with_parent_workdir(None)
5475                .parent_workdir,
5476            None
5477        );
5478    }
5479
5480    /// Every presence input the table reads, one at a time, with its expected present routes.
5481    fn presence_cases() -> Vec<(&'static str, RouteInputs, Vec<Route>)> {
5482        vec![
5483            ("nothing", RouteInputs::default(), vec![]),
5484            (
5485                "one tool",
5486                RouteInputs {
5487                    tools: vec!["t".to_string()],
5488                    ..RouteInputs::default()
5489                },
5490                vec![Route::WasmTools],
5491            ),
5492            (
5493                "a driver",
5494                RouteInputs {
5495                    drivers: vec!["d".to_string()],
5496                    ..RouteInputs::default()
5497                },
5498                vec![Route::Drivers],
5499            ),
5500            (
5501                "a hook",
5502                RouteInputs {
5503                    hooks: vec!["h".to_string()],
5504                    ..RouteInputs::default()
5505                },
5506                vec![Route::Hooks],
5507            ),
5508            (
5509                "install.tool",
5510                RouteInputs {
5511                    install_tool: true,
5512                    ..RouteInputs::default()
5513                },
5514                vec![Route::WasmTools],
5515            ),
5516            (
5517                "transport: process",
5518                RouteInputs {
5519                    process_transport: true,
5520                    ..RouteInputs::default()
5521                },
5522                vec![Route::ProcessHarness, Route::HarnessTools],
5523            ),
5524            (
5525                "plan.submit without shell.allow",
5526                RouteInputs {
5527                    plan_submit: true,
5528                    ..RouteInputs::default()
5529                },
5530                vec![],
5531            ),
5532            (
5533                "plan.submit with shell.allow",
5534                RouteInputs {
5535                    plan_submit: true,
5536                    shell_allow: true,
5537                    ..RouteInputs::default()
5538                },
5539                vec![Route::Shell, Route::PlanShellSteps],
5540            ),
5541            (
5542                "shell.allow alone",
5543                RouteInputs {
5544                    shell_allow: true,
5545                    ..RouteInputs::default()
5546                },
5547                vec![Route::Shell],
5548            ),
5549        ]
5550    }
5551
5552    /// The table over every tier, every declared class and every presence input: one row per
5553    /// [`Route::ALL`] entry in the fixed order; shell commands, plan shell steps and the three
5554    /// guest-worker routes covered by the applied tier's mechanism, the harness routes
5555    /// `host-process` with their note, whatever the tier; presence exactly as each input implies.
5556    #[test]
5557    fn the_route_table_holds_its_invariants_on_every_input() {
5558        for tier in ALL_TIERS {
5559            for declared in [
5560                ContainmentClass::Advisory,
5561                ContainmentClass::Scoped,
5562                ContainmentClass::Sealed,
5563            ] {
5564                let mechanism =
5565                    ContainmentDecision::decide(*tier, declared, false, false).enforcement_tier();
5566                for (case, inputs, present) in presence_cases() {
5567                    let rows = route_table(&inputs, mechanism);
5568                    assert_eq!(
5569                        rows.iter().map(|row| row.route).collect::<Vec<_>>(),
5570                        Route::ALL.to_vec(),
5571                        "{case}"
5572                    );
5573                    for row in &rows {
5574                        // Every route is under the class on these tiers (ALL_TIERS has no
5575                        // unconfined): the harness and its tools, and the guest workers.
5576                        let covered = matches!(
5577                            row.route,
5578                            Route::Shell
5579                                | Route::PlanShellSteps
5580                                | Route::ProcessHarness
5581                                | Route::HarnessTools
5582                                | Route::WasmTools
5583                                | Route::Drivers
5584                                | Route::Hooks
5585                        );
5586                        assert_eq!(row.under_class, covered, "{case}: {row:?}");
5587                        if covered {
5588                            assert_eq!(row.mechanism, mechanism, "{case}: {row:?}");
5589                            assert_eq!(row.note, None, "{case}: {row:?}");
5590                        } else {
5591                            assert_eq!(row.mechanism, HOST_PROCESS_MECHANISM, "{case}: {row:?}");
5592                            assert!(row.note.is_some(), "{case}: {row:?}");
5593                        }
5594                        assert_eq!(
5595                            row.present,
5596                            present.contains(&row.route),
5597                            "{case} on {tier:?}/{declared}: {row:?}"
5598                        );
5599                    }
5600                }
5601            }
5602        }
5603    }
5604
5605    /// Every route is under the class: shell commands and plan steps, the process harness and the
5606    /// tools it runs, and the guest workers of WASM tools, drivers and hooks. None has a note.
5607    #[test]
5608    fn every_route_is_under_the_class_with_no_note() {
5609        for route in Route::ALL {
5610            assert!(route.under_class(), "{route:?}");
5611            assert_eq!(route.note(), None, "{route:?}");
5612        }
5613    }
5614
5615    /// The artifacts on each route come from the preopens by role.
5616    #[test]
5617    fn route_inputs_read_roles_off_the_preopens() {
5618        let preopen = |artifact: &str, role: &str| PreopenReport {
5619            artifact: artifact.to_string(),
5620            role: role.to_string(),
5621            scope: None,
5622            surface: PreopenSurface::WholeWorkdir,
5623            runtime_dir_reach: None,
5624            extra_roots: Vec::new(),
5625        };
5626        let preopens = vec![
5627            preopen("fs-tool", "tool"),
5628            preopen("browser", "tool"),
5629            preopen("driver-anthropic", "driver"),
5630            preopen("audit", "hook"),
5631        ];
5632        let inputs = RouteInputs::new(&[], false, &[], &preopens, Some("http"));
5633        let rows = route_table(&inputs, "landlock+seccomp");
5634        let row = |route: Route| rows.iter().find(|row| row.route == route).unwrap();
5635        assert_eq!(row(Route::WasmTools).artifacts, vec!["fs-tool", "browser"]);
5636        assert_eq!(row(Route::Drivers).artifacts, vec!["driver-anthropic"]);
5637        assert_eq!(row(Route::Hooks).artifacts, vec!["audit"]);
5638        assert!(!row(Route::ProcessHarness).present);
5639        assert!(RouteInputs::new(&[], false, &[], &[], Some("process")).process_transport);
5640    }
5641
5642    /// The `routes` key as `--explain-scope --json` and `session_start` carry it: seven rows,
5643    /// always serialized, every key on every row.
5644    #[test]
5645    fn the_routes_json_is_pinned() {
5646        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
5647            .with_routes(Some("process"));
5648        let json = serde_json::to_value(&report).unwrap();
5649        assert!(json.get("mechanism_covers").is_none(), "{json}");
5650        let routes = json["routes"].as_array().expect("routes is an array");
5651        assert_eq!(routes.len(), 7);
5652        let shell_present = !report.shell_allow.is_empty();
5653        assert_eq!(
5654            routes[0],
5655            serde_json::json!({
5656                "route": "shell",
5657                "present": shell_present,
5658                "under_class": true,
5659                "mechanism": "landlock+seccomp",
5660                "note": null,
5661                "artifacts": [],
5662            })
5663        );
5664        assert_eq!(
5665            routes[5],
5666            serde_json::json!({
5667                "route": "process-harness",
5668                "present": true,
5669                "under_class": true,
5670                "mechanism": "landlock+seccomp",
5671                "note": null,
5672                "artifacts": [],
5673            })
5674        );
5675        assert_eq!(
5676            routes
5677                .iter()
5678                .map(|row| row["route"].as_str().unwrap())
5679                .collect::<Vec<_>>(),
5680            [
5681                "shell",
5682                "plan-shell-steps",
5683                "wasm-tools",
5684                "drivers",
5685                "hooks",
5686                "process-harness",
5687                "harness-tools"
5688            ]
5689        );
5690        assert_eq!(
5691            json["cgroup_scope"],
5692            serde_json::json!({"status": "not-probed"})
5693        );
5694    }
5695
5696    /// The text report replaces the `covers:` line with a `Routes` block under `Containment`.
5697    #[test]
5698    fn the_routes_block_follows_the_containment_block() {
5699        let preopens = vec![PreopenReport {
5700            artifact: "fs-tool".to_string(),
5701            role: "tool".to_string(),
5702            scope: None,
5703            surface: PreopenSurface::WholeWorkdir,
5704            runtime_dir_reach: None,
5705            extra_roots: Vec::new(),
5706        }];
5707        let report = report_for(
5708            &sample_policy(),
5709            ContainmentClass::Advisory,
5710            EnforcementTier::KernelSealed,
5711            None,
5712            None,
5713            Some(6),
5714            None,
5715            Vec::new(),
5716            Vec::new(),
5717            preopens,
5718            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5719            crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
5720            IoMaxReport::default(),
5721        )
5722        .with_routes(None);
5723        let rendered = report.render();
5724        assert!(!rendered.contains("covers:"), "{rendered}");
5725        let routes = rendered.find("\nRoutes\n").expect("a Routes block");
5726        assert!(
5727            routes > rendered.find("  mechanism: ").unwrap(),
5728            "{rendered}"
5729        );
5730        assert!(
5731            routes < rendered.find("\nRuntime directory\n").unwrap(),
5732            "{rendered}"
5733        );
5734        assert!(
5735            rendered.contains("  shell commands    present  landlock+seccomp\n"),
5736            "{rendered}"
5737        );
5738        assert!(
5739            rendered.contains("  WASM tools        present  landlock+seccomp (fs-tool)\n"),
5740            "{rendered}"
5741        );
5742        assert!(
5743            rendered.contains("  process harness   absent   landlock+seccomp\n"),
5744            "{rendered}"
5745        );
5746        assert!(
5747            rendered.contains("  cgroup scope: not-probed\n"),
5748            "{rendered}"
5749        );
5750    }
5751
5752    /// `W-SEC-034` fires only at a `scoped` or `sealed` floor, and names exactly the present
5753    /// routes the class does not cover, with their artifacts.
5754    #[test]
5755    fn the_uncovered_route_warning_names_exactly_the_present_uncovered_routes() {
5756        let inputs = RouteInputs {
5757            shell_allow: true,
5758            tools: vec!["fs-tool".to_string()],
5759            drivers: vec!["driver-anthropic".to_string()],
5760            ..RouteInputs::default()
5761        };
5762        let routes = route_table(&inputs, "landlock+seccomp");
5763        assert_eq!(
5764            uncovered_route_warning(ContainmentClass::Advisory, &routes),
5765            None
5766        );
5767
5768        let covered_only = route_table(
5769            &RouteInputs {
5770                shell_allow: true,
5771                plan_submit: true,
5772                ..RouteInputs::default()
5773            },
5774            "landlock+seccomp",
5775        );
5776        assert_eq!(
5777            uncovered_route_warning(ContainmentClass::Scoped, &covered_only),
5778            None
5779        );
5780
5781        // WASM tools and drivers run in guest workers under the class: nothing to name.
5782        for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5783            assert_eq!(uncovered_route_warning(declared, &routes), None);
5784        }
5785
5786        // A process capsule's harness, the tools it runs and its driver are all under the class,
5787        // so `W-SEC-034` has nothing to name.
5788        let process = route_table(
5789            &RouteInputs {
5790                process_transport: true,
5791                drivers: vec!["driver-claude-code".to_string()],
5792                ..RouteInputs::default()
5793            },
5794            "landlock+seccomp",
5795        );
5796        for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5797            assert_eq!(uncovered_route_warning(declared, &process), None);
5798        }
5799    }
5800
5801    /// The emitter writes the code, the message and the link to the bootstrap log it is handed.
5802    #[test]
5803    fn the_uncovered_route_warning_reaches_the_bootstrap_log() {
5804        let temp = tempfile::tempdir().unwrap();
5805        let mut routes = route_table(
5806            &RouteInputs {
5807                drivers: vec!["d".to_string()],
5808                process_transport: true,
5809                ..RouteInputs::default()
5810            },
5811            "landlock+seccomp",
5812        );
5813        // Every route is under the class today, so stand in an uncovered one to carry the
5814        // warning through the emitter.
5815        let harness = routes
5816            .iter_mut()
5817            .find(|row| row.route == Route::ProcessHarness)
5818            .unwrap();
5819        harness.under_class = false;
5820        warn_on_uncovered_routes(
5821            ContainmentClass::Sealed,
5822            &routes,
5823            Some(&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap()),
5824        );
5825        let log = std::fs::read_to_string(temp.path().join("logs").join("bootstrap.log"))
5826            .unwrap_or_default();
5827        assert_eq!(log.matches("warning[W-SEC-034]").count(), 1, "{log}");
5828        assert!(log.contains("the process harness"), "{log}");
5829        assert!(!log.contains("drivers (d)"), "{log}");
5830        assert!(log.contains("#w-sec-034"), "{log}");
5831    }
5832
5833    /// Without a kernel filesystem mechanism `HOME` is relocated and nothing more; a Landlock
5834    /// session does not carry the statement and a composed root carries nothing.
5835    #[test]
5836    fn an_advisory_session_says_home_is_relocated_not_protected() {
5837        for tier in [
5838            EnforcementTier::EnvironmentOnly,
5839            EnforcementTier::KernelSeccompOnly,
5840        ] {
5841            let boundary = filesystem_boundary(tier);
5842            let home = boundary
5843                .not_protected
5844                .iter()
5845                .find(|statement| statement.contains("relocated, not protected"))
5846                .unwrap_or_else(|| panic!("{tier:?}: {boundary:?}"));
5847            assert!(home.contains("session's home folder"), "{home}");
5848            assert!(
5849                home.contains("real home directory stays readable and writable by absolute path"),
5850                "{home}"
5851            );
5852            assert!(home.contains("no kernel mechanism"), "{home}");
5853        }
5854        assert!(!filesystem_boundary(EnforcementTier::KernelFull)
5855            .not_protected
5856            .iter()
5857            .any(|statement| statement.contains("relocated, not protected")));
5858        assert!(filesystem_boundary(EnforcementTier::KernelSealed)
5859            .not_protected
5860            .is_empty());
5861    }
5862
5863    /// A host that mediates nothing says so, in the block an operator reads before the grants.
5864    #[test]
5865    fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
5866        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
5867
5868        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5869        assert_eq!(
5870            report.filesystem_boundary.restriction,
5871            FilesystemRestriction::Advisory
5872        );
5873        // The filesystem, path rewriting, the relocated HOME, and the runtime and records
5874        // directories no command is kept from.
5875        assert_eq!(report.filesystem_boundary.not_protected.len(), 5);
5876        assert_eq!(
5877            report.filesystem_boundary.not_protected[2],
5878            ADVISORY_HOME_NOT_PROTECTED
5879        );
5880        assert_eq!(
5881            Some(&report.filesystem_boundary.not_protected[3]),
5882            report.runtime_dir.not_protected_because.as_ref()
5883        );
5884        assert_eq!(
5885            Some(&report.filesystem_boundary.not_protected[4]),
5886            report.records_dir.not_protected_because.as_ref()
5887        );
5888        assert!(report.filesystem_boundary.not_protected[4].contains("the records directory"));
5889
5890        let filesystem = &report.filesystem_boundary.not_protected[0];
5891        assert!(filesystem.contains("advisory"), "{filesystem}");
5892        assert!(
5893            filesystem.contains("denies a read or a write outside the work folder"),
5894            "{filesystem}"
5895        );
5896
5897        let rewriting = &report.filesystem_boundary.not_protected[1];
5898        assert!(rewriting.contains("not a boundary"), "{rewriting}");
5899        assert!(rewriting.contains("HOME"), "{rewriting}");
5900
5901        let rendered = report.render();
5902        let heading = rendered
5903            .find("\nNot protected here\n")
5904            .expect("a session that composes no root prints the block");
5905        assert!(
5906            heading > rendered.find("Containment\n").unwrap(),
5907            "the block belongs under Containment:\n{rendered}"
5908        );
5909        assert!(
5910            heading < rendered.find("\nEffective grants\n").unwrap(),
5911            "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
5912        );
5913        for statement in &report.filesystem_boundary.not_protected {
5914            assert!(rendered.contains(statement.as_str()), "{rendered}");
5915        }
5916    }
5917
5918    fn inside_placement() -> RuntimeDirPlacement {
5919        RuntimeDirPlacement {
5920            sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
5921            records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
5922            workdir: std::path::PathBuf::from("/home/dev"),
5923            sessions_inside_workdir: true,
5924            records_inside_workdir: true,
5925        }
5926    }
5927
5928    /// One claim per applied tier, from the tier, the ruleset ABI and the placement alone.
5929    #[test]
5930    fn runtime_dir_report_makes_one_claim_per_tier() {
5931        let outside = RuntimeDirPlacement::outside_for_test();
5932        for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
5933            let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
5934            assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
5935            assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
5936            assert_eq!(sealed.not_protected_because, None);
5937        }
5938
5939        let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
5940        assert!(full.read_only_to_commands);
5941        assert_eq!(full.enforced_by, Some("landlock"));
5942        assert_eq!(full.not_protected_because, None);
5943
5944        for abi in [Some(1), Some(2)] {
5945            let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
5946            assert!(!old.read_only_to_commands);
5947            assert_eq!(old.enforced_by, None);
5948            let reason = old.not_protected_because.unwrap();
5949            assert!(
5950                reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
5951                    && reason.contains("truncate(2)")
5952                    && reason.contains("6.2"),
5953                "{reason}"
5954            );
5955        }
5956
5957        let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
5958        assert!(!seccomp.read_only_to_commands);
5959        assert!(seccomp
5960            .not_protected_because
5961            .unwrap()
5962            .contains("no Landlock"));
5963
5964        let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
5965        assert!(!none.read_only_to_commands);
5966        let reason = none.not_protected_because.unwrap();
5967        assert!(
5968            reason.contains("no kernel containment") && reason.contains("macOS"),
5969            "{reason}"
5970        );
5971
5972        assert_eq!(
5973            full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
5974            "the path carries the literal session-id segment"
5975        );
5976    }
5977
5978    /// Inside the workdir, nothing protects it on any tier, and the reason names both paths.
5979    #[test]
5980    fn runtime_dir_report_inside_the_workdir_is_never_protected() {
5981        for tier in ALL_TIERS {
5982            let report = runtime_dir_report(*tier, Some(6), &inside_placement());
5983            assert!(!report.read_only_to_commands, "{tier:?}");
5984            assert_eq!(report.enforced_by, None);
5985        }
5986        let reason =
5987            runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
5988                .not_protected_because
5989                .unwrap();
5990        assert!(
5991            reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
5992            "{reason}"
5993        );
5994        assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
5995    }
5996
5997    /// A runtime directory inside the workdir caps the class at `advisory`, refuses a `scoped` or
5998    /// `sealed` floor naming both paths and the fix, and lets an `advisory` launch through.
5999    #[test]
6000    fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
6001        let inside = inside_placement();
6002        assert_eq!(
6003            achieved_containment_class(EnforcementTier::KernelSealed, false, true),
6004            ContainmentClass::Advisory
6005        );
6006        assert_eq!(
6007            achieved_containment_class(EnforcementTier::KernelSealed, false, false),
6008            ContainmentClass::Sealed
6009        );
6010        for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
6011            match check_containment_floor(
6012                declared,
6013                ContainmentClass::Advisory,
6014                None,
6015                false,
6016                inside.inside(),
6017            ) {
6018                Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
6019                    assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
6020                    assert!(reason.contains("the workdir /home/dev"), "{reason}");
6021                    assert!(
6022                        reason.contains("run with a --workdir that does not contain ~/.murmur"),
6023                        "{reason}"
6024                    );
6025                }
6026                other => panic!("{declared:?}: {other:?}"),
6027            }
6028        }
6029        assert!(check_containment_floor(
6030            ContainmentClass::Advisory,
6031            ContainmentClass::Advisory,
6032            None,
6033            false,
6034            inside.inside()
6035        )
6036        .is_ok());
6037
6038        let report = report_for(
6039            &sample_policy(),
6040            ContainmentClass::Advisory,
6041            EnforcementTier::KernelFull,
6042            None,
6043            None,
6044            Some(6),
6045            None,
6046            Vec::new(),
6047            Vec::new(),
6048            Vec::new(),
6049            &inside,
6050            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6051                EnforcementTier::KernelFull,
6052                ContainmentClass::Advisory,
6053            )),
6054            IoMaxReport::default(),
6055        );
6056        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
6057        let reason = report.runtime_dir.not_protected_because.clone().unwrap();
6058        assert!(report.filesystem_boundary.not_protected.contains(&reason));
6059        let rendered = report.render();
6060        assert!(
6061            rendered.contains(&format!("  commands:  can change it — {reason}\n")),
6062            "{rendered}"
6063        );
6064    }
6065
6066    /// The block `--explain-scope` prints after `Containment`, and the JSON object it renders.
6067    #[test]
6068    fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
6069        let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
6070        let rendered = report.render();
6071        let block =
6072            "Runtime directory\n  path:      /home/test/.murmur/sessions/<session-id>/runtime\n  \
6073                     commands:  read only (landlock)\n";
6074        let at = rendered.find(block).expect(&rendered);
6075        assert!(at > rendered.find("Containment\n").unwrap());
6076        assert!(at < rendered.find("Effective grants").unwrap());
6077
6078        let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
6079        assert!(sealed
6080            .render()
6081            .contains("  commands:  read only (mountns+landlock)\n"));
6082
6083        let json = serde_json::to_string(&report.runtime_dir).unwrap();
6084        assert_eq!(
6085            json,
6086            r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
6087        );
6088    }
6089
6090    /// A Landlock domain really does mediate the filesystem, so its statement says what Landlock
6091    /// leaves open rather than repeating the advisory one.
6092    #[test]
6093    fn a_landlock_session_reports_denial_rather_than_absence() {
6094        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
6095
6096        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
6097        assert_eq!(
6098            report.filesystem_boundary.restriction,
6099            FilesystemRestriction::Enforced
6100        );
6101        assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
6102
6103        let filesystem = &report.filesystem_boundary.not_protected[0];
6104        assert!(
6105            !filesystem.contains("advisory"),
6106            "Landlock mediates the filesystem here: {filesystem}"
6107        );
6108        assert!(
6109            filesystem.contains("denied rather than absent"),
6110            "{filesystem}"
6111        );
6112        for probe in ["stat", "access", "readlink"] {
6113            assert!(filesystem.contains(probe), "{filesystem}");
6114        }
6115    }
6116
6117    /// A capsule that declares less than `sealed` composes no root on a sealed-capable host: it
6118    /// runs Landlock-scoped, is reported `scoped` on `landlock+seccomp`, and the block says so
6119    /// rather than claim the paths outside its grants are absent.
6120    #[test]
6121    fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
6122        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
6123
6124        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
6125        assert_eq!(report.enforcement_tier, "landlock+seccomp");
6126        assert_eq!(
6127            report.host_containment,
6128            ContainmentClass::Sealed,
6129            "the host can back sealed, which only `host_containment` says"
6130        );
6131        assert_eq!(
6132            report.filesystem_boundary.restriction,
6133            FilesystemRestriction::Enforced,
6134            "no composed root is installed for a capsule that did not declare one"
6135        );
6136        assert_eq!(
6137            report.filesystem_boundary.not_protected,
6138            boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
6139                .filesystem_boundary
6140                .not_protected,
6141            "the same mechanism gets the same statements on either host"
6142        );
6143        assert!(report.render().contains("Not protected here"));
6144    }
6145
6146    /// The half of the block that is genuinely mechanism-independent: `HOME` is rewritten on every
6147    /// tier, so the statement about it is one string, not one per mechanism.
6148    #[test]
6149    fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
6150        let advisory =
6151            boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
6152        let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
6153
6154        assert_eq!(
6155            advisory.filesystem_boundary.not_protected[1],
6156            enforced.filesystem_boundary.not_protected[1]
6157        );
6158        assert!(
6159            advisory.filesystem_boundary.not_protected[1]
6160                .contains("home/ in its records directory"),
6161            "the statement must name the directory `shell::build_shell_env` really sets HOME to"
6162        );
6163    }
6164
6165    /// A composed root prints no heading — not a heading with an empty list under it — and still
6166    /// carries the key, because an absent key must identify a runtime that predates the field.
6167    #[test]
6168    fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
6169        let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
6170
6171        assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
6172        assert_eq!(
6173            report.filesystem_boundary.restriction,
6174            FilesystemRestriction::Absent
6175        );
6176        assert!(report.filesystem_boundary.not_protected.is_empty());
6177        assert!(
6178            !report.render().contains("Not protected here"),
6179            "{}",
6180            report.render()
6181        );
6182
6183        let json = serde_json::to_value(&report).unwrap();
6184        assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
6185        assert_eq!(
6186            json["filesystem_boundary"]["not_protected"],
6187            serde_json::json!([]),
6188            "the key is present and empty at absent, never missing"
6189        );
6190    }
6191
6192    /// `capabilities.filesystem.workdir_exec` caps the achieved *class* at `advisory` and leaves
6193    /// the *mechanism* alone: Landlock is still installed, so the block prints the Landlock
6194    /// statement rather than claiming the host denies nothing.
6195    #[test]
6196    fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
6197        let policy = CapabilityPolicy {
6198            workdir_exec_allowed: true,
6199            ..CapabilityPolicy::default()
6200        };
6201
6202        let report = report_for(
6203            &policy,
6204            ContainmentClass::Advisory,
6205            EnforcementTier::KernelSealed,
6206            None,
6207            None,
6208            Some(6),
6209            None,
6210            Vec::new(),
6211            Vec::new(),
6212            Vec::new(),
6213            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6214            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6215                EnforcementTier::KernelSealed,
6216                ContainmentClass::Advisory,
6217            )),
6218            IoMaxReport::default(),
6219        );
6220
6221        assert_eq!(report.enforcement_tier, "landlock+seccomp");
6222        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
6223        assert_eq!(
6224            report.filesystem_boundary.restriction,
6225            FilesystemRestriction::Enforced
6226        );
6227        assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
6228        assert!(report.render().contains("Not protected here"));
6229    }
6230
6231    /// The restriction follows the tier the session applies, at every combination of host tier and
6232    /// declared floor — which is what `runtime_writes` beside it is already keyed on.
6233    #[test]
6234    fn the_restriction_follows_the_tier_the_session_would_install() {
6235        for tier in ALL_TIERS {
6236            for declared in [
6237                ContainmentClass::Advisory,
6238                ContainmentClass::Scoped,
6239                ContainmentClass::Sealed,
6240            ] {
6241                let report = boundary_report(declared, *tier);
6242                let expected = match crate::sandbox::applied_tier(*tier, declared) {
6243                    EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
6244                    EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
6245                    EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
6246                        FilesystemRestriction::Advisory
6247                    }
6248                    EnforcementTier::Unconfined => FilesystemRestriction::None,
6249                };
6250                assert_eq!(
6251                    report.filesystem_boundary.restriction, expected,
6252                    "{tier:?} / {declared:?}"
6253                );
6254                assert_eq!(
6255                    report.filesystem_boundary.not_protected.is_empty(),
6256                    expected == FilesystemRestriction::Absent,
6257                    "{tier:?} / {declared:?}"
6258                );
6259                let wire = match expected {
6260                    FilesystemRestriction::Advisory => "advisory",
6261                    FilesystemRestriction::Enforced => "enforced",
6262                    FilesystemRestriction::Absent => "absent",
6263                    FilesystemRestriction::None => "none",
6264                };
6265                assert_eq!(
6266                    serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
6267                    serde_json::Value::String(wire.to_string())
6268                );
6269            }
6270        }
6271    }
6272
6273    /// The one assertion the report cannot satisfy by agreeing with itself: what a subprocess in a
6274    /// real session actually gets back when it probes a host file outside its workdir, compared
6275    /// against the `restriction` the report prints for that same session.
6276    ///
6277    /// Runs on whatever tier this host reaches, and asserts against the restriction it observes
6278    /// rather than against a tier it assumes. `bash` reads the file with a redirect and tests its
6279    /// existence with `[[ -e ]]` — both are syscalls the shell makes itself, so nothing here needs
6280    /// a second binary on the exec allowlist.
6281    ///
6282    /// Driving `execute_shell` needs an egress network namespace, so it stands down where the host
6283    /// withholds one rather than failing: a host that restricts unprivileged user namespaces
6284    /// refuses the uid_map write every spawn goes through, whatever the filesystem tier.
6285    #[test]
6286    fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
6287        if crate::network_namespace::skip_without_egress_namespace(
6288            "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
6289        ) {
6290            return;
6291        }
6292        if crate::sandbox::find_on_path("bash").is_none() {
6293            eprintln!(
6294                "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
6295                 no bash on PATH"
6296            );
6297            return;
6298        }
6299        let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
6300        let secret = outside.path().join("host-file");
6301        std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
6302        let secret = secret.to_str().unwrap().to_string();
6303
6304        for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
6305            let workdir = tempfile::tempdir().unwrap();
6306            let policy = CapabilityPolicy {
6307                shell_allow: vec!["bash".to_string()],
6308                ..CapabilityPolicy::default()
6309            };
6310            let probe = crate::sandbox::HostProbe::probe();
6311            let host_tier = probe.tier();
6312            let runtime = tempfile::tempdir().unwrap();
6313            let enforcement =
6314                crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
6315                    .unwrap()
6316                    .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
6317            let report = report_for(
6318                &policy,
6319                declared,
6320                host_tier,
6321                None,
6322                None,
6323                None,
6324                None,
6325                Vec::new(),
6326                Vec::new(),
6327                Vec::new(),
6328                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6329                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6330                    host_tier, declared,
6331                )),
6332                IoMaxReport::default(),
6333            );
6334
6335            let command = format!(
6336                "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
6337                 else echo read=refused; fi; \
6338                 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
6339            );
6340            let result = crate::shell::execute_shell(
6341                "bash",
6342                &["-c", &command],
6343                &[],
6344                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
6345                &policy,
6346                &enforcement,
6347            )
6348            .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
6349            let seen = result.stdout;
6350
6351            match report.filesystem_boundary.restriction {
6352                FilesystemRestriction::Advisory => assert!(
6353                    seen.contains("read=PROBE-CONTENTS"),
6354                    "reported advisory, so nothing denies the read: {seen}"
6355                ),
6356                FilesystemRestriction::Enforced => assert!(
6357                    seen.contains("read=refused") && seen.contains("exists=yes"),
6358                    "reported enforced, so the read is denied and the path still exists: {seen}"
6359                ),
6360                FilesystemRestriction::Absent => assert!(
6361                    seen.contains("read=refused") && seen.contains("exists=no"),
6362                    "reported absent, so the path is not in the composed root: {seen}"
6363                ),
6364                FilesystemRestriction::None => assert!(
6365                    seen.contains("read=PROBE-CONTENTS"),
6366                    "reported none, so nothing denies the read: {seen}"
6367                ),
6368            }
6369        }
6370    }
6371
6372    /// The `pub` field names of `CapabilityPolicy`, read out of `types.rs`'s own text.
6373    fn capability_policy_field_names(source: &str) -> Vec<String> {
6374        source
6375            .split_once("pub struct CapabilityPolicy {")
6376            .expect("types.rs declares CapabilityPolicy")
6377            .1
6378            .split_once("\n}")
6379            .expect("CapabilityPolicy's declaration is closed at column 0")
6380            .0
6381            .lines()
6382            .filter_map(|line| {
6383                line.strip_prefix("    pub ")
6384                    .and_then(|rest| rest.split_once(':'))
6385                    .map(|(name, _)| name.to_string())
6386            })
6387            .collect()
6388    }
6389
6390    /// Every row of the per-tier `/tmp` table, through the decision staging makes and the report
6391    /// built from it: exact `tmp_mapped`, `mapped_by` and `tmpdir`, and a reason exactly when not
6392    /// mapped. The report the session records is the one `scope_report_for_tier` builds from the
6393    /// same answer.
6394    #[test]
6395    fn the_temp_dir_report_follows_the_per_tier_table() {
6396        use crate::temp_dir::temp_mapping;
6397
6398        let placement = RuntimeDirPlacement::outside_for_test();
6399        let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
6400        let tmp = crate::temp_dir::canonical_tmp();
6401        let elsewhere = std::path::Path::new("/work");
6402        // (host tier, declared, mount namespace usable, workdir, tmp_mapped, mapped_by)
6403        let rows = [
6404            (
6405                EnforcementTier::KernelSealed,
6406                ContainmentClass::Sealed,
6407                true,
6408                elsewhere,
6409                true,
6410                Some("composed-root"),
6411            ),
6412            (
6413                EnforcementTier::KernelSealed,
6414                ContainmentClass::Scoped,
6415                true,
6416                elsewhere,
6417                true,
6418                Some("mount-namespace"),
6419            ),
6420            (
6421                EnforcementTier::KernelSealed,
6422                ContainmentClass::Advisory,
6423                true,
6424                elsewhere,
6425                true,
6426                Some("mount-namespace"),
6427            ),
6428            (
6429                EnforcementTier::KernelFull,
6430                ContainmentClass::Scoped,
6431                true,
6432                elsewhere,
6433                true,
6434                Some("mount-namespace"),
6435            ),
6436            (
6437                EnforcementTier::KernelFull,
6438                ContainmentClass::Scoped,
6439                false,
6440                elsewhere,
6441                false,
6442                None,
6443            ),
6444            (
6445                EnforcementTier::KernelSeccompOnly,
6446                ContainmentClass::Advisory,
6447                true,
6448                elsewhere,
6449                true,
6450                Some("mount-namespace"),
6451            ),
6452            (
6453                EnforcementTier::KernelSeccompOnly,
6454                ContainmentClass::Advisory,
6455                false,
6456                elsewhere,
6457                false,
6458                None,
6459            ),
6460            (
6461                EnforcementTier::EnvironmentOnly,
6462                ContainmentClass::Advisory,
6463                false,
6464                elsewhere,
6465                false,
6466                None,
6467            ),
6468            (
6469                EnforcementTier::KernelFull,
6470                ContainmentClass::Scoped,
6471                true,
6472                tmp.as_path(),
6473                false,
6474                None,
6475            ),
6476            (
6477                EnforcementTier::KernelSeccompOnly,
6478                ContainmentClass::Advisory,
6479                true,
6480                tmp.as_path(),
6481                false,
6482                None,
6483            ),
6484        ];
6485        for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
6486            let applied = crate::sandbox::applied_tier(tier, declared);
6487            let mapping = temp_mapping(applied, usable, workdir);
6488            let report = temp_dir_report(mapping, &placement);
6489            let row = format!(
6490                "{tier:?} {declared:?} usable={usable} workdir={}",
6491                workdir.display()
6492            );
6493            assert_eq!(report.path, folder, "{row}");
6494            assert_eq!(report.tmp_mapped, mapped, "{row}");
6495            assert_eq!(report.mapped_by, mapped_by, "{row}");
6496            assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
6497            assert_eq!(
6498                report
6499                    .not_mapped_because
6500                    .as_deref()
6501                    .is_some_and(|reason| !reason.is_empty()),
6502                !mapped,
6503                "{row}"
6504            );
6505
6506            let scope = report_for(
6507                &CapabilityPolicy::default(),
6508                declared,
6509                tier,
6510                None,
6511                None,
6512                None,
6513                None,
6514                Vec::new(),
6515                Vec::new(),
6516                Vec::new(),
6517                &placement,
6518                mapping,
6519                IoMaxReport::default(),
6520            );
6521            assert_eq!(scope.temp_dir, report, "{row}");
6522        }
6523
6524        let reason = |tier, usable, workdir| {
6525            temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
6526                .not_mapped_because
6527                .unwrap()
6528        };
6529        assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
6530            .starts_with("this host has no kernel containment for capsule commands"));
6531        assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
6532            .contains("neither a private mount namespace nor Landlock"));
6533        assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
6534            .contains("a literal /tmp path is refused"));
6535        assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
6536            .starts_with("the workdir is /tmp itself"));
6537    }
6538
6539    /// The `Temporary folder` block: its shape mapped and not, its JSON keys, and its place after
6540    /// the records directory in `--explain-scope`.
6541    #[test]
6542    fn the_temp_folder_block_renders_after_the_records_directory() {
6543        use crate::temp_dir::{NotMappedReason, TempMapping};
6544
6545        let placement = RuntimeDirPlacement::outside_for_test();
6546        let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
6547        assert_eq!(
6548            render_temp_dir(&mapped),
6549            "Temporary folder\n  path:      /home/test/.murmur/records/work/<session-id>/tmp\n  \
6550             TMPDIR:    /tmp\n  /tmp:      mapped (mount-namespace)\n"
6551        );
6552        let unmapped = temp_dir_report(
6553            TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
6554            &placement,
6555        );
6556        assert_eq!(
6557            render_temp_dir(&unmapped),
6558            format!(
6559                "Temporary folder\n  path:      {path}\n  TMPDIR:    {path}\n  /tmp:      not \
6560                 mapped — {}\n",
6561                NotMappedReason::NoMountNamespace.describe(),
6562                path = "/home/test/.murmur/records/work/<session-id>/tmp"
6563            )
6564        );
6565        assert_eq!(
6566            serde_json::to_value(&mapped).unwrap(),
6567            serde_json::json!({
6568                "path": "/home/test/.murmur/records/work/<session-id>/tmp",
6569                "tmpdir": "/tmp",
6570                "tmp_mapped": true,
6571                "mapped_by": "mount-namespace",
6572                "not_mapped_because": null,
6573            })
6574        );
6575
6576        let scope = report_for(
6577            &CapabilityPolicy::default(),
6578            ContainmentClass::Scoped,
6579            EnforcementTier::KernelFull,
6580            None,
6581            None,
6582            None,
6583            None,
6584            Vec::new(),
6585            Vec::new(),
6586            Vec::new(),
6587            &placement,
6588            TempMapping::MountNamespace,
6589            IoMaxReport::default(),
6590        );
6591        let rendered = scope.render();
6592        let records = rendered.find("Records directory\n").expect("records block");
6593        let temp = rendered.find("Temporary folder\n").expect("temp block");
6594        assert!(records < temp, "{rendered}");
6595        assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
6596        assert!(serde_json::to_value(&scope)
6597            .unwrap()
6598            .get("temp_dir")
6599            .is_some());
6600    }
6601}