Skip to main content

capsule_runtime/
containment.rs

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