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