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::{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    }
68}
69
70/// The class a session achieves: the class of the tier its commands run on, from
71/// [`achieved_class_for_tier`], capped by what the capsule's own
72/// `capabilities.filesystem.workdir_exec` leaves standing and by where its runtime directory lies.
73///
74/// Both caps lower the class without changing the tier: the commands still run under the same
75/// mechanism, which no longer backs the claim the higher class makes.
76///
77/// Why `workdir_exec` caps at [`ContainmentClass::Advisory`]: `scoped` (and `sealed` above it) both
78/// claim that what runs inside the capsule is bounded by what the operator declared. With the
79/// workdir's `Execute` right granted, a binary the agent compiles, downloads or renames inside its
80/// own workdir runs whatever `capabilities.shell.allow` says — the allowlist stops being an
81/// enforceable property. A tier that backs `scoped` still cannot back a claim the capsule itself
82/// has given up, so the honest report is `advisory`, on every tier.
83///
84/// Why `runtime_dir_inside_workdir` caps at [`ContainmentClass::Advisory`] too: with
85/// `~/.murmur/sessions` inside the workdir the capsule's commands write, they can rewrite the
86/// task, `MURMUR.md`, a submitted plan or an installed tool's entrypoint, which every class above
87/// `advisory` claims they cannot.
88fn achieved_containment_class(
89    tier: EnforcementTier,
90    workdir_exec: bool,
91    runtime_dir_inside_workdir: bool,
92) -> ContainmentClass {
93    let class = achieved_class_for_tier(tier);
94    if workdir_exec || runtime_dir_inside_workdir {
95        class.min(ContainmentClass::Advisory)
96    } else {
97        class
98    }
99}
100
101/// A session's containment, decided once: the tier its commands run on and the class that tier
102/// achieves for this capsule.
103///
104/// Every reader of a session's class or tier takes it from here — the floor refusal, the
105/// [`ScopeReport`] (and so `--explain-scope` and `session_start`), the `/tmp` mapping, the
106/// `MURMUR.md` exec facts, the tier `ShellEnforcement` installs, the resource plane's and peer
107/// handoff's symlink rule, `mur doctor` and the escape-conformance harness — so no report can name
108/// a class or tier the session's commands do not run under.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct ContainmentDecision {
111    declared: ContainmentClass,
112    host_tier: EnforcementTier,
113    applied_tier: EnforcementTier,
114    achieved: ContainmentClass,
115}
116
117impl ContainmentDecision {
118    /// Decides from one host tier reading, the combined declared floor, the capsule's
119    /// `capabilities.filesystem.workdir_exec` and whether its runtime directory lies inside the
120    /// workdir. The last two cap the class and never change the tier.
121    pub(crate) fn decide(
122        host_tier: EnforcementTier,
123        declared: ContainmentClass,
124        workdir_exec: bool,
125        runtime_dir_inside_workdir: bool,
126    ) -> Self {
127        let applied_tier = crate::sandbox::applied_tier(host_tier, declared);
128        Self {
129            declared,
130            host_tier,
131            applied_tier,
132            achieved: achieved_containment_class(
133                applied_tier,
134                workdir_exec,
135                runtime_dir_inside_workdir,
136            ),
137        }
138    }
139
140    /// The combined declared floor the decision was made for.
141    pub fn declared(&self) -> ContainmentClass {
142        self.declared
143    }
144
145    /// The class this session's commands run under.
146    pub fn achieved(&self) -> ContainmentClass {
147        self.achieved
148    }
149
150    /// The class the host could back for a capsule declaring `sealed`, with no capsule-side cap.
151    /// Reported only; nothing decides from it.
152    pub fn host_containment(&self) -> ContainmentClass {
153        achieved_class_for_tier(self.host_tier)
154    }
155
156    /// The wire name of the tier this session installs, as [`ScopeReport::enforcement_tier`]
157    /// spells it.
158    pub fn enforcement_tier(&self) -> &'static str {
159        enforcement_tier_name(self.applied_tier)
160    }
161
162    /// The tier this session installs. `pub(crate)` because [`EnforcementTier`] is.
163    pub(crate) fn applied_tier(&self) -> EnforcementTier {
164        self.applied_tier
165    }
166}
167
168/// Probes this host once and decides for a capsule declaring `declared`, with neither capsule-side
169/// cap, returning the probe's sealed shortfall beside it.
170///
171/// What `mur doctor`'s callers and the escape-conformance harness ask: the class and tier a
172/// session declaring `declared` would run under here, and why `sealed` is out of reach when it is.
173/// A *session* takes its own [`HostProbe`] in `runtime::stage_session` and decides with its real
174/// `workdir_exec` and runtime-directory placement instead.
175pub fn detect_containment_decision(
176    declared: ContainmentClass,
177) -> (ContainmentDecision, Option<SealedShortfall>) {
178    let probe = HostProbe::probe();
179    (
180        ContainmentDecision::decide(probe.tier(), declared, false, false),
181        probe.sealed_shortfall(),
182    )
183}
184
185/// Why `achieved` falls short of `declared`, naming the missing *mechanism* rather than the
186/// missing class. `None` when the floor is met.
187///
188/// Pure and host-independent: the caller supplies both classes *and* — for the `sealed` arm — the
189/// already-probed [`SealedShortfall`], so this function still probes nothing and stays testable
190/// without a kernel. `sealed_shortfall` is `None` when the caller has not probed, in which case
191/// the arm falls back to naming the mechanism generically.
192///
193/// `workdir_exec` is the capsule's own `capabilities.filesystem.workdir_exec`. It is checked before
194/// every host arm below, because it is the one shortfall an operator cannot fix by changing hosts:
195/// no kernel backs `scoped` for a capsule that has declared its workdir executable. Naming a
196/// missing Landlock ABI to someone whose real problem is a line in their own manifest sends them to
197/// the wrong machine.
198///
199/// `runtime_dir_inside` is the placement when the session's runtime directory lies inside the
200/// workdir, checked right after `workdir_exec` and for the same reason: the fix is a different
201/// `--workdir`, not a different host.
202pub fn containment_shortfall_reason(
203    declared: ContainmentClass,
204    achieved: ContainmentClass,
205    sealed_shortfall: Option<&SealedShortfall>,
206    workdir_exec: bool,
207    runtime_dir_inside: Option<&RuntimeDirPlacement>,
208) -> Option<String> {
209    containment_shortfall(
210        declared,
211        achieved,
212        sealed_shortfall,
213        workdir_exec,
214        runtime_dir_inside,
215    )
216    .map(|(reason, _)| reason)
217}
218
219/// Why a containment floor is unmet, which decides what an operator is told to change: a host
220/// that cannot back the class, or one of the two capsule-side causes no host can fix.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum ShortfallCause {
223    /// This host lacks the mechanism the declared class needs.
224    Host,
225    /// `capabilities.filesystem.workdir_exec: true` caps the class at `advisory`.
226    WorkdirExec,
227    /// The runtime or records directory lies inside the workdir, which caps the class at
228    /// `advisory`.
229    InsideWorkdir,
230}
231
232/// [`containment_shortfall_reason`], with the [`ShortfallCause`] it was decided by.
233fn containment_shortfall(
234    declared: ContainmentClass,
235    achieved: ContainmentClass,
236    sealed_shortfall: Option<&SealedShortfall>,
237    workdir_exec: bool,
238    runtime_dir_inside: Option<&RuntimeDirPlacement>,
239) -> Option<(String, ShortfallCause)> {
240    if achieved >= declared {
241        return None;
242    }
243
244    if workdir_exec && declared > ContainmentClass::Advisory {
245        return Some((
246            WORKDIR_EXEC_SHORTFALL.to_string(),
247            ShortfallCause::WorkdirExec,
248        ));
249    }
250
251    if let Some(placement) = runtime_dir_inside.filter(|_| declared > ContainmentClass::Advisory) {
252        return Some((placement.inside_shortfall(), ShortfallCause::InsideWorkdir));
253    }
254
255    let reason = match declared {
256        // Unreachable in practice — `Advisory` is the weakest class, so nothing is below it —
257        // but stated rather than `unreachable!()` so a future variant reordering cannot panic
258        // a launch path.
259        ContainmentClass::Advisory => {
260            "advisory is the weakest containment class and is satisfied by every host".to_string()
261        }
262        ContainmentClass::Scoped => {
263            "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
264             ABI); this host provides no kernel filesystem mediation, so paths outside the \
265             workdir are constrained by convention only"
266                .to_string()
267        }
268        // The mechanism exists now, so the reason names *which part of it* this host is missing
269        // and how to fix that part — never the old blanket "no host can provide it today".
270        ContainmentClass::Sealed => match sealed_shortfall {
271            Some(shortfall) => shortfall.reason(),
272            None => "sealed requires a private mount namespace pivoted onto a composed root \
273                     (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
274                     provide"
275                .to_string(),
276        },
277    };
278
279    Some((reason, ShortfallCause::Host))
280}
281
282/// The reason a capsule that declared `capabilities.filesystem.workdir_exec: true` falls short of
283/// any floor above `advisory`. Fixed text rather than a `match` arm per declared class: the
284/// mechanism, the consequence and the remedy are identical for `scoped` and `sealed`, because both
285/// rest on the claim this declaration gives up.
286const WORKDIR_EXEC_SHORTFALL: &str =
287    "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
288     workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
289     capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
290     No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
291     then enforced by the kernel on the resolved path) or lower the declared containment floor to \
292     advisory";
293
294/// The refusal gate: `Ok(())` when the session's `achieved` class
295/// ([`ContainmentDecision::achieved`]) meets or exceeds the `declared` floor,
296/// [`RuntimeError::ContainmentFloorUnmet`] otherwise.
297///
298/// Pure — takes every input as an argument and probes nothing, so the whole decision is
299/// testable without a kernel.
300pub fn check_containment_floor(
301    declared: ContainmentClass,
302    achieved: ContainmentClass,
303    sealed_shortfall: Option<&SealedShortfall>,
304    workdir_exec: bool,
305    runtime_dir_inside: Option<&RuntimeDirPlacement>,
306) -> Result<(), RuntimeError> {
307    match containment_shortfall(
308        declared,
309        achieved,
310        sealed_shortfall,
311        workdir_exec,
312        runtime_dir_inside,
313    ) {
314        None => Ok(()),
315        Some((reason, cause)) => Err(RuntimeError::ContainmentFloorUnmet {
316            declared,
317            achieved,
318            reason,
319            cause,
320        }),
321    }
322}
323
324/// The declared `exports.files` block as it appears in a [`ScopeReport`] — and therefore in
325/// `mur run --explain-scope --json` and in `trace.jsonl`'s `session_start.effective_grants`.
326///
327/// A flattened copy rather than [`FileExport`] itself: this is a wire shape with a stable field
328/// order and a `mode` that serializes as `"read-only"`, and it must not move whenever the
329/// manifest type gains a field the report has no business publishing.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
331pub struct ExportsFilesReport {
332    /// `exports.files.root` verbatim, as declared.
333    pub root: String,
334    pub mode: ExportMode,
335    /// The effective per-file read ceiling, with the default already applied.
336    pub max_bytes: u64,
337}
338
339impl From<&FileExport> for ExportsFilesReport {
340    fn from(export: &FileExport) -> Self {
341        Self {
342            root: export.root.clone(),
343            mode: export.mode,
344            max_bytes: export.max_bytes,
345        }
346    }
347}
348
349/// The declared `exports.peer_files` block as it appears in a [`ScopeReport`].
350///
351/// A flattened copy rather than [`PeerFilesExport`] itself, on the same terms as
352/// [`ExportsFilesReport`]: a wire shape with a stable field order, which must not move whenever
353/// the manifest type gains a field the report has no business publishing.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct PeerFilesReport {
356    /// `exports.peer_files.root` verbatim, as declared.
357    pub root: String,
358    /// The effective handle-lifetime ceiling in seconds, with the ephemeral default already
359    /// applied — so an operator reads the value the runtime will actually clamp to, not the
360    /// absence of a declaration.
361    pub max_ttl_secs: u64,
362    /// The effective per-file ceiling on a redeemed read, with the default already applied.
363    pub max_bytes: u64,
364}
365
366impl From<&PeerFilesExport> for PeerFilesReport {
367    fn from(export: &PeerFilesExport) -> Self {
368        Self {
369            root: export.root.clone(),
370            max_ttl_secs: export.effective_max_ttl_secs(),
371            max_bytes: export.max_bytes,
372        }
373    }
374}
375
376/// One artifact's durable state grant as it appears in a [`ScopeReport`].
377///
378/// A grant rather than a disclosure, unlike [`ExportsFilesReport`] beside it — but reported on
379/// exactly the same terms, and for the same reason: it opens a host path outside the workdir that
380/// an operator reading `filesystem scope` alone would never see.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
382pub struct StateStoreReport {
383    /// The artifact whose entry declared `capabilities.state`.
384    pub artifact: String,
385    /// The store directory name, with the capsule-name default already applied — so an operator
386    /// reads the directory that will actually be opened, not the absence of a declaration.
387    pub store: String,
388    /// Absolute host path of the store, `~/.murmur/state/<store>` with the home already resolved.
389    /// Reported as a string rather than a path so the JSON shape does not vary by platform.
390    pub host_path: String,
391}
392
393/// The filesystem surface one artifact's guest is preopened into, as a stable wire name.
394///
395/// The three values are the whole space: a WASI guest either holds a directory descriptor for the
396/// accessible workdir, holds one for a subtree of it, or holds none at all. Which one an entry
397/// resolves to is decided by its role and its `capabilities.filesystem.scope` in
398/// [`crate::network_policy::preopen_reports`]; the baselines the two roles start from are
399/// [`crate::network_policy::ToolCapabilityGrant`] and
400/// [`crate::network_policy::HookCapabilityGrant`].
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum PreopenSurface {
404    /// The entire accessible workdir, preopened as `"."` with `FsPerms::ReadWrite`. The wide
405    /// default a `runtime: tool` or `runtime: driver` entry with no
406    /// `capabilities.filesystem.scope` resolves to — see
407    /// [`crate::network_policy::ToolCapabilityGrant`] for the threat that default is chosen
408    /// against and the threat it is not.
409    WholeWorkdir,
410    /// `<accessible workdir>/<scope>` and nothing above it, preopened as `"."` with the same
411    /// rights. What a declared `capabilities.filesystem.scope` resolves to, whatever the role.
412    ScopedSubtree,
413    /// No preopened directory of any kind, so the guest holds no `wasi:filesystem` descriptor. The
414    /// default-deny baseline a `runtime: hook` entry with no `capabilities.filesystem.scope`
415    /// resolves to — see [`crate::network_policy::HookCapabilityGrant`].
416    Nothing,
417    /// The session's records directory's `hooks/` and nothing else, preopened as `"."`
418    /// read-write: what a `runtime: hook` entry declaring `capabilities.filesystem.records: true`
419    /// resolves to, in place of a workdir scope.
420    RecordsHooks,
421}
422
423impl PreopenSurface {
424    /// The phrase [`PreopenReport::render`] names this surface with. Distinct per variant rather
425    /// than a formatted path, because the point of the line is that an operator can tell the three
426    /// apart without knowing the resolved workdir.
427    fn summary(self) -> &'static str {
428        match self {
429            PreopenSurface::WholeWorkdir => "the whole accessible workdir",
430            PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
431            PreopenSurface::Nothing => "nothing preopened",
432            PreopenSurface::RecordsHooks => "the records directory's hooks/",
433        }
434    }
435}
436
437/// One artifact's resolved WASI preopen as it appears in a [`ScopeReport`] — and therefore in
438/// `mur run --explain-scope`, in `mur doctor`, and in `trace.jsonl`'s
439/// `session_start.effective_grants`.
440///
441/// Per-artifact, like [`StateStoreReport`] beside it, and reported for the same reason: the
442/// capsule-wide [`ScopeReport::filesystem_scope`] is joined into no guest preopen root, so an
443/// operator reading that field alone learns nothing about what any tool, driver or hook can
444/// actually see.
445#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct PreopenReport {
447    /// The artifact whose entry in the capsule operator's own manifest this describes.
448    pub artifact: String,
449    /// [`murmur_artifact::ArtifactRuntime::as_str`] verbatim, so the report and the manifest name
450    /// a role with one vocabulary.
451    pub role: String,
452    /// The entry's declared `capabilities.filesystem.scope`, verbatim. `None` = undeclared, which
453    /// is what [`Self::surface`] has already resolved against the role's own baseline.
454    pub scope: Option<String>,
455    /// What the guest is preopened into once the role's baseline and [`Self::scope`] are both
456    /// applied.
457    pub surface: PreopenSurface,
458    /// How the artifact reaches the session's runtime directory, read-only: at `murmur-runtime/`
459    /// for a WASM `runtime: tool` with no `capabilities.filesystem.scope`, through
460    /// `$MURMUR_RUNTIME_DIR` for a native one, `unresolved` where the report could not tell which
461    /// the installed artifact is. `None` — serialized `null` — for every other entry, which has
462    /// no reach.
463    pub runtime_dir_reach: Option<RuntimeDirReach>,
464}
465
466/// How one artifact reaches the runtime directory, as [`PreopenReport::runtime_dir_reach`]
467/// carries it.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
469#[serde(rename_all = "kebab-case")]
470pub enum RuntimeDirReach {
471    /// A WASM tool's read-only preopen at `murmur-runtime/`.
472    MurmurRuntime,
473    /// A native tool's `$MURMUR_RUNTIME_DIR`, read-only by Landlock where the host enforces it.
474    Environment,
475    /// A tool whose implementation the report could not resolve: no lock entry pins it.
476    Unresolved,
477}
478
479/// `reach` once the artifact's implementation is known: native reaches the runtime directory
480/// through its environment, WASM through its preopen, and an unknown implementation stays
481/// unresolved. No reach stays none.
482#[must_use]
483pub fn resolve_runtime_dir_reach(
484    reach: Option<RuntimeDirReach>,
485    implementation: Option<murmur_artifact::ArtifactImplementation>,
486) -> Option<RuntimeDirReach> {
487    reach?;
488    Some(match implementation {
489        Some(murmur_artifact::ArtifactImplementation::Native) => RuntimeDirReach::Environment,
490        Some(murmur_artifact::ArtifactImplementation::Wasm) => RuntimeDirReach::MurmurRuntime,
491        None => RuntimeDirReach::Unresolved,
492    })
493}
494
495/// Every report's [`PreopenReport::runtime_dir_reach`] resolved through `implementation_of`, the
496/// implementation of the artifact a report names, or `None` where it is not known.
497pub fn resolve_runtime_dir_reaches(
498    reports: &mut [PreopenReport],
499    implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
500) {
501    for report in reports {
502        report.runtime_dir_reach = resolve_runtime_dir_reach(
503            report.runtime_dir_reach,
504            implementation_of(&report.artifact),
505        );
506    }
507}
508
509impl PreopenReport {
510    /// The single line this preopen renders as, shared by [`ScopeReport::render`] and by
511    /// `mur doctor` so the two cannot word the same grant differently.
512    ///
513    /// `runtime_dir_protected` is [`RuntimeDirReport::read_only_to_commands`] for the session:
514    /// whether a native tool's `$MURMUR_RUNTIME_DIR` is read-only on this host. A WASM tool's
515    /// preopen is read-only on every host.
516    pub fn render(&self, runtime_dir_protected: bool) -> String {
517        let artifact = &self.artifact;
518        let role = &self.role;
519        let surface = self.surface.summary();
520        let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
521        let env = crate::runtime_dir::RUNTIME_DIR_ENV;
522        let runtime_dir = match (self.runtime_dir_reach, runtime_dir_protected) {
523            (None, _) => String::new(),
524            (Some(RuntimeDirReach::MurmurRuntime), _) => {
525                format!("; runtime directory read-only at {guest}/")
526            }
527            (Some(RuntimeDirReach::Environment), true) => {
528                format!("; runtime directory read-only at ${env}")
529            }
530            (Some(RuntimeDirReach::Environment), false) => {
531                format!("; runtime directory at ${env}, which commands can change on this host")
532            }
533            (Some(RuntimeDirReach::Unresolved), true) => {
534                format!("; runtime directory read-only at {guest}/ if WASM, at ${env} if native")
535            }
536            (Some(RuntimeDirReach::Unresolved), false) => format!(
537                "; runtime directory read-only at {guest}/ if WASM, at ${env} if native, which \
538                 commands can change on this host"
539            ),
540        };
541        match &self.scope {
542            Some(scope) => format!(
543                "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
544                 {scope}{runtime_dir}"
545            ),
546            None => format!(
547                "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
548                 declared{runtime_dir}"
549            ),
550        }
551    }
552}
553
554/// What the filesystem mechanism this session installs restricts, as a stable wire name.
555///
556/// Keyed on [`ContainmentDecision::applied_tier`], the tier
557/// [`crate::sandbox::ShellEnforcement::resolve`] installs the session's mechanisms from and the
558/// one [`ScopeReport::enforcement_tier`] names. Not on the achieved class, which `workdir_exec`
559/// and the runtime directory's placement can cap at `advisory` without changing what the
560/// mechanism restricts.
561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
562#[serde(rename_all = "kebab-case")]
563pub enum FilesystemRestriction {
564    /// No kernel mechanism mediates the filesystem, so a path outside the workdir is reachable.
565    Advisory,
566    /// Landlock refuses the open, and the path outside the granted set still exists.
567    Enforced,
568    /// A composed root, where the path outside the capsule is not there to open.
569    Absent,
570}
571
572/// What the filesystem mechanism this session installs does *not* protect, in words an operator
573/// can act on.
574///
575/// The counterpart to every grant beside it in a [`ScopeReport`]: those say what the capsule may
576/// reach, and this says which of the restrictions an operator would read into them are not there.
577///
578/// [`Self::not_protected`] is non-empty below [`FilesystemRestriction::Absent`], and empty at it
579/// unless the runtime directory lies inside the workdir. It is serialized either way — an absent
580/// key identifies a runtime that predates the field, never a capsule with nothing to disclaim.
581#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
582pub struct FilesystemBoundaryReport {
583    /// What the session's filesystem mechanism restricts.
584    pub restriction: FilesystemRestriction,
585    /// One statement per protection an operator would otherwise assume, the runtime directory's
586    /// [`RuntimeDirReport::not_protected_because`] among them when it is set. Rendered verbatim
587    /// under `Not protected here`, so the JSON and the human report cannot word the same
588    /// disclaimer differently.
589    pub not_protected: Vec<String>,
590}
591
592/// What a session with no kernel filesystem mechanism leaves unrestricted.
593///
594/// Names the mechanism rather than the class, because "advisory" is the word an operator has
595/// already read on the `achieved:` line and taken for a protection.
596const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
597    "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
598     outside the work folder, so capabilities.filesystem.scope bounds what the runtime hands a \
599     subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
600     ABI installs a Landlock domain instead, where the kernel refuses the open.";
601
602/// What a Landlock-scoped session leaves unrestricted.
603///
604/// Not the `advisory` sentence: Landlock really does mediate the filesystem here, and an operator
605/// told otherwise goes and hardens something the kernel already holds. What Landlock does not do is
606/// make a path absent — it mediates the operations that touch a file, not path resolution.
607const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
608    "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
609     stat, access and readlink on that path still succeed and still report the real file's \
610     metadata, because Landlock mediates the operations that touch a file and not path resolution. \
611     A probe that only asks whether a path exists therefore learns nothing about what was denied — \
612     read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
613     reaches the sealed tier composes a root the path is not in at all.";
614
615/// What no mechanism protects, and the reason `--explain-scope` carries this block at all.
616///
617/// Identical wherever it prints, because the rewrite it describes is identical: `HOME` is replaced
618/// with the session's home folder on every tier and every platform (see `crate::shell`), so the
619/// obvious `~`-based containment probe answers *no such file* on a host that denies nothing.
620const PATH_REWRITING_NOT_PROTECTED: &str =
621    "Path rewriting is a convenience, not a boundary: HOME is set to the session's home folder, \
622     home/ in its records directory, on every tier, so `cat ~/.ssh/id_rsa` answers \"No such file \
623     or directory\" whether or not anything was denied — the tilde resolved into that folder and \
624     the host file was never looked for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
625     to a real file outside the workdir instead.";
626
627/// The filesystem boundary the tier a session applies actually provides, and the protections it
628/// does not.
629///
630/// Pure and derived from the applied tier alone, so `--explain-scope` and
631/// `session_start.effective_grants` cannot word the same disclaimer differently: both go through
632/// [`scope_report_for_tier`], which calls this once with the tier it has already resolved.
633///
634/// The argument is the *applied* tier, never the host tier and never the achieved class. A composed
635/// root is what makes a host path absent, and a session composes one only when the host reaches
636/// `KernelSealed` **and** the capsule declared a `sealed` floor; a Landlock domain is installed
637/// whenever the applied tier is `KernelFull` or `KernelSealed`, including for a capsule whose
638/// `capabilities.filesystem.workdir_exec` caps its achieved class at `advisory`.
639pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
640    // The path-rewriting statement rides along with the mechanism-specific one rather than standing
641    // on its own, so a composed root — where the paths outside it are absent, and the `~` rewrite
642    // lands inside the workdir — discloses nothing at all.
643    let (restriction, filesystem) = match applied {
644        EnforcementTier::KernelSealed => (FilesystemRestriction::Absent, None),
645        EnforcementTier::KernelFull => (
646            FilesystemRestriction::Enforced,
647            Some(ENFORCED_FILESYSTEM_NOT_PROTECTED),
648        ),
649        EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => (
650            FilesystemRestriction::Advisory,
651            Some(ADVISORY_FILESYSTEM_NOT_PROTECTED),
652        ),
653    };
654
655    let not_protected = match filesystem {
656        Some(filesystem) => vec![
657            filesystem.to_string(),
658            PATH_REWRITING_NOT_PROTECTED.to_string(),
659        ],
660        None => Vec::new(),
661    };
662
663    FilesystemBoundaryReport {
664        restriction,
665        not_protected,
666    }
667}
668
669/// What the session's runtime directory, or its records directory, is to the capsule's commands —
670/// `--explain-scope`'s `Runtime directory` and `Records directory` blocks,
671/// `session_start.effective_grants.runtime_dir` and `.records_dir` and `mur doctor`'s sections,
672/// all from [`runtime_dir_report`] and [`records_dir_report`].
673#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
674pub struct RuntimeDirReport {
675    /// `<home>/.murmur/sessions/<session-id>/runtime`, or
676    /// `<home>/.murmur/records/<workdir root>/<session-id>`, with the literal `<session-id>`
677    /// segment `runtime_writes` carries.
678    pub path: String,
679    /// Whether no capsule command can change a file in it.
680    pub read_only_to_commands: bool,
681    /// The mechanism that makes it read-only: `mountns+landlock` on the sealed tier, `landlock`
682    /// on the Landlock tier, `null` when nothing does.
683    pub enforced_by: Option<&'static str>,
684    /// Why a command can change it, in words an operator can act on; `null` when none can.
685    pub not_protected_because: Option<String>,
686}
687
688/// The runtime directory on the sealed tier: a read-only bind plus a Landlock rule.
689const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
690
691/// The runtime directory on the Landlock tier: a read-only Landlock rule.
692const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
693
694/// The lowest Landlock ABI that mediates `truncate(2)`.
695const LANDLOCK_TRUNCATE_ABI: u8 = 3;
696
697/// One of the two directories outside the workdir the runtime keeps out of the commands' reach,
698/// as [`protected_dir_report`] words its claims about it.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum ProtectedDir {
701    Runtime,
702    Records,
703}
704
705impl ProtectedDir {
706    fn noun(self) -> &'static str {
707        match self {
708            Self::Runtime => "runtime directory",
709            Self::Records => "records directory",
710        }
711    }
712
713    /// What a command could empty there on a Landlock ABI that does not mediate `truncate(2)`.
714    fn file(self) -> &'static str {
715        match self {
716            Self::Runtime => "runtime file",
717            Self::Records => "record",
718        }
719    }
720
721    fn heading(self) -> &'static str {
722        match self {
723            Self::Runtime => "Runtime directory",
724            Self::Records => "Records directory",
725        }
726    }
727}
728
729/// What the session's runtime directory is to its commands, from the tier the session applies,
730/// the Landlock ABI its ruleset is built at and where the directory lies.
731///
732/// Pure and the one source of the claim: `--explain-scope`, `session_start.effective_grants` and
733/// `mur doctor` all print what this returns. It never claims protection on the seccomp-only tier,
734/// with no kernel containment, or when the directory lies inside the workdir, and on the Landlock
735/// tier below ABI 3 it says `truncate(2)` is not mediated.
736#[must_use]
737pub(crate) fn runtime_dir_report(
738    applied: EnforcementTier,
739    landlock_abi: Option<u8>,
740    placement: &RuntimeDirPlacement,
741) -> RuntimeDirReport {
742    protected_dir_report(
743        ProtectedDir::Runtime,
744        applied,
745        landlock_abi,
746        placement.report_path(),
747        placement.sessions_inside_workdir,
748        &placement.workdir,
749    )
750}
751
752/// [`runtime_dir_report`] for the session's records directory, which the same rule and bind
753/// protect on the same tiers.
754#[must_use]
755pub(crate) fn records_dir_report(
756    applied: EnforcementTier,
757    landlock_abi: Option<u8>,
758    placement: &RuntimeDirPlacement,
759) -> RuntimeDirReport {
760    protected_dir_report(
761        ProtectedDir::Records,
762        applied,
763        landlock_abi,
764        placement.records_report_path(),
765        placement.records_inside_workdir,
766        &placement.workdir,
767    )
768}
769
770fn protected_dir_report(
771    dir: ProtectedDir,
772    applied: EnforcementTier,
773    landlock_abi: Option<u8>,
774    path: String,
775    inside_workdir: bool,
776    workdir: &std::path::Path,
777) -> RuntimeDirReport {
778    let noun = dir.noun();
779    let not_protected = |reason: String| RuntimeDirReport {
780        path: path.clone(),
781        read_only_to_commands: false,
782        enforced_by: None,
783        not_protected_because: Some(reason),
784    };
785    match applied {
786        EnforcementTier::EnvironmentOnly => not_protected(format!(
787            "this host has no kernel containment for capsule commands — macOS and every \
788             non-Linux host run them as the operator's own processes, so a command can change \
789             the {noun}"
790        )),
791        EnforcementTier::KernelSeccompOnly => not_protected(format!(
792            "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
793             {noun}"
794        )),
795        _ if inside_workdir => not_protected(format!(
796            "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
797             it; run with a --workdir that does not contain ~/.murmur",
798            workdir.display()
799        )),
800        EnforcementTier::KernelSealed => RuntimeDirReport {
801            path,
802            read_only_to_commands: true,
803            enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
804            not_protected_because: None,
805        },
806        EnforcementTier::KernelFull => {
807            let abi = landlock_abi.unwrap_or(1);
808            if abi < LANDLOCK_TRUNCATE_ABI {
809                not_protected(format!(
810                    "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
811                     {} though not replace it; kernel 6.2 or later refuses it",
812                    dir.file()
813                ))
814            } else {
815                RuntimeDirReport {
816                    path,
817                    read_only_to_commands: true,
818                    enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
819                    not_protected_because: None,
820                }
821            }
822        }
823    }
824}
825
826/// The `Runtime directory` block of `--explain-scope` and `mur doctor`: its path, and whether
827/// the capsule's commands can change it. Ends with a newline.
828#[must_use]
829pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
830    render_protected_dir(ProtectedDir::Runtime, report)
831}
832
833/// The `Records directory` block of `--explain-scope` and `mur doctor`, on
834/// [`render_runtime_dir`]'s terms. Ends with a newline.
835#[must_use]
836pub fn render_records_dir(report: &RuntimeDirReport) -> String {
837    render_protected_dir(ProtectedDir::Records, report)
838}
839
840fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
841    let commands = match (&report.not_protected_because, report.enforced_by) {
842        (Some(reason), _) => format!("can change it — {reason}"),
843        (None, Some(mechanism)) => format!("read only ({mechanism})"),
844        (None, None) => "read only".to_string(),
845    };
846    format!(
847        "{}\n  path:      {}\n  commands:  {commands}\n",
848        dir.heading(),
849        report.path
850    )
851}
852
853/// The session's temporary folder, and whether a literal `/tmp` reaches it —
854/// `--explain-scope`'s `Temporary folder` block, `session_start.effective_grants.temp_dir` and
855/// `mur doctor`'s section, all from [`temp_dir_report`]. Field names are the JSON keys.
856#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
857pub struct TempDirReport {
858    /// `<home>/.murmur/records/<workdir root>/<session-id>/tmp`, with the literal `<session-id>`
859    /// segment the records directory's path carries.
860    pub path: String,
861    /// The value of `TMPDIR`, `TMP` and `TEMP` in every command: `/tmp` when mapped, [`Self::path`]
862    /// when not.
863    pub tmpdir: String,
864    /// Whether a literal `/tmp` is the folder.
865    pub tmp_mapped: bool,
866    /// The mechanism that maps it: `composed-root` on the sealed tier, `mount-namespace` on the
867    /// tiers below it, `null` when not mapped.
868    pub mapped_by: Option<&'static str>,
869    /// Why `/tmp` is not mapped and what a literal `/tmp` path reaches instead; `null` when
870    /// mapped.
871    pub not_mapped_because: Option<String>,
872}
873
874/// What the session's temporary folder is to its commands under `mapping`, with the folder's path
875/// from `placement`. Pure.
876#[must_use]
877pub(crate) fn temp_dir_report(
878    mapping: crate::temp_dir::TempMapping,
879    placement: &RuntimeDirPlacement,
880) -> TempDirReport {
881    let path = std::path::Path::new(&placement.records_report_path())
882        .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
883        .display()
884        .to_string();
885    TempDirReport {
886        tmpdir: mapping.tmpdir_value(std::path::Path::new(&path)),
887        path,
888        tmp_mapped: mapping.is_mapped(),
889        mapped_by: mapping.mapped_by(),
890        not_mapped_because: match mapping {
891            crate::temp_dir::TempMapping::NotMapped(reason) => Some(reason.describe().to_string()),
892            _ => None,
893        },
894    }
895}
896
897/// The `Temporary folder` block of `--explain-scope` and `mur doctor`: its path, the value of
898/// `TMPDIR`, and whether a literal `/tmp` reaches it. Ends with a newline.
899#[must_use]
900pub fn render_temp_dir(report: &TempDirReport) -> String {
901    let tmp = match (report.mapped_by, &report.not_mapped_because) {
902        (Some(mechanism), _) => format!("mapped ({mechanism})"),
903        (None, Some(reason)) => format!("not mapped — {reason}"),
904        (None, None) => "not mapped".to_string(),
905    };
906    format!(
907        "Temporary folder\n  path:      {}\n  TMPDIR:    {}\n  /tmp:      {tmp}\n",
908        report.path, report.tmpdir
909    )
910}
911
912/// Read-only answer to "what would this capsule actually be allowed to do on this host?".
913///
914/// Built from the already-parsed [`CapabilityPolicy`] plus one [`ContainmentDecision`]. Nothing
915/// here stages artifacts, touches a registry, creates a workdir or resolves DNS — it is a
916/// diagnostic, so it reports even when [`Self::floor_met`] is `false`.
917#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
918pub struct ScopeReport {
919    /// Strongest class any source asked for (manifest / workspace config / `--containment`).
920    pub declared_containment: ContainmentClass,
921    /// The class this session's commands run under: the class of [`Self::enforcement_tier`],
922    /// capped at `advisory` by `workdir_exec` or a runtime directory inside the workdir. From
923    /// [`ContainmentDecision::achieved`].
924    pub achieved_containment: ContainmentClass,
925    /// The class this host could back for a capsule declaring `sealed`, never what this session
926    /// runs on: a capsule declaring less runs on a weaker tier, and [`Self::achieved_containment`]
927    /// says which. From [`ContainmentDecision::host_containment`]. Always serialized.
928    pub host_containment: ContainmentClass,
929    /// `achieved_containment >= declared_containment`. `false` means `mur run` would refuse.
930    pub floor_met: bool,
931    /// The missing mechanism when `floor_met` is `false`; absent otherwise.
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub shortfall_reason: Option<String>,
934    /// The tier this session installs, as a stable wire name: the host's tier capped by the
935    /// declared floor, from [`ContainmentDecision::enforcement_tier`].
936    pub enforcement_tier: &'static str,
937    /// What [`Self::enforcement_tier`] confines, as stable wire names: always `shell`,
938    /// `plan-shell-steps` and `native-tools`, since all three are spawned through one enforcement
939    /// path.
940    ///
941    /// Always serialized, on the same terms as [`Self::workdir_exec`]: a consumer keys on its
942    /// presence, and a report without it comes from a runtime that does not confine native tools.
943    pub mechanism_covers: Vec<&'static str>,
944    /// Where this host's permission to create an unprivileged user namespace comes from, as
945    /// [`UsernsGrant::wire_name`], or `null` off Linux, where AppArmor does not exist and the
946    /// question has no answer.
947    ///
948    /// Always serialized, on the same terms as [`Self::workdir_exec`]: the key's absence
949    /// identifies a runtime that predates it, not a host that was not asked. Reported because
950    /// three very different hosts — no AppArmor, hardening switched off host-wide, and the shipped
951    /// profile confining `mur` — otherwise produce byte-identical reports while differing enormously
952    /// in what else on the machine can create a user namespace.
953    pub userns_grant: Option<UsernsGrant>,
954    /// The Landlock ABI this session's ruleset is built at: the kernel's, capped at
955    /// [`crate::sandbox::LANDLOCK_TARGET_ABI`], or `null` when no Landlock domain is installed —
956    /// a tier below `landlock+seccomp`, or a kernel without Landlock.
957    ///
958    /// Keyed on the session's applied tier, like [`Self::filesystem_boundary`], and computed
959    /// by the same [`crate::sandbox::ruleset_landlock_abi`] the shell child builds at, so the
960    /// trace records the ruleset that was installed. Always serialized, on the same terms as
961    /// [`Self::userns_grant`]. Below 2 a file cannot be moved or hard-linked between two folders
962    /// of the workdir; below 3 truncating a host file outside it is not refused.
963    pub landlock_abi: Option<u8>,
964    /// `capabilities.filesystem.scope`, verbatim.
965    pub filesystem_scope: Option<String>,
966    /// `capabilities.filesystem.workdir_exec`. Always present (never skipped when `false`), so a
967    /// consumer can tell "declared false" from "this runtime predates the key" by the field's
968    /// presence rather than by its value.
969    ///
970    /// `true` is the only manifest declaration that lowers [`Self::achieved_containment`], which is
971    /// why it is reported next to the grants rather than buried: reading `achieved: advisory` on a
972    /// Landlock-capable host makes sense only alongside it.
973    pub workdir_exec: bool,
974    /// `capabilities.filesystem.read_only`, verbatim — the workdir subtrees the capsule may read
975    /// but must not write. Empty when the capsule declares none, which is the whole workdir
976    /// writable. Always serialized as an array (never skipped), on the same terms as
977    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the field, not a
978    /// capsule that declared nothing.
979    ///
980    /// The one field here that subtracts rather than grants, which is why it is reported beside
981    /// [`Self::read_only_advisory_for`] rather than alone: a protection stated without its
982    /// qualification reads as a boundary the capsule does not have.
983    pub read_only_paths: Vec<String>,
984    /// The entries of [`Self::shell_allow`] that [`Self::read_only_paths`] is only advisory
985    /// against — the same set `W-SEC-017` fires for, resolved by the same
986    /// [`read_only_advisory_for`] both this report and `mur doctor` call.
987    ///
988    /// Empty means the declaration is enforced for every readable call. Non-empty names the
989    /// binaries whose own file I/O the dispatch-time write-intent analyser cannot read, so the
990    /// report cannot claim more than `W-SEC-017` already qualifies. Always serialized as an array
991    /// (never skipped), on the same terms as [`Self::read_only_paths`], and always empty when
992    /// that list is: nothing is advisory against a protection that was never declared.
993    pub read_only_advisory_for: Vec<String>,
994    /// The filesystem surface every guest-bearing artifact in this capsule is preopened into, one
995    /// entry per `runtime: tool`, `runtime: driver` and `runtime: hook` entry, in manifest order.
996    /// Empty when the capsule declares only skills. Always serialized as an array (never skipped),
997    /// on the same terms as [`Self::state_stores`]: an absent key identifies a runtime that
998    /// predates the field, not a capsule with no guest artifacts.
999    ///
1000    /// This is the field that answers "what can this tool read and write?".
1001    /// [`Self::filesystem_scope`] above does not: it is the capsule-wide declaration, and it is
1002    /// joined into no preopen root.
1003    ///
1004    /// Copied through untouched, exactly as the reports beside it are — resolving a preopen
1005    /// changes no class, no floor and no tier.
1006    pub preopens: Vec<PreopenReport>,
1007    /// `capabilities.network.allow`, verbatim — declared destinations, not resolved IPs.
1008    pub network_allow: Vec<String>,
1009    /// `capabilities.network.unix_sockets`.
1010    pub unix_sockets: bool,
1011    /// `capabilities.shell.allow`, verbatim.
1012    pub shell_allow: Vec<String>,
1013    /// `capabilities.spawn.allow`, verbatim.
1014    pub spawn_allow: Vec<String>,
1015    /// `capabilities.env.allow`, verbatim.
1016    pub env_allow: Vec<String>,
1017    /// `capabilities.plan.submit` — whether the model is offered the `submit-plan` tool. Always
1018    /// serialized (never skipped when `false`), on the same terms as [`Self::workdir_exec`]: an
1019    /// absent key identifies a runtime that predates the grant, not a capsule that declined it.
1020    ///
1021    /// It opens no host resource of its own. It is reported beside the grants because it changes
1022    /// *how much* of every other grant one model turn can reach: a plan runs many steps against
1023    /// this capsule's own tools, shell allowlist and spawn grant without a turn in between.
1024    pub plan_submit: bool,
1025    /// `capabilities.install.skill`, verbatim: which skill artifacts a pull (`manage.pull`,
1026    /// `pull-artifact`) may install into the session. Always serialized, an empty list included,
1027    /// on [`Self::plan_submit`]'s terms: an absent key identifies a runtime that predates the
1028    /// grant.
1029    ///
1030    /// It opens no host resource of its own. A pulled skill adds text to what the model reads and
1031    /// gains no grant.
1032    pub install_skill: Vec<String>,
1033    /// `capabilities.install.tool`, verbatim: which tool artifacts, WASM or native, a pull may
1034    /// install. Always serialized, on [`Self::install_skill`]'s terms. A pulled tool joins the
1035    /// dispatch allowlist, runs on this capsule's own grants and gains none of its own.
1036    pub install_tool: Vec<String>,
1037    /// `capabilities.install.from`: `["stores"]`, or `["stores", "sources"]` when a pull the
1038    /// stores cannot answer is fetched from the scope's GitHub source. `[]` for a capsule with no
1039    /// install grant, which pulls from nowhere. Always serialized, on [`Self::install_skill`]'s
1040    /// terms.
1041    pub install_from: Vec<String>,
1042    /// Every host directory a `capabilities.shell.interpreter_runtime` grant opens, rendered as
1043    /// `<binary>: <dir>[ (list_dir)]`. These are the paths outside the workdir that stay
1044    /// reachable even at `scoped`.
1045    pub interpreter_runtime_grants: Vec<String>,
1046    /// Every host path staging derived from a recognised `shell.allow` interpreter's own import
1047    /// path, rendered `<binary>: <dir> (list_dir)` for a directory and `<binary>: <path>` for a zip
1048    /// archive or an extension-module library. Each is readable and none is executable. Always
1049    /// serialized, `[]` when no allowlisted binary is a recognised interpreter.
1050    pub derived_interpreter_runtime: Vec<String>,
1051    /// Every host trust-store path staging found for a capsule whose commands reach the network,
1052    /// rendered `<path> (list_dir)` for a directory and `<path>` for a CA bundle file. Each is
1053    /// readable and none is executable. Always serialized, `[]` when `shell.allow` or
1054    /// `network.allow` is empty or the host has none of the locations
1055    /// [`crate::trust_store`] checks.
1056    pub trust_store: Vec<String>,
1057    /// The read-only file surface `exports.files` declares, or `null` when the capsule declares
1058    /// none. Always serialized (never skipped), so a consumer can tell "this runtime predates
1059    /// exports" from "this capsule declined to export anything" by the field's presence rather
1060    /// than by its value — the same terms [`Self::workdir_exec`] is reported on.
1061    ///
1062    /// An export is a *disclosure*, not a grant: it never appears in
1063    /// [`Self::achieved_containment`], and declaring one cannot change any other field of this
1064    /// report. It is printed beside the grants because it is the other direction of the same
1065    /// question — what crosses the capsule boundary, and which way.
1066    pub exports_files: Option<ExportsFilesReport>,
1067    /// The peer-handoff surface `exports.peer_files` declares, or `null` when the capsule
1068    /// declares none. Always serialized, on the same terms as [`Self::exports_files`].
1069    ///
1070    /// A separate authoriser from `exports.files`, over a separate subtree: a capsule may declare
1071    /// either, both or neither, and this field says nothing about the other. Like every export it
1072    /// is a disclosure rather than a grant, and declaring it cannot change
1073    /// [`Self::achieved_containment`], [`Self::enforcement_tier`] or [`Self::floor_met`].
1074    pub peer_files: Option<PeerFilesReport>,
1075    /// Whether the door serves tasks from peer capsules: `exports.peer_tasks.accept`, with an
1076    /// absent block reported as `false`. Always serialized.
1077    ///
1078    /// Like every export it opens a surface rather than granting the guest anything, and it cannot
1079    /// change [`Self::achieved_containment`], [`Self::enforcement_tier`] or [`Self::floor_met`].
1080    pub peer_tasks: bool,
1081    /// `capabilities.peer_fetch.allow`, verbatim — the peers this capsule may redeem a handle
1082    /// against. Empty when the block is absent, which is deny.
1083    ///
1084    /// A separate list from [`Self::network_allow`] and never an alias for it: a destination
1085    /// reachable by `send` is not redeemable unless it also appears here, because ingesting a
1086    /// peer's bytes into this workdir is a different question from being allowed to talk to it.
1087    pub peer_fetch_allow: Vec<String>,
1088    /// Every durable state store this session's artifacts declare, one entry per declaring
1089    /// artifact, empty when none does. Always serialized as an array (never skipped), on the same
1090    /// terms as [`Self::exports_files`]: an absent key identifies a runtime that predates state
1091    /// stores, not a capsule that declined one.
1092    ///
1093    /// Declaring a store cannot change [`Self::achieved_containment`], [`Self::floor_met`] or
1094    /// [`Self::enforcement_tier`] — it is copied through untouched, exactly as the export reports
1095    /// beside it are. It is reported because it is the one grant that opens a path *outside* every
1096    /// workdir, which nothing else in this block would reveal.
1097    pub state_stores: Vec<StateStoreReport>,
1098    /// Every artifact whose manifest entry declares a `config:` block, in manifest order, empty
1099    /// when none does. Always serialized as an array (never skipped), on the same terms as
1100    /// [`Self::state_stores`]: an absent key identifies a runtime that predates artifact config,
1101    /// not a capsule that declined it.
1102    ///
1103    /// Names only. What an artifact is configured *with* is operator-authored plaintext that
1104    /// belongs in the manifest and in nothing this report — or `trace.jsonl`, which carries this
1105    /// report verbatim — writes out.
1106    ///
1107    /// Declaring config cannot change [`Self::achieved_containment`], [`Self::floor_met`] or
1108    /// [`Self::enforcement_tier`]: it adds one environment variable to one guest and reaches no
1109    /// grant. Reported because a variable an artifact silently depends on is otherwise invisible
1110    /// to an operator reading the capsule's scope.
1111    pub configured_artifacts: Vec<String>,
1112    /// Every `capabilities.shell.staged_runtime` grant, rendered as
1113    /// `<binary>: <source_path> (pin: <pin>)`. These are host runtime trees a `sealed` capsule
1114    /// asks to have bind-mounted read-only into its composed root.
1115    ///
1116    /// Reported whatever the floor and whatever this host can back — a capsule that declares a
1117    /// staged runtime and cannot run here is exactly the case an operator is inspecting when they
1118    /// reach for `--explain-scope`, so hiding the grant behind a met floor would blank out the
1119    /// diagnostic in the one case it is for.
1120    pub staged_runtime_grants: Vec<String>,
1121    /// Every path the runtime itself writes inside the capsule's workdir, so a consumer for whom
1122    /// that workdir is the deliverable can subtract them and be left with what the *capsule*
1123    /// changed. Always serialized as an array (never skipped), on the same terms as
1124    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the declaration,
1125    /// not a runtime that writes nothing.
1126    ///
1127    /// The one field here that describes what the runtime does rather than what the capsule may
1128    /// do. It is reported beside the grants because it answers the other half of "what is this
1129    /// workdir", and because a consumer that has to maintain its own list of runtime entries
1130    /// silently falls behind every time the set changes — enabling `sealed` being exactly that
1131    /// case.
1132    ///
1133    /// The same set whatever [`Self::enforcement_tier`] and [`Self::achieved_containment`] say:
1134    /// no row turns on the tier. See [`crate::workdir_writes`].
1135    pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1136    /// The declared `capabilities.resources.cgroup_io_bytes_per_sec` ceiling, and whether it is
1137    /// actually on this session's cgroup scope.
1138    ///
1139    /// Always serialized (never skipped), on the same terms as [`Self::workdir_exec`] and
1140    /// [`Self::state_stores`]: an absent key identifies a runtime that predates the field, not a
1141    /// host that was not asked. [`IoMaxStatus::NotProbed`] is what "not asked" looks like, and it
1142    /// is a value in the report rather than a missing key.
1143    ///
1144    /// The one field here filled from a *performed* operation rather than from the policy: at
1145    /// launch it carries the outcome of the real `io.max` write, established before `session_start`
1146    /// is written so `trace.jsonl` cannot claim a ceiling the kernel refused; under
1147    /// `--explain-scope` it carries the outcome of the same write against a throwaway cgroup that
1148    /// is created and removed. Declaring a ceiling that does not apply changes no class and refuses
1149    /// no launch — `io.max` is the one non-fatal cgroup limit — so this field is the only place
1150    /// the shortfall appears.
1151    pub io_max: IoMaxReport,
1152    /// What this session's filesystem mechanism does not restrict — the block `--explain-scope`
1153    /// prints as `Not protected here`, directly under `Containment`.
1154    ///
1155    /// Always serialized (never skipped), on the same terms as [`Self::state_stores`]: an absent
1156    /// key identifies a runtime that predates the field, and an empty
1157    /// [`FilesystemBoundaryReport::not_protected`] identifies a session with nothing to disclaim.
1158    ///
1159    /// Derived in [`filesystem_boundary`] from the tier [`Self::enforcement_tier`] names, and
1160    /// from no [`CapabilityPolicy`] field: `workdir_exec` caps [`Self::achieved_containment`]
1161    /// without changing what the mechanism restricts. It stands beside the grants rather than
1162    /// among them because it qualifies every one at once.
1163    pub filesystem_boundary: FilesystemBoundaryReport,
1164    /// What the session's runtime directory is to its commands, from [`runtime_dir_report`] on
1165    /// the applied tier, [`Self::landlock_abi`] and where the directory lies. Always serialized.
1166    /// When it is not read-only, its reason is also a [`FilesystemBoundaryReport::not_protected`]
1167    /// statement.
1168    pub runtime_dir: RuntimeDirReport,
1169    /// What the session's records directory is to its commands, from [`records_dir_report`] on
1170    /// the same inputs. Always serialized; its reason, when set, is also a
1171    /// [`FilesystemBoundaryReport::not_protected`] statement.
1172    pub records_dir: RuntimeDirReport,
1173    /// The session's temporary folder and whether a literal `/tmp` reaches it, from
1174    /// [`temp_dir_report`] on the session's [`crate::temp_dir::temp_mapping`] answer — the one
1175    /// its enforcement installs. Always serialized.
1176    pub temp_dir: TempDirReport,
1177}
1178
1179impl ScopeReport {
1180    /// Human-readable rendering used by `mur run --explain-scope` without `--json`.
1181    pub fn render(&self) -> String {
1182        let mut out = String::new();
1183        out.push_str("Containment\n");
1184        out.push_str(&format!("  declared:  {}\n", self.declared_containment));
1185        out.push_str(&format!("  achieved:  {}\n", self.achieved_containment));
1186        out.push_str(&format!("  host can back: {}\n", self.host_containment));
1187        out.push_str(&format!(
1188            "  floor met: {}\n",
1189            if self.floor_met { "yes" } else { "no" }
1190        ));
1191        if let Some(reason) = &self.shortfall_reason {
1192            out.push_str(&format!("  reason:    {reason}\n"));
1193        }
1194        out.push_str(&format!("  mechanism: {}\n", self.enforcement_tier));
1195        out.push_str(&format!(
1196            "  covers:    {}\n",
1197            render_mechanism_covers(&self.mechanism_covers)
1198        ));
1199        out.push_str(&format!(
1200            "  userns grant: {}\n",
1201            match self.userns_grant {
1202                Some(grant) => grant.wire_name(),
1203                None => "n/a",
1204            }
1205        ));
1206        out.push_str(&format!(
1207            "  landlock ABI: {}\n",
1208            match self.landlock_abi {
1209                Some(abi) => abi.to_string(),
1210                None => "n/a".to_string(),
1211            }
1212        ));
1213
1214        out.push('\n');
1215        out.push_str(&render_runtime_dir(&self.runtime_dir));
1216        out.push('\n');
1217        out.push_str(&render_records_dir(&self.records_dir));
1218        out.push('\n');
1219        out.push_str(&render_temp_dir(&self.temp_dir));
1220
1221        out.push_str(&render_not_protected(&self.filesystem_boundary));
1222
1223        out.push_str("\nEffective grants\n");
1224        out.push_str(&format!(
1225            "  filesystem scope: {}\n",
1226            self.filesystem_scope.as_deref().unwrap_or("<none>")
1227        ));
1228        out.push_str(&format!("  workdir exec:     {}\n", self.workdir_exec));
1229        out.push_str(&render_read_only(
1230            &self.read_only_paths,
1231            &self.read_only_advisory_for,
1232        ));
1233        push_list(
1234            &mut out,
1235            "preopens",
1236            &self
1237                .preopens
1238                .iter()
1239                .map(|preopen| preopen.render(self.runtime_dir.read_only_to_commands))
1240                .collect::<Vec<_>>(),
1241        );
1242        push_list(&mut out, "network allow", &self.network_allow);
1243        out.push_str(&format!("  unix sockets:     {}\n", self.unix_sockets));
1244        push_list(&mut out, "shell allow", &self.shell_allow);
1245        push_list(&mut out, "spawn allow", &self.spawn_allow);
1246        push_list(&mut out, "env allow", &self.env_allow);
1247        out.push_str(&format!("  plan submit:      {}\n", self.plan_submit));
1248        out.push_str(&render_install(
1249            &self.install_skill,
1250            &self.install_tool,
1251            &self.install_from,
1252        ));
1253        push_list(
1254            &mut out,
1255            "interpreter runtime",
1256            &self.interpreter_runtime_grants,
1257        );
1258        push_list(
1259            &mut out,
1260            "derived interpreter runtime",
1261            &self.derived_interpreter_runtime,
1262        );
1263        push_list(&mut out, "trust store", &self.trust_store);
1264        push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1265        push_list(
1266            &mut out,
1267            "state stores",
1268            &self
1269                .state_stores
1270                .iter()
1271                .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1272                .collect::<Vec<_>>(),
1273        );
1274        push_list(&mut out, "artifact config", &self.configured_artifacts);
1275
1276        out.push_str("\nResource plane\n");
1277        match &self.exports_files {
1278            None => out.push_str("  exports.files: <none>\n"),
1279            Some(export) => {
1280                out.push_str(&format!("  exports.files root: {}\n", export.root));
1281                out.push_str(&format!("  mode:               {}\n", export.mode));
1282                out.push_str(&format!(
1283                    "  max bytes:          {} (per file)\n",
1284                    export.max_bytes
1285                ));
1286            }
1287        }
1288
1289        out.push_str(&format!(
1290            "  io.max:        {} ({} bytes/s declared)\n",
1291            match self.io_max.status {
1292                IoMaxStatus::Enforced => "enforced",
1293                IoMaxStatus::Unavailable => "unavailable",
1294                IoMaxStatus::NotRequired => "not-required",
1295                IoMaxStatus::NotProbed => "not-probed",
1296            },
1297            self.io_max.declared_bytes_per_sec
1298        ));
1299        if let Some(reason) = &self.io_max.reason {
1300            out.push_str(&format!("    {reason}\n"));
1301        }
1302
1303        out.push_str("\nPeer handoff\n");
1304        match &self.peer_files {
1305            None => out.push_str("  exports.peer_files: <none>\n"),
1306            Some(peer) => {
1307                out.push_str(&format!("  exports.peer_files root: {}\n", peer.root));
1308                out.push_str(&format!(
1309                    "  max handle ttl:          {}s\n",
1310                    peer.max_ttl_secs
1311                ));
1312                out.push_str(&format!(
1313                    "  max bytes:               {} (per file)\n",
1314                    peer.max_bytes
1315                ));
1316            }
1317        }
1318        out.push_str(if self.peer_tasks {
1319            "  exports.peer_tasks: accept\n"
1320        } else {
1321            "  exports.peer_tasks: refuse\n"
1322        });
1323        push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1324
1325        out.push_str("\nRuntime writes\n");
1326        out.push_str(&crate::workdir_writes::render_runtime_writes(
1327            &self.runtime_writes,
1328        ));
1329
1330        if !self.floor_met {
1331            out.push_str(
1332                "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1333                 launch here.\n",
1334            );
1335        }
1336        out
1337    }
1338}
1339
1340/// The allowlisted binaries a declared `capabilities.filesystem.read_only` is only advisory
1341/// against: those whose own file I/O the dispatch-time write-intent analyser cannot read, in
1342/// `shell_allow` order.
1343///
1344/// Exactly the set `W-SEC-017` fires one line for, resolved here so `--explain-scope`, `trace.jsonl`
1345/// and `mur doctor` cannot disagree with the warning printed beside them. Empty when nothing is
1346/// declared read-only: an interpreter is not advisory against a protection that does not exist.
1347#[must_use]
1348pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
1349    if read_only_paths.is_empty() {
1350        return Vec::new();
1351    }
1352    shell_allow
1353        .iter()
1354        .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
1355        .cloned()
1356        .collect()
1357}
1358
1359/// The `read_only` block of the scope report: the declared paths, then one line saying whether the
1360/// protection is enforced for every readable call or advisory against a named binary.
1361///
1362/// Shared by [`ScopeReport::render`] and by `mur doctor` so the two cannot word the same grant
1363/// differently — the precedent [`PreopenReport::render`] sets. Ends with a newline; emits nothing
1364/// beyond the `<none>` line when no path is declared, because there is no enforcement question to
1365/// answer about a protection nobody asked for.
1366#[must_use]
1367pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
1368    let mut out = String::new();
1369    push_list(&mut out, "read_only", read_only_paths);
1370    if read_only_paths.is_empty() {
1371        return out;
1372    }
1373    if advisory_for.is_empty() {
1374        out.push_str(
1375            "  read_only enforcement: enforced for every tool call and every shell command the \
1376             dispatch check can read\n",
1377        );
1378    } else {
1379        out.push_str(&format!(
1380            "  read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
1381             something the dispatch check can read; enforced for every tool call and for every \
1382             shell command it can read\n",
1383            advisory_for.join(", ")
1384        ));
1385    }
1386    out
1387}
1388
1389/// The `install skill`, `install tool` and `install from` lines of the scope report: which
1390/// skills and tools a pull may install, `<none>` for a list the capsule left empty, and the
1391/// places it may install them from (`install from: stores` or `install from: stores, sources`),
1392/// printed only for a capsule holding an install grant.
1393///
1394/// Shared by [`ScopeReport::render`] and by `mur doctor`, on [`render_read_only`]'s terms. Ends
1395/// with a newline.
1396#[must_use]
1397pub fn render_install(
1398    install_skill: &[String],
1399    install_tool: &[String],
1400    install_from: &[String],
1401) -> String {
1402    let mut out = String::new();
1403    push_list(&mut out, "install skill", install_skill);
1404    push_list(&mut out, "install tool", install_tool);
1405    if !install_from.is_empty() {
1406        out.push_str(&format!("  install from: {}\n", install_from.join(", ")));
1407    }
1408    out
1409}
1410
1411/// [`ScopeReport::install_from`] for `policy`: empty without an install grant.
1412#[must_use]
1413pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
1414    if !crate::install_grant::is_granted(policy) {
1415        return Vec::new();
1416    }
1417    let mut places = vec!["stores".to_string()];
1418    if policy.install_from_sources {
1419        places.push("sources".to_string());
1420    }
1421    places
1422}
1423
1424/// The `Not protected here` block: one line per statement in
1425/// [`FilesystemBoundaryReport::not_protected`], and nothing at all when that list is empty.
1426///
1427/// A heading with no entries under it would read as a disclaimer the reader had to interpret, so a
1428/// composed root prints no heading rather than an empty one. Every line is a statement from the report
1429/// verbatim, so the human rendering and `--explain-scope --json` say the same thing in the same
1430/// words.
1431fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
1432    if boundary.not_protected.is_empty() {
1433        return String::new();
1434    }
1435    let mut out = String::from("\nNot protected here\n");
1436    for statement in &boundary.not_protected {
1437        out.push_str(&format!("  - {statement}\n"));
1438    }
1439    out
1440}
1441
1442fn push_list(out: &mut String, label: &str, values: &[String]) {
1443    if values.is_empty() {
1444        out.push_str(&format!("  {label}: <none>\n"));
1445        return;
1446    }
1447    out.push_str(&format!("  {label}:\n"));
1448    for value in values {
1449        out.push_str(&format!("    - {value}\n"));
1450    }
1451}
1452
1453/// Builds a [`ScopeReport`] for `policy` against this host, with `declared` as the already-
1454/// combined floor. Takes one [`crate::sandbox::HostProbe`], so the tier, the shortfall and the
1455/// grant it reports all describe one host at one moment.
1456///
1457/// `state_stores`, `configured_artifacts` and `preopens` are resolved by the caller (from the
1458/// manifest's artifact entries, via [`crate::state_store::state_store_reports`],
1459/// [`crate::artifact_config::configured_artifact_names`] and
1460/// [`crate::network_policy::preopen_reports`]) rather than derived here, because all three are
1461/// per-artifact and `policy` is capsule-wide. No resolution creates anything, so this stays a
1462/// read-only diagnostic.
1463///
1464/// `workdir` is the workdir root the launch would use — `--workdir`, or `<manifest dir>/workdir`
1465/// without one — which names the records group and decides whether the runtime or records
1466/// directory lies inside it. Nothing is created to answer that; a `HOME` that cannot hold either
1467/// is refused, as the launch would be.
1468#[allow(clippy::too_many_arguments)]
1469pub fn explain_scope(
1470    policy: &CapabilityPolicy,
1471    declared: ContainmentClass,
1472    exports: Option<&Exports>,
1473    state_stores: Vec<StateStoreReport>,
1474    configured_artifacts: Vec<String>,
1475    preopens: Vec<PreopenReport>,
1476    io_max: IoMaxReport,
1477    workdir: &std::path::Path,
1478) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
1479    let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1480    let probe = HostProbe::probe();
1481    let decision = ContainmentDecision::decide(
1482        probe.tier(),
1483        declared,
1484        policy.workdir_exec_allowed,
1485        placement.inside_workdir(),
1486    );
1487    let temp_mapping = crate::temp_dir::temp_mapping(
1488        decision.applied_tier(),
1489        probe.mount_namespace_usable(),
1490        &placement.workdir,
1491    );
1492    Ok(scope_report_for_tier(
1493        policy,
1494        &decision,
1495        probe.sealed_shortfall().as_ref(),
1496        probe.userns_grant(),
1497        probe.landlock_kernel_abi(),
1498        exports,
1499        state_stores,
1500        configured_artifacts,
1501        preopens,
1502        &placement,
1503        temp_mapping,
1504        io_max,
1505    ))
1506}
1507
1508/// `mur doctor`'s `Runtime directory`, `Records directory` and `Temporary folder` sections for a
1509/// capsule declaring `declared`, run from `workdir`: the same [`runtime_dir_report`],
1510/// [`records_dir_report`] and [`temp_dir_report`] on the tier `probe`'s [`ContainmentDecision`]
1511/// applies, its ruleset ABI and mount namespace, or why `HOME` cannot hold them.
1512///
1513/// `probe` is the reading the rest of the doctor run reports from, so every section describes one
1514/// host at one moment.
1515pub fn doctor_runtime_dir(
1516    probe: &HostProbe,
1517    declared: ContainmentClass,
1518    workdir: &std::path::Path,
1519) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
1520    let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1521    // `workdir_exec` and the placement cap the class, never the tier, and only the tier is read.
1522    let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
1523    let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
1524    let temp_mapping =
1525        crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
1526    Ok(DoctorDirReports {
1527        runtime_dir: runtime_dir_report(applied, abi, &placement),
1528        records_dir: records_dir_report(applied, abi, &placement),
1529        temp_dir: temp_dir_report(temp_mapping, &placement),
1530        placement,
1531    })
1532}
1533
1534/// What [`doctor_runtime_dir`] reports.
1535#[derive(Debug, Clone, PartialEq, Eq)]
1536pub struct DoctorDirReports {
1537    pub runtime_dir: RuntimeDirReport,
1538    pub records_dir: RuntimeDirReport,
1539    pub temp_dir: TempDirReport,
1540    pub placement: RuntimeDirPlacement,
1541}
1542
1543/// [`explain_scope`] with the [`ContainmentDecision`] and the sealed shortfall injected — the
1544/// seam `runtime::stage_session` builds the session's report through, and every test, through
1545/// [`report_for`], so no test depends on the host it happens to run on.
1546///
1547/// `decision` must have been decided with `policy.workdir_exec_allowed` and
1548/// `runtime_dir_placement.inside_workdir()`: the report states both beside the class they cap.
1549#[allow(clippy::too_many_arguments)]
1550pub(crate) fn scope_report_for_tier(
1551    policy: &CapabilityPolicy,
1552    decision: &ContainmentDecision,
1553    sealed_shortfall: Option<&SealedShortfall>,
1554    userns_grant: Option<UsernsGrant>,
1555    landlock_kernel_abi: Option<u8>,
1556    exports: Option<&Exports>,
1557    state_stores: Vec<StateStoreReport>,
1558    configured_artifacts: Vec<String>,
1559    preopens: Vec<PreopenReport>,
1560    runtime_dir_placement: &RuntimeDirPlacement,
1561    temp_mapping: crate::temp_dir::TempMapping,
1562    io_max: IoMaxReport,
1563) -> ScopeReport {
1564    debug_assert_eq!(
1565        *decision,
1566        ContainmentDecision::decide(
1567            decision.host_tier,
1568            decision.declared,
1569            policy.workdir_exec_allowed,
1570            runtime_dir_placement.inside_workdir(),
1571        ),
1572        "the decision must be made from the policy and placement this report states"
1573    );
1574    let declared = decision.declared();
1575    let achieved = decision.achieved();
1576    // The tier `ShellEnforcement::resolve` installs from the same decision. The mechanism fields
1577    // below are keyed on it rather than on `achieved`, which `workdir_exec` and the placement can
1578    // cap without changing what the mechanism restricts.
1579    let applied = decision.applied_tier();
1580    let shortfall_reason = containment_shortfall_reason(
1581        declared,
1582        achieved,
1583        sealed_shortfall,
1584        policy.workdir_exec_allowed,
1585        runtime_dir_placement.inside(),
1586    );
1587    let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
1588    let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
1589    let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
1590    let mut filesystem_boundary = filesystem_boundary(applied);
1591    for report in [&runtime_dir, &records_dir] {
1592        if let Some(reason) = &report.not_protected_because {
1593            filesystem_boundary.not_protected.push(reason.clone());
1594        }
1595    }
1596
1597    ScopeReport {
1598        declared_containment: declared,
1599        achieved_containment: achieved,
1600        host_containment: decision.host_containment(),
1601        floor_met: shortfall_reason.is_none(),
1602        shortfall_reason,
1603        enforcement_tier: decision.enforcement_tier(),
1604        mechanism_covers: MECHANISM_COVERS.to_vec(),
1605        userns_grant,
1606        landlock_abi,
1607        // Copied straight through and never consulted above: `achieved` is already computed, and
1608        // no export or peer grant may reach it.
1609        exports_files: exports
1610            .and_then(|exports| exports.files.as_ref())
1611            .map(ExportsFilesReport::from),
1612        peer_files: exports
1613            .and_then(|exports| exports.peer_files.as_ref())
1614            .map(PeerFilesReport::from),
1615        peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
1616        peer_fetch_allow: policy.peer_fetch_allow.clone(),
1617        // Resolved by `state_store::state_store_reports` before this call and copied through on
1618        // the same terms as the export reports above: a grant that opens a path outside the
1619        // workdir still gets no say in what class the session is reported to achieve.
1620        state_stores,
1621        // Resolved by `artifact_config::configured_artifact_names` before this call and copied
1622        // through on the same terms: one environment variable delivered to one guest gets no say
1623        // in what class the session is reported to achieve.
1624        configured_artifacts,
1625        filesystem_scope: policy.filesystem_scope.clone(),
1626        workdir_exec: policy.workdir_exec_allowed,
1627        read_only_paths: policy.read_only_paths.clone(),
1628        read_only_advisory_for: read_only_advisory_for(
1629            &policy.read_only_paths,
1630            &policy.shell_allow,
1631        ),
1632        // Resolved by `network_policy::preopen_reports` before this call and copied through on the
1633        // same terms as the reports above: what one guest is preopened into gets no say in what
1634        // class the session is reported to achieve.
1635        preopens,
1636        network_allow: policy.network_allow.clone(),
1637        unix_sockets: policy.unix_sockets_allowed,
1638        shell_allow: policy.shell_allow.clone(),
1639        spawn_allow: policy.spawn_allow.clone(),
1640        env_allow: policy.env_allow.clone(),
1641        plan_submit: policy.plan_submit,
1642        install_skill: policy.install_skill.clone(),
1643        install_tool: policy.install_tool.clone(),
1644        install_from: install_from_places(policy),
1645        interpreter_runtime_grants: policy
1646            .shell_interpreter_runtime
1647            .iter()
1648            .flat_map(|grant| {
1649                grant.dirs.iter().map(move |dir| {
1650                    let listable = if dir.list_dir { " (list_dir)" } else { "" };
1651                    format!("{}: {}{}", grant.binary, dir.path, listable)
1652                })
1653            })
1654            .collect(),
1655        derived_interpreter_runtime: policy
1656            .shell_derived_interpreter_runtime
1657            .iter()
1658            .flat_map(|runtime| {
1659                let binary = runtime.binary.display();
1660                runtime
1661                    .entries()
1662                    .map(|(path, is_dir)| {
1663                        let listable = if is_dir { " (list_dir)" } else { "" };
1664                        format!("{binary}: {}{listable}", path.display())
1665                    })
1666                    .chain(
1667                        runtime
1668                            .libraries
1669                            .iter()
1670                            .map(|library| format!("{binary}: {}", library.display())),
1671                    )
1672                    .collect::<Vec<_>>()
1673            })
1674            .collect(),
1675        trust_store: policy
1676            .shell_trust_store
1677            .iter()
1678            .map(|entry| {
1679                let listable = if entry.directory { " (list_dir)" } else { "" };
1680                format!("{}{listable}", entry.path.display())
1681            })
1682            .collect(),
1683        staged_runtime_grants: policy
1684            .shell_staged_runtime
1685            .iter()
1686            .map(|grant| {
1687                format!(
1688                    "{}: {} (pin: {})",
1689                    grant.binary, grant.source_path, grant.pin
1690                )
1691            })
1692            .collect(),
1693        // Nothing here touches the filesystem: `--explain-scope` returns before a workdir exists,
1694        // and a diagnostic that created the directory it describes would be answering a question
1695        // it had changed.
1696        runtime_writes: crate::workdir_writes::runtime_writes(),
1697        // Established by performing the `io.max` write, never inferred: `prepare_scope` fills it
1698        // at launch and `cgroup::probe_io_max` fills it under `--explain-scope`. Like every field
1699        // above it, it reaches no class and no floor.
1700        io_max,
1701        // Derived here, from the `applied` tier already resolved above, rather than filled by
1702        // either caller: `--explain-scope --json` and `session_start.effective_grants` are the
1703        // same object, and a field either of them worded for itself would make them differ.
1704        filesystem_boundary,
1705        runtime_dir,
1706        records_dir,
1707        temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
1708    }
1709}
1710
1711/// [`scope_report_for_tier`] for a capsule declaring `declared` on a host whose tier is `tier`,
1712/// deciding from `policy` and `runtime_dir_placement` as `runtime::stage_session` does.
1713#[cfg(test)]
1714#[allow(clippy::too_many_arguments)]
1715pub(crate) fn report_for(
1716    policy: &CapabilityPolicy,
1717    declared: ContainmentClass,
1718    tier: EnforcementTier,
1719    sealed_shortfall: Option<&SealedShortfall>,
1720    userns_grant: Option<UsernsGrant>,
1721    landlock_kernel_abi: Option<u8>,
1722    exports: Option<&Exports>,
1723    state_stores: Vec<StateStoreReport>,
1724    configured_artifacts: Vec<String>,
1725    preopens: Vec<PreopenReport>,
1726    runtime_dir_placement: &RuntimeDirPlacement,
1727    temp_mapping: crate::temp_dir::TempMapping,
1728    io_max: IoMaxReport,
1729) -> ScopeReport {
1730    let decision = ContainmentDecision::decide(
1731        tier,
1732        declared,
1733        policy.workdir_exec_allowed,
1734        runtime_dir_placement.inside_workdir(),
1735    );
1736    scope_report_for_tier(
1737        policy,
1738        &decision,
1739        sealed_shortfall,
1740        userns_grant,
1741        landlock_kernel_abi,
1742        exports,
1743        state_stores,
1744        configured_artifacts,
1745        preopens,
1746        runtime_dir_placement,
1747        temp_mapping,
1748        io_max,
1749    )
1750}
1751
1752/// The subprocess routes the session's enforcement tier confines, as
1753/// [`ScopeReport::mechanism_covers`] reports them.
1754pub(crate) const MECHANISM_COVERS: &[&str] = &["shell", "plan-shell-steps", "native-tools"];
1755
1756/// One [`MECHANISM_COVERS`] wire name as `--explain-scope` prints it.
1757fn render_mechanism_covers(covers: &[&str]) -> String {
1758    covers
1759        .iter()
1760        .map(|cover| match *cover {
1761            "shell" => "shell commands",
1762            "plan-shell-steps" => "plan shell steps",
1763            "native-tools" => "native tools",
1764            other => other,
1765        })
1766        .collect::<Vec<_>>()
1767        .join(", ")
1768}
1769
1770/// The wire name of the tier a native tool of a capsule declaring `declared` runs under on the
1771/// host `probe` read: [`ContainmentDecision::enforcement_tier`], which is what
1772/// `ShellEnforcement::resolve` installs for every subprocess. Spelled as
1773/// [`ScopeReport::enforcement_tier`] spells a tier.
1774pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
1775    // `workdir_exec` and the placement cap the class, never the tier, and only the tier is read.
1776    ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
1777}
1778
1779/// Stable wire name for a tier, so `--explain-scope --json` consumers see the mechanism rather
1780/// than a `Debug` rendering that is free to change.
1781fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
1782    match tier {
1783        EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
1784        EnforcementTier::KernelFull => "landlock+seccomp",
1785        EnforcementTier::KernelSeccompOnly => "seccomp-only",
1786        EnforcementTier::EnvironmentOnly => "none",
1787    }
1788}
1789
1790#[cfg(test)]
1791#[allow(clippy::print_stdout, clippy::print_stderr)]
1792mod tests {
1793    use super::*;
1794    use crate::sealed::SealedBlocker;
1795
1796    /// A shortfall of `blocker` with no failed attempt, whose reason is the class sentence alone.
1797    fn blocked(blocker: SealedBlocker) -> SealedShortfall {
1798        SealedShortfall {
1799            blocker,
1800            failed_call: None,
1801            errno: None,
1802            userns_grant: UsernsGrant::Withheld,
1803            attachment: crate::sealed::ProfileAttachment::NotReported,
1804        }
1805    }
1806    use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1807
1808    /// Every tier, so a new variant cannot quietly escape a test that iterates "all of them".
1809    const ALL_TIERS: &[EnforcementTier] = &[
1810        EnforcementTier::KernelSealed,
1811        EnforcementTier::KernelFull,
1812        EnforcementTier::KernelSeccompOnly,
1813        EnforcementTier::EnvironmentOnly,
1814    ];
1815
1816    #[test]
1817    fn kernel_full_achieves_scoped() {
1818        assert_eq!(
1819            achieved_class_for_tier(EnforcementTier::KernelFull),
1820            ContainmentClass::Scoped
1821        );
1822    }
1823
1824    /// The sealed tier, and only the sealed tier, reports `sealed`.
1825    #[test]
1826    fn only_the_sealed_tier_achieves_sealed() {
1827        assert_eq!(
1828            achieved_class_for_tier(EnforcementTier::KernelSealed),
1829            ContainmentClass::Sealed
1830        );
1831        for tier in ALL_TIERS
1832            .iter()
1833            .filter(|t| **t != EnforcementTier::KernelSealed)
1834        {
1835            assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1836        }
1837    }
1838
1839    #[test]
1840    fn tiers_without_landlock_achieve_only_advisory() {
1841        assert_eq!(
1842            achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1843            ContainmentClass::Advisory
1844        );
1845        assert_eq!(
1846            achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1847            ContainmentClass::Advisory
1848        );
1849    }
1850
1851    /// The whole tier × `workdir_exec` truth table, with both inputs supplied by the test and no
1852    /// kernel consulted — the seam `achieved_class_for_tier`/`achieved_containment_class` exists
1853    /// for. This is emphatically *not* a test of the security property (that a workdir binary
1854    /// cannot execute); it tests only the class arithmetic, which is all that can honestly be
1855    /// tested without a Landlock-capable host.
1856    #[test]
1857    fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1858        // Without the declaration: the host ceiling, unchanged.
1859        assert_eq!(
1860            achieved_containment_class(EnforcementTier::KernelSealed, false, false),
1861            ContainmentClass::Sealed
1862        );
1863        assert_eq!(
1864            achieved_containment_class(EnforcementTier::KernelFull, false, false),
1865            ContainmentClass::Scoped
1866        );
1867        assert_eq!(
1868            achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
1869            ContainmentClass::Advisory
1870        );
1871        assert_eq!(
1872            achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
1873            ContainmentClass::Advisory
1874        );
1875
1876        // With it: advisory, whatever the host could otherwise back.
1877        for tier in ALL_TIERS {
1878            assert_eq!(
1879                achieved_containment_class(*tier, true, false),
1880                ContainmentClass::Advisory,
1881                "workdir_exec must cap tier {tier:?} at advisory"
1882            );
1883        }
1884    }
1885
1886    /// The cap goes in the wrapper, never in the tier-only function — a regression here would mean
1887    /// a manifest key had leaked into the one mapping that must stay host-only.
1888    #[test]
1889    fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1890        for tier in ALL_TIERS {
1891            assert_eq!(
1892                achieved_class_for_tier(*tier),
1893                achieved_containment_class(*tier, false, false),
1894                "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1895            );
1896        }
1897    }
1898
1899    /// The refusal an operator actually hits: `workdir_exec: true` plus
1900    /// `capabilities.containment: scoped`, on a host that could otherwise back `scoped`. It must
1901    /// refuse, and the reason must send them to their manifest rather than to another machine.
1902    #[test]
1903    fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1904        let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
1905        assert_eq!(achieved, ContainmentClass::Advisory);
1906
1907        let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
1908            .expect_err("workdir_exec + scoped must refuse");
1909        match error {
1910            RuntimeError::ContainmentFloorUnmet {
1911                declared,
1912                achieved,
1913                reason,
1914                ..
1915            } => {
1916                assert_eq!(declared, ContainmentClass::Scoped);
1917                assert_eq!(achieved, ContainmentClass::Advisory);
1918                assert!(
1919                    reason.contains("workdir_exec"),
1920                    "the reason must name the manifest key, got: {reason}"
1921                );
1922                assert!(
1923                    !reason.contains("this host provides no kernel filesystem mediation"),
1924                    "the reason must not blame the host for a manifest declaration: {reason}"
1925                );
1926            }
1927            other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1928        }
1929    }
1930
1931    /// `sealed` gets the same manifest-side reason rather than a `SealedBlocker` one: a probed
1932    /// blocker is real but is not what is stopping *this* capsule, and reporting it would send the
1933    /// operator to install an AppArmor profile that changes nothing.
1934    #[test]
1935    fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1936        let reason = containment_shortfall_reason(
1937            ContainmentClass::Sealed,
1938            ContainmentClass::Advisory,
1939            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
1940            true,
1941            None,
1942        )
1943        .expect("refusal");
1944        assert!(reason.contains("workdir_exec"));
1945        assert!(!reason.contains("apparmor_parser"));
1946    }
1947
1948    /// The declaration is not itself a refusal: a capsule that declares `workdir_exec` and asks
1949    /// for nothing above `advisory` launches normally on every host.
1950    #[test]
1951    fn workdir_exec_alone_refuses_nothing() {
1952        for tier in ALL_TIERS {
1953            let achieved = achieved_containment_class(*tier, true, false);
1954            assert!(
1955                check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
1956                    .is_ok(),
1957                "workdir_exec at an advisory floor must launch on tier {tier:?}"
1958            );
1959        }
1960    }
1961
1962    #[test]
1963    fn advisory_floor_is_met_on_every_tier() {
1964        for tier in ALL_TIERS {
1965            let achieved = achieved_class_for_tier(*tier);
1966            assert!(check_containment_floor(
1967                ContainmentClass::Advisory,
1968                achieved,
1969                None,
1970                false,
1971                None
1972            )
1973            .is_ok());
1974        }
1975    }
1976
1977    #[test]
1978    fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1979        for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1980            let achieved = achieved_class_for_tier(tier);
1981            assert!(
1982                check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1983                    .is_ok()
1984            );
1985        }
1986
1987        for tier in [
1988            EnforcementTier::KernelSeccompOnly,
1989            EnforcementTier::EnvironmentOnly,
1990        ] {
1991            let achieved = achieved_class_for_tier(tier);
1992            let error =
1993                check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1994                    .expect_err("scoped must refuse without Landlock");
1995            match error {
1996                RuntimeError::ContainmentFloorUnmet {
1997                    declared,
1998                    achieved,
1999                    reason,
2000                    ..
2001                } => {
2002                    assert_eq!(declared, ContainmentClass::Scoped);
2003                    assert_eq!(achieved, ContainmentClass::Advisory);
2004                    assert!(
2005                        reason.contains("Landlock"),
2006                        "reason must name the missing mechanism, got: {reason}"
2007                    );
2008                }
2009                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2010            }
2011        }
2012    }
2013
2014    /// The edge case the sealed tier makes possible: a capsule declaring a *weaker* class on a
2015    /// sealed-capable host. It runs on `landlock+seccomp` and achieves `scoped` — never the
2016    /// `sealed` the host could back — and that still meets its floor.
2017    #[test]
2018    fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
2019        for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
2020            let decision =
2021                ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
2022            assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
2023            assert_eq!(decision.achieved(), ContainmentClass::Scoped);
2024            assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
2025            assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
2026            assert!(
2027                check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
2028            );
2029            assert_eq!(
2030                containment_shortfall_reason(declared, decision.achieved(), None, false, None),
2031                None
2032            );
2033        }
2034    }
2035
2036    const ALL_CLASSES: &[ContainmentClass] = &[
2037        ContainmentClass::Advisory,
2038        ContainmentClass::Scoped,
2039        ContainmentClass::Sealed,
2040    ];
2041
2042    /// Every host tier × declared class × `workdir_exec` × runtime directory inside the workdir:
2043    /// the tier the session installs, the class read off it, the host's ceiling and the wire name
2044    /// of the installed tier, from a table written out here rather than recomputed.
2045    #[test]
2046    fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
2047        use ContainmentClass::{Advisory, Scoped, Sealed};
2048        use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
2049        // (host tier, declared) → (applied tier, uncapped class, wire name)
2050        let table = [
2051            (
2052                (KernelSealed, Advisory),
2053                (KernelFull, Scoped, "landlock+seccomp"),
2054            ),
2055            (
2056                (KernelSealed, Scoped),
2057                (KernelFull, Scoped, "landlock+seccomp"),
2058            ),
2059            (
2060                (KernelSealed, Sealed),
2061                (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
2062            ),
2063            (
2064                (KernelFull, Advisory),
2065                (KernelFull, Scoped, "landlock+seccomp"),
2066            ),
2067            (
2068                (KernelFull, Scoped),
2069                (KernelFull, Scoped, "landlock+seccomp"),
2070            ),
2071            (
2072                (KernelFull, Sealed),
2073                (KernelFull, Scoped, "landlock+seccomp"),
2074            ),
2075            (
2076                (KernelSeccompOnly, Advisory),
2077                (KernelSeccompOnly, Advisory, "seccomp-only"),
2078            ),
2079            (
2080                (KernelSeccompOnly, Scoped),
2081                (KernelSeccompOnly, Advisory, "seccomp-only"),
2082            ),
2083            (
2084                (KernelSeccompOnly, Sealed),
2085                (KernelSeccompOnly, Advisory, "seccomp-only"),
2086            ),
2087            (
2088                (EnvironmentOnly, Advisory),
2089                (EnvironmentOnly, Advisory, "none"),
2090            ),
2091            (
2092                (EnvironmentOnly, Scoped),
2093                (EnvironmentOnly, Advisory, "none"),
2094            ),
2095            (
2096                (EnvironmentOnly, Sealed),
2097                (EnvironmentOnly, Advisory, "none"),
2098            ),
2099        ];
2100        assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
2101        for ((host_tier, declared), (applied, class, wire)) in table {
2102            let host_class = match host_tier {
2103                KernelSealed => Sealed,
2104                KernelFull => Scoped,
2105                KernelSeccompOnly | EnvironmentOnly => Advisory,
2106            };
2107            for workdir_exec in [false, true] {
2108                for inside in [false, true] {
2109                    let decision =
2110                        ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
2111                    let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
2112                    assert_eq!(decision.declared(), declared, "{what}");
2113                    assert_eq!(decision.applied_tier(), applied, "{what}");
2114                    assert_eq!(decision.enforcement_tier(), wire, "{what}");
2115                    assert_eq!(decision.host_containment(), host_class, "{what}");
2116                    let expected = if workdir_exec || inside {
2117                        Advisory
2118                    } else {
2119                        class
2120                    };
2121                    assert_eq!(decision.achieved(), expected, "{what}");
2122                    // No declaration raises a class above the host's, and only a `sealed`
2123                    // declaration is ever reported `sealed`.
2124                    assert!(decision.achieved() <= decision.host_containment(), "{what}");
2125                    assert!(
2126                        decision.achieved() != Sealed || declared == Sealed,
2127                        "{what}"
2128                    );
2129                }
2130            }
2131        }
2132
2133        // The three rows a sealed-capable host turns on, by name.
2134        let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
2135        assert_eq!(
2136            (
2137                scoped.achieved(),
2138                scoped.enforcement_tier(),
2139                scoped.host_containment()
2140            ),
2141            (Scoped, "landlock+seccomp", Sealed)
2142        );
2143        let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
2144        assert_eq!(
2145            (sealed.achieved(), sealed.enforcement_tier()),
2146            (Sealed, "mountns+pivot_root+landlock+seccomp")
2147        );
2148        let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
2149        assert_eq!(
2150            (advisory.achieved(), advisory.enforcement_tier()),
2151            (Scoped, "landlock+seccomp")
2152        );
2153    }
2154
2155    /// The floor refusal decides from the decision's class exactly as it did from the host tier's
2156    /// class: the two differ only where the host is sealed-capable and the declaration is below
2157    /// `sealed`, and there both meet the floor (or, capped, both refuse with the same words).
2158    #[test]
2159    fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
2160        for &host_tier in ALL_TIERS {
2161            for &declared in ALL_CLASSES {
2162                for workdir_exec in [false, true] {
2163                    for inside in [None, Some(inside_placement())] {
2164                        let in_workdir = inside.is_some();
2165                        let decision = ContainmentDecision::decide(
2166                            host_tier,
2167                            declared,
2168                            workdir_exec,
2169                            in_workdir,
2170                        );
2171                        let host_class =
2172                            achieved_containment_class(host_tier, workdir_exec, in_workdir);
2173                        let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
2174                        assert_eq!(
2175                            containment_shortfall_reason(
2176                                declared,
2177                                decision.achieved(),
2178                                Some(&shortfall),
2179                                workdir_exec,
2180                                inside.as_ref(),
2181                            ),
2182                            containment_shortfall_reason(
2183                                declared,
2184                                host_class,
2185                                Some(&shortfall),
2186                                workdir_exec,
2187                                inside.as_ref(),
2188                            ),
2189                            "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
2190                        );
2191                    }
2192                }
2193            }
2194        }
2195    }
2196
2197    #[test]
2198    fn sealed_floor_is_met_only_on_the_sealed_tier() {
2199        assert!(check_containment_floor(
2200            ContainmentClass::Sealed,
2201            achieved_class_for_tier(EnforcementTier::KernelSealed),
2202            None,
2203            false,
2204            None
2205        )
2206        .is_ok());
2207
2208        for tier in ALL_TIERS
2209            .iter()
2210            .filter(|t| **t != EnforcementTier::KernelSealed)
2211        {
2212            let achieved = achieved_class_for_tier(*tier);
2213            let error = check_containment_floor(
2214                ContainmentClass::Sealed,
2215                achieved,
2216                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2217                false,
2218                None,
2219            )
2220            .expect_err("sealed must refuse on a host that cannot back it");
2221            match error {
2222                RuntimeError::ContainmentFloorUnmet {
2223                    declared, reason, ..
2224                } => {
2225                    assert_eq!(declared, ContainmentClass::Sealed);
2226                    assert!(
2227                        reason.contains("--cap-add SYS_ADMIN"),
2228                        "reason must name the blocking mechanism's remediation, got: {reason}"
2229                    );
2230                }
2231                other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2232            }
2233        }
2234    }
2235
2236    /// The refusal text is mechanism-specific, not one fixed sentence: the same
2237    /// declared/achieved pair produces different, individually actionable reasons.
2238    #[test]
2239    fn the_sealed_refusal_names_the_specific_blocker() {
2240        let achieved = ContainmentClass::Scoped;
2241
2242        let apparmor = containment_shortfall_reason(
2243            ContainmentClass::Sealed,
2244            achieved,
2245            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2246            false,
2247            None,
2248        )
2249        .expect("refusal");
2250        assert!(apparmor.contains("mur-sealed"));
2251        assert!(apparmor.contains("apparmor_parser -r"));
2252        assert!(!apparmor.contains("--cap-add"));
2253
2254        let container = containment_shortfall_reason(
2255            ContainmentClass::Sealed,
2256            achieved,
2257            Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2258            false,
2259            None,
2260        )
2261        .expect("refusal");
2262        assert!(container.contains("--cap-add SYS_ADMIN"));
2263        assert!(container.contains("outside the container"));
2264        assert!(!container.contains("apparmor_parser"));
2265
2266        // A host that mounts and will not pivot is refused at launch with its own text, rather
2267        // than at the first subprocess with `E-RUN-014`.
2268        let pivot = containment_shortfall_reason(
2269            ContainmentClass::Sealed,
2270            ContainmentClass::Advisory,
2271            Some(&blocked(SealedBlocker::PivotRootDenied)),
2272            false,
2273            None,
2274        )
2275        .expect("refusal");
2276        assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
2277        assert_ne!(
2278            pivot,
2279            containment_shortfall_reason(
2280                ContainmentClass::Sealed,
2281                ContainmentClass::Advisory,
2282                Some(&blocked(SealedBlocker::MountDenied)),
2283                false,
2284                None,
2285            )
2286            .expect("refusal")
2287        );
2288
2289        // Never the pre-mechanism blanket text again, under any blocker.
2290        for blocker in [
2291            SealedBlocker::NotLinux,
2292            SealedBlocker::AppArmorProfileMissing,
2293            SealedBlocker::NamespaceCreationDenied,
2294            SealedBlocker::MountDenied,
2295            SealedBlocker::PivotRootDenied,
2296            SealedBlocker::KernelUnsupported,
2297            SealedBlocker::LandlockUnavailable,
2298        ] {
2299            let reason = containment_shortfall_reason(
2300                ContainmentClass::Sealed,
2301                achieved,
2302                Some(&blocked(blocker)),
2303                false,
2304                None,
2305            )
2306            .expect("refusal");
2307            assert!(
2308                !reason.contains("no host can provide it today"),
2309                "got: {reason}"
2310            );
2311        }
2312    }
2313
2314    /// A caller with no probe result still gets a reason naming the mechanism rather than an
2315    /// empty or misleading one.
2316    #[test]
2317    fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
2318        let reason = containment_shortfall_reason(
2319            ContainmentClass::Sealed,
2320            ContainmentClass::Advisory,
2321            None,
2322            false,
2323            None,
2324        )
2325        .expect("refusal");
2326        assert!(reason.contains("pivot_root"));
2327        assert!(reason.contains("CLONE_NEWNS"));
2328    }
2329
2330    #[test]
2331    fn shortfall_reason_is_absent_when_the_floor_is_met() {
2332        assert_eq!(
2333            containment_shortfall_reason(
2334                ContainmentClass::Advisory,
2335                ContainmentClass::Scoped,
2336                None,
2337                false,
2338                None
2339            ),
2340            None
2341        );
2342        assert_eq!(
2343            containment_shortfall_reason(
2344                ContainmentClass::Scoped,
2345                ContainmentClass::Scoped,
2346                None,
2347                false,
2348                None
2349            ),
2350            None
2351        );
2352        assert_eq!(
2353            containment_shortfall_reason(
2354                ContainmentClass::Sealed,
2355                ContainmentClass::Sealed,
2356                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2357                false,
2358                None
2359            ),
2360            None,
2361            "a met floor must ignore a stale blocker rather than manufacture a refusal"
2362        );
2363    }
2364
2365    fn sample_policy() -> CapabilityPolicy {
2366        CapabilityPolicy {
2367            network_allow: vec!["https://api.example.com".to_string()],
2368            unix_sockets_allowed: false,
2369            filesystem_scope: Some("workdir".to_string()),
2370            shell_allow: vec!["python3".to_string()],
2371            spawn_allow: vec!["helper".to_string()],
2372            env_allow: vec!["TZ".to_string()],
2373            shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
2374                binary: "python3".to_string(),
2375                dirs: vec![InterpreterRuntimeDir {
2376                    path: "/usr/lib/python3.11".to_string(),
2377                    list_dir: true,
2378                }],
2379            }],
2380            ..CapabilityPolicy::default()
2381        }
2382    }
2383
2384    #[test]
2385    fn scope_report_mirrors_the_policy_and_the_probed_tier() {
2386        let report = report_for(
2387            &sample_policy(),
2388            ContainmentClass::Scoped,
2389            EnforcementTier::KernelFull,
2390            None,
2391            None,
2392            None,
2393            None,
2394            Vec::new(),
2395            Vec::new(),
2396            Vec::new(),
2397            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2398            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2399                EnforcementTier::KernelFull,
2400                ContainmentClass::Scoped,
2401            )),
2402            IoMaxReport::default(),
2403        );
2404
2405        assert_eq!(report.declared_containment, ContainmentClass::Scoped);
2406        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2407        assert!(report.floor_met);
2408        assert_eq!(report.shortfall_reason, None);
2409        assert_eq!(report.enforcement_tier, "landlock+seccomp");
2410        assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
2411        assert_eq!(report.network_allow, vec!["https://api.example.com"]);
2412        assert!(!report.unix_sockets);
2413        assert_eq!(report.shell_allow, vec!["python3"]);
2414        assert_eq!(report.spawn_allow, vec!["helper"]);
2415        assert_eq!(report.env_allow, vec!["TZ"]);
2416        assert!(!report.workdir_exec);
2417        assert_eq!(
2418            report.interpreter_runtime_grants,
2419            vec!["python3: /usr/lib/python3.11 (list_dir)"]
2420        );
2421    }
2422
2423    /// `--explain-scope` is where an operator finds out *why* a Landlock-capable host is reporting
2424    /// `advisory`, so the declaration and the class it produced have to appear together — in the
2425    /// struct, in the JSON, and in the rendered text.
2426    #[test]
2427    fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
2428        let policy = CapabilityPolicy {
2429            shell_allow: vec!["gcc".to_string()],
2430            workdir_exec_allowed: true,
2431            ..CapabilityPolicy::default()
2432        };
2433
2434        let report = report_for(
2435            &policy,
2436            ContainmentClass::Scoped,
2437            EnforcementTier::KernelFull,
2438            None,
2439            None,
2440            None,
2441            None,
2442            Vec::new(),
2443            Vec::new(),
2444            Vec::new(),
2445            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2446            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2447                EnforcementTier::KernelFull,
2448                ContainmentClass::Scoped,
2449            )),
2450            IoMaxReport::default(),
2451        );
2452
2453        assert!(report.workdir_exec);
2454        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2455        assert!(!report.floor_met);
2456        // The host is fully capable; the mechanism line must keep saying so, so the report does
2457        // not read as a broken host.
2458        assert_eq!(report.enforcement_tier, "landlock+seccomp");
2459        assert!(report
2460            .shortfall_reason
2461            .as_deref()
2462            .expect("refusal")
2463            .contains("workdir_exec"));
2464
2465        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2466        assert_eq!(value["workdir_exec"], true);
2467
2468        let rendered = report.render();
2469        assert!(rendered.contains("workdir exec:     true"));
2470        assert!(rendered.contains("achieved:  advisory"));
2471    }
2472
2473    /// The key is always written, on the same terms `workdir_exec` and `state_stores` are: an
2474    /// absent `io_max` identifies a runtime that predates the field, not a host that was not
2475    /// asked. "Not asked" is a value — `not-probed` — inside the object.
2476    #[test]
2477    fn scope_report_json_always_carries_io_max() {
2478        let unprobed: serde_json::Value = serde_json::to_value(report_for(
2479            &sample_policy(),
2480            ContainmentClass::Scoped,
2481            EnforcementTier::KernelFull,
2482            None,
2483            None,
2484            None,
2485            None,
2486            Vec::new(),
2487            Vec::new(),
2488            Vec::new(),
2489            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2490            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2491                EnforcementTier::KernelFull,
2492                ContainmentClass::Scoped,
2493            )),
2494            IoMaxReport::default(),
2495        ))
2496        .unwrap();
2497        assert_eq!(unprobed["io_max"]["status"], "not-probed");
2498        assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
2499        assert!(unprobed["io_max"].get("reason").is_none());
2500
2501        let enforced: serde_json::Value = serde_json::to_value(report_for(
2502            &sample_policy(),
2503            ContainmentClass::Scoped,
2504            EnforcementTier::KernelFull,
2505            None,
2506            None,
2507            None,
2508            None,
2509            Vec::new(),
2510            Vec::new(),
2511            Vec::new(),
2512            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2513            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2514                EnforcementTier::KernelFull,
2515                ContainmentClass::Scoped,
2516            )),
2517            IoMaxReport {
2518                declared_bytes_per_sec: 52_428_800,
2519                status: IoMaxStatus::Enforced,
2520                reason: None,
2521            },
2522        ))
2523        .unwrap();
2524        assert_eq!(enforced["io_max"]["status"], "enforced");
2525        assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
2526    }
2527
2528    /// A ceiling that did not apply has to be visible in the rendering an operator reads, not
2529    /// only in the JSON: this report is where the claim of an I/O bound would otherwise stand
2530    /// uncontradicted.
2531    #[test]
2532    fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
2533        let rendered = report_for(
2534            &sample_policy(),
2535            ContainmentClass::Scoped,
2536            EnforcementTier::KernelFull,
2537            None,
2538            None,
2539            None,
2540            None,
2541            Vec::new(),
2542            Vec::new(),
2543            Vec::new(),
2544            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2545            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2546                EnforcementTier::KernelFull,
2547                ContainmentClass::Scoped,
2548            )),
2549            IoMaxReport {
2550                declared_bytes_per_sec: 52_428_800,
2551                status: IoMaxStatus::Unavailable,
2552                reason: Some("the io controller is not delegated".to_string()),
2553            },
2554        )
2555        .render();
2556
2557        assert!(rendered.contains("Resource plane"), "{rendered}");
2558        assert!(rendered.contains("io.max:"), "{rendered}");
2559        assert!(rendered.contains("unavailable"), "{rendered}");
2560        assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
2561        assert!(
2562            rendered.contains("the io controller is not delegated"),
2563            "{rendered}"
2564        );
2565    }
2566
2567    /// The field is always written, including `false`, so a consumer can distinguish "this capsule
2568    /// declared nothing" from "this runtime does not know the key".
2569    #[test]
2570    fn scope_report_json_always_carries_workdir_exec() {
2571        let value: serde_json::Value = serde_json::to_value(report_for(
2572            &sample_policy(),
2573            ContainmentClass::Scoped,
2574            EnforcementTier::KernelFull,
2575            None,
2576            None,
2577            None,
2578            None,
2579            Vec::new(),
2580            Vec::new(),
2581            Vec::new(),
2582            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2583            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2584                EnforcementTier::KernelFull,
2585                ContainmentClass::Scoped,
2586            )),
2587            IoMaxReport::default(),
2588        ))
2589        .unwrap();
2590        assert_eq!(value["workdir_exec"], false);
2591    }
2592
2593    /// `--explain-scope` is a diagnostic, not a launch: a capsule declaring `staged_runtime` on a
2594    /// host that cannot back `sealed` is exactly the case an operator inspects with it, so the
2595    /// grant must be reported on every tier — including the ones where `mur run` would refuse.
2596    #[test]
2597    fn staged_runtime_grants_are_reported_on_every_tier() {
2598        let policy = CapabilityPolicy {
2599            shell_allow: vec!["python3".to_string()],
2600            shell_staged_runtime: vec![StagedRuntimeGrant {
2601                binary: "python3".to_string(),
2602                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
2603                pin: "conda-4.10.3/python-3.9.19".to_string(),
2604            }],
2605            ..CapabilityPolicy::default()
2606        };
2607
2608        for tier in ALL_TIERS {
2609            let report = report_for(
2610                &policy,
2611                ContainmentClass::Sealed,
2612                *tier,
2613                Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2614                None,
2615                None,
2616                None,
2617                Vec::new(),
2618                Vec::new(),
2619                Vec::new(),
2620                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2621                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2622                    *tier,
2623                    ContainmentClass::Sealed,
2624                )),
2625                IoMaxReport::default(),
2626            );
2627            assert_eq!(
2628                report.staged_runtime_grants,
2629                vec![
2630                    "python3: /opt/testbed/conda/envs/django__django \
2631                     (pin: conda-4.10.3/python-3.9.19)"
2632                ],
2633                "tier {tier:?} must still report the declared staged runtime"
2634            );
2635            assert!(
2636                report.render().contains("staged runtime"),
2637                "the rendered report must carry a staged runtime section on tier {tier:?}"
2638            );
2639        }
2640    }
2641
2642    /// The absent case: no `staged_runtime` in the manifest leaves the section empty rather than
2643    /// inventing one, and `render` still names it as `<none>` like every other empty list.
2644    #[test]
2645    fn absent_staged_runtime_reports_nothing() {
2646        let report = report_for(
2647            &sample_policy(),
2648            ContainmentClass::Scoped,
2649            EnforcementTier::KernelFull,
2650            None,
2651            None,
2652            None,
2653            None,
2654            Vec::new(),
2655            Vec::new(),
2656            Vec::new(),
2657            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2658            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2659                EnforcementTier::KernelFull,
2660                ContainmentClass::Scoped,
2661            )),
2662            IoMaxReport::default(),
2663        );
2664        assert!(report.staged_runtime_grants.is_empty());
2665        assert!(report.render().contains("staged runtime: <none>"));
2666    }
2667
2668    /// A capsule that declares `scoped` *and* opens host paths through `interpreter_runtime`
2669    /// still achieves only `scoped` — the grant set is reported, never consulted to upgrade
2670    /// the achieved class.
2671    #[test]
2672    fn grant_set_never_raises_the_achieved_class() {
2673        let report = report_for(
2674            &sample_policy(),
2675            ContainmentClass::Sealed,
2676            EnforcementTier::KernelFull,
2677            Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2678            None,
2679            None,
2680            None,
2681            Vec::new(),
2682            Vec::new(),
2683            Vec::new(),
2684            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2685            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2686                EnforcementTier::KernelFull,
2687                ContainmentClass::Sealed,
2688            )),
2689            IoMaxReport::default(),
2690        );
2691        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2692        assert!(!report.floor_met);
2693    }
2694
2695    #[test]
2696    fn scope_report_still_reports_when_the_floor_is_unmet() {
2697        let report = report_for(
2698            &sample_policy(),
2699            ContainmentClass::Sealed,
2700            EnforcementTier::EnvironmentOnly,
2701            Some(&blocked(SealedBlocker::NotLinux)),
2702            None,
2703            None,
2704            None,
2705            Vec::new(),
2706            Vec::new(),
2707            Vec::new(),
2708            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2709            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2710                EnforcementTier::EnvironmentOnly,
2711                ContainmentClass::Sealed,
2712            )),
2713            IoMaxReport::default(),
2714        );
2715
2716        assert_eq!(report.declared_containment, ContainmentClass::Sealed);
2717        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2718        assert!(!report.floor_met);
2719        assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
2720        assert_eq!(report.enforcement_tier, "none");
2721    }
2722
2723    #[test]
2724    fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
2725        let report = report_for(
2726            &sample_policy(),
2727            ContainmentClass::Sealed,
2728            EnforcementTier::KernelSealed,
2729            None,
2730            None,
2731            None,
2732            None,
2733            Vec::new(),
2734            Vec::new(),
2735            Vec::new(),
2736            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2737            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2738                EnforcementTier::KernelSealed,
2739                ContainmentClass::Sealed,
2740            )),
2741            IoMaxReport::default(),
2742        );
2743        assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2744        assert_eq!(report.host_containment, ContainmentClass::Sealed);
2745        assert!(report.floor_met);
2746        assert_eq!(
2747            report.enforcement_tier,
2748            "mountns+pivot_root+landlock+seccomp"
2749        );
2750        assert_eq!(
2751            report.filesystem_boundary.restriction,
2752            FilesystemRestriction::Absent
2753        );
2754    }
2755
2756    /// Every host tier × declared class: the class, the tier, the filesystem boundary, the ruleset
2757    /// ABI and the host's ceiling a report names all follow the one decision, so no report can
2758    /// pair `mountns+pivot_root+landlock+seccomp` with `restriction: enforced`.
2759    #[test]
2760    fn every_report_field_follows_the_decision() {
2761        for &host_tier in ALL_TIERS {
2762            for &declared in ALL_CLASSES {
2763                let decision = ContainmentDecision::decide(host_tier, declared, false, false);
2764                let report = scope_report_for_tier(
2765                    &sample_policy(),
2766                    &decision,
2767                    None,
2768                    None,
2769                    Some(6),
2770                    None,
2771                    Vec::new(),
2772                    Vec::new(),
2773                    Vec::new(),
2774                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2775                    crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
2776                    IoMaxReport::default(),
2777                );
2778                let what = format!("{host_tier:?} {declared}");
2779                assert_eq!(report.declared_containment, declared, "{what}");
2780                assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
2781                assert_eq!(
2782                    report.enforcement_tier,
2783                    decision.enforcement_tier(),
2784                    "{what}"
2785                );
2786                assert_eq!(
2787                    report.host_containment,
2788                    decision.host_containment(),
2789                    "{what}"
2790                );
2791                assert_eq!(
2792                    report.landlock_abi,
2793                    crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
2794                    "{what}"
2795                );
2796                let restriction = filesystem_boundary(decision.applied_tier()).restriction;
2797                assert_eq!(
2798                    report.filesystem_boundary.restriction, restriction,
2799                    "{what}"
2800                );
2801                assert_eq!(
2802                    restriction,
2803                    match report.enforcement_tier {
2804                        "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
2805                        "landlock+seccomp" => FilesystemRestriction::Enforced,
2806                        _ => FilesystemRestriction::Advisory,
2807                    },
2808                    "{what}"
2809                );
2810                assert_eq!(
2811                    report.temp_dir,
2812                    temp_dir_report(
2813                        crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
2814                        &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2815                    ),
2816                    "{what}"
2817                );
2818            }
2819        }
2820    }
2821
2822    /// The host's ceiling is its own field and its own line, directly under the session's class,
2823    /// so a reader sees both and cannot take one for the other.
2824    #[test]
2825    fn host_containment_is_reported_beside_the_achieved_class() {
2826        let report = report_for(
2827            &sample_policy(),
2828            ContainmentClass::Scoped,
2829            EnforcementTier::KernelSealed,
2830            None,
2831            None,
2832            None,
2833            None,
2834            Vec::new(),
2835            Vec::new(),
2836            Vec::new(),
2837            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2838            crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
2839            IoMaxReport::default(),
2840        );
2841        let value = serde_json::to_value(&report).unwrap();
2842        assert_eq!(value["achieved_containment"], "scoped");
2843        assert_eq!(value["host_containment"], "sealed");
2844        assert_eq!(value["enforcement_tier"], "landlock+seccomp");
2845        assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
2846
2847        let rendered = report.render();
2848        assert!(
2849            rendered.contains("  achieved:  scoped\n  host can back: sealed\n  floor met: yes\n"),
2850            "{rendered}"
2851        );
2852        assert!(
2853            rendered.contains("  mechanism: landlock+seccomp\n"),
2854            "{rendered}"
2855        );
2856    }
2857
2858    #[test]
2859    fn scope_report_json_uses_wire_names() {
2860        let report = report_for(
2861            &CapabilityPolicy::default(),
2862            ContainmentClass::Advisory,
2863            EnforcementTier::KernelSeccompOnly,
2864            None,
2865            None,
2866            None,
2867            None,
2868            Vec::new(),
2869            Vec::new(),
2870            Vec::new(),
2871            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2872            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2873                EnforcementTier::KernelSeccompOnly,
2874                ContainmentClass::Advisory,
2875            )),
2876            IoMaxReport::default(),
2877        );
2878        let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2879
2880        assert_eq!(value["declared_containment"], "advisory");
2881        assert_eq!(value["achieved_containment"], "advisory");
2882        assert_eq!(value["host_containment"], "advisory");
2883        assert_eq!(value["floor_met"], true);
2884        assert_eq!(value["enforcement_tier"], "seccomp-only");
2885        // Absent rather than null when the floor is met.
2886        assert!(value.get("shortfall_reason").is_none());
2887    }
2888
2889    /// `landlock_abi` is the ruleset ABI of the *applied* tier: the kernel's capped at 3 on a
2890    /// Landlock tier, `null` below one, and the key is present either way.
2891    #[test]
2892    fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
2893        let report = |tier, kernel| {
2894            report_for(
2895                &sample_policy(),
2896                ContainmentClass::Scoped,
2897                tier,
2898                None,
2899                None,
2900                kernel,
2901                None,
2902                Vec::new(),
2903                Vec::new(),
2904                Vec::new(),
2905                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2906                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2907                    tier,
2908                    ContainmentClass::Scoped,
2909                )),
2910                IoMaxReport::default(),
2911            )
2912        };
2913
2914        for (tier, kernel, expected) in [
2915            (EnforcementTier::KernelFull, Some(8), Some(3)),
2916            (EnforcementTier::KernelSealed, Some(8), Some(3)),
2917            (EnforcementTier::KernelFull, Some(2), Some(2)),
2918            (EnforcementTier::KernelFull, Some(1), Some(1)),
2919            (EnforcementTier::KernelFull, None, None),
2920            (EnforcementTier::KernelSeccompOnly, Some(8), None),
2921            (EnforcementTier::EnvironmentOnly, None, None),
2922        ] {
2923            let scope = report(tier, kernel);
2924            assert_eq!(
2925                scope.landlock_abi, expected,
2926                "{tier:?} with kernel {kernel:?}"
2927            );
2928
2929            let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
2930            assert!(value.as_object().unwrap().contains_key("landlock_abi"));
2931            match expected {
2932                Some(abi) => assert_eq!(value["landlock_abi"], abi),
2933                None => assert!(value["landlock_abi"].is_null()),
2934            }
2935
2936            let rendered = scope.render();
2937            let line = match expected {
2938                Some(abi) => format!("  landlock ABI: {abi}\n"),
2939                None => "  landlock ABI: n/a\n".to_string(),
2940            };
2941            assert!(rendered.contains(&line), "{rendered}");
2942            assert!(
2943                rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
2944            );
2945        }
2946    }
2947
2948    /// The grant is reported, and it never moves a class, a floor or a mechanism: the same tier
2949    /// with four different grants produces four reports that differ in exactly one field.
2950    #[test]
2951    fn the_userns_grant_is_reported_without_changing_any_verdict() {
2952        let baseline = report_for(
2953            &sample_policy(),
2954            ContainmentClass::Advisory,
2955            EnforcementTier::KernelSealed,
2956            None,
2957            None,
2958            None,
2959            None,
2960            Vec::new(),
2961            Vec::new(),
2962            Vec::new(),
2963            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2964            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2965                EnforcementTier::KernelSealed,
2966                ContainmentClass::Advisory,
2967            )),
2968            IoMaxReport::default(),
2969        );
2970
2971        for grant in UsernsGrant::ALL {
2972            let report = report_for(
2973                &sample_policy(),
2974                ContainmentClass::Advisory,
2975                EnforcementTier::KernelSealed,
2976                None,
2977                Some(*grant),
2978                None,
2979                None,
2980                Vec::new(),
2981                Vec::new(),
2982                Vec::new(),
2983                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2984                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2985                    EnforcementTier::KernelSealed,
2986                    ContainmentClass::Advisory,
2987                )),
2988                IoMaxReport::default(),
2989            );
2990            assert_eq!(report.achieved_containment, baseline.achieved_containment);
2991            assert_eq!(report.floor_met, baseline.floor_met);
2992            assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
2993            assert_eq!(report.userns_grant, Some(*grant));
2994
2995            let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2996            assert_eq!(value["userns_grant"], grant.wire_name());
2997            assert!(report
2998                .render()
2999                .contains(&format!("userns grant: {}", grant.wire_name())));
3000        }
3001
3002        // Off Linux the key is present and null — AppArmor does not exist there, which is not the
3003        // same statement as "the grant was withheld".
3004        let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
3005        assert!(value["userns_grant"].is_null());
3006        assert!(baseline.render().contains("userns grant: n/a"));
3007    }
3008
3009    #[test]
3010    fn rendered_report_names_both_classes_and_the_refusal() {
3011        let rendered = report_for(
3012            &sample_policy(),
3013            ContainmentClass::Sealed,
3014            EnforcementTier::KernelFull,
3015            Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3016            None,
3017            None,
3018            None,
3019            Vec::new(),
3020            Vec::new(),
3021            Vec::new(),
3022            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3023            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3024                EnforcementTier::KernelFull,
3025                ContainmentClass::Sealed,
3026            )),
3027            IoMaxReport::default(),
3028        )
3029        .render();
3030
3031        assert!(rendered.contains("declared:  sealed"));
3032        assert!(rendered.contains("achieved:  scoped"));
3033        assert!(rendered.contains("floor met: no"));
3034        assert!(rendered.contains("mur-sealed"));
3035        assert!(rendered.contains("would refuse to launch"));
3036    }
3037
3038    /// The invariant the whole design rests on: an export is a disclosure, not a grant. Declaring
3039    /// one must leave the achieved class — and everything else the report says about what the
3040    /// guest can reach — byte-identical.
3041    #[test]
3042    fn an_export_never_changes_what_the_report_says_about_containment() {
3043        let export = FileExport {
3044            root: "out/".to_string(),
3045            mode: ExportMode::ReadOnly,
3046            max_bytes: 10 * 1024 * 1024,
3047        };
3048        for tier in ALL_TIERS {
3049            for workdir_exec in [false, true] {
3050                let policy = CapabilityPolicy {
3051                    workdir_exec_allowed: workdir_exec,
3052                    ..sample_policy()
3053                };
3054                let without = report_for(
3055                    &policy,
3056                    ContainmentClass::Scoped,
3057                    *tier,
3058                    None,
3059                    None,
3060                    None,
3061                    None,
3062                    Vec::new(),
3063                    Vec::new(),
3064                    Vec::new(),
3065                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3066                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3067                        *tier,
3068                        ContainmentClass::Scoped,
3069                    )),
3070                    IoMaxReport::default(),
3071                );
3072                let with = report_for(
3073                    &policy,
3074                    ContainmentClass::Scoped,
3075                    *tier,
3076                    None,
3077                    None,
3078                    None,
3079                    Some(&Exports {
3080                        files: Some(export.clone()),
3081                        peer_files: None,
3082                        peer_tasks: None,
3083                    }),
3084                    Vec::new(),
3085                    Vec::new(),
3086                    Vec::new(),
3087                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3088                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3089                        *tier,
3090                        ContainmentClass::Scoped,
3091                    )),
3092                    IoMaxReport::default(),
3093                );
3094                assert_eq!(
3095                    with.achieved_containment, without.achieved_containment,
3096                    "tier {tier:?}, workdir_exec {workdir_exec}"
3097                );
3098                assert_eq!(with.enforcement_tier, without.enforcement_tier);
3099                assert_eq!(with.floor_met, without.floor_met);
3100                assert_eq!(with.shortfall_reason, without.shortfall_reason);
3101                // Everything but the one new field is identical, which is the strongest form of
3102                // "an export changes nothing else" this report can state.
3103                assert_eq!(
3104                    ScopeReport {
3105                        exports_files: None,
3106                        ..with.clone()
3107                    },
3108                    without
3109                );
3110                assert_eq!(
3111                    with.exports_files,
3112                    Some(ExportsFilesReport {
3113                        root: "out/".to_string(),
3114                        mode: ExportMode::ReadOnly,
3115                        max_bytes: 10 * 1024 * 1024,
3116                    })
3117                );
3118            }
3119        }
3120    }
3121
3122    /// The same invariant for the peer-handoff grants, and stated separately because they are a
3123    /// separate authoriser: declaring either half must leave the achieved class, the tier and the
3124    /// floor verdict byte-identical, and must change no field of the report but its own.
3125    #[test]
3126    fn peer_handoff_never_changes_what_the_report_says_about_containment() {
3127        let peer_files = PeerFilesExport {
3128            root: "out/".to_string(),
3129            max_ttl_secs: Some(900),
3130            max_bytes: 10 * 1024 * 1024,
3131        };
3132        for tier in ALL_TIERS {
3133            for workdir_exec in [false, true] {
3134                let without_policy = CapabilityPolicy {
3135                    workdir_exec_allowed: workdir_exec,
3136                    ..sample_policy()
3137                };
3138                let with_policy = CapabilityPolicy {
3139                    peer_fetch_allow: vec!["localhost:41234".to_string()],
3140                    ..without_policy.clone()
3141                };
3142                let without = report_for(
3143                    &without_policy,
3144                    ContainmentClass::Scoped,
3145                    *tier,
3146                    None,
3147                    None,
3148                    None,
3149                    None,
3150                    Vec::new(),
3151                    Vec::new(),
3152                    Vec::new(),
3153                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3154                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3155                        *tier,
3156                        ContainmentClass::Scoped,
3157                    )),
3158                    IoMaxReport::default(),
3159                );
3160                let with = report_for(
3161                    &with_policy,
3162                    ContainmentClass::Scoped,
3163                    *tier,
3164                    None,
3165                    None,
3166                    None,
3167                    Some(&Exports {
3168                        files: None,
3169                        peer_files: Some(peer_files.clone()),
3170                        peer_tasks: None,
3171                    }),
3172                    Vec::new(),
3173                    Vec::new(),
3174                    Vec::new(),
3175                    &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3176                    crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3177                        *tier,
3178                        ContainmentClass::Scoped,
3179                    )),
3180                    IoMaxReport::default(),
3181                );
3182                assert_eq!(
3183                    with.achieved_containment, without.achieved_containment,
3184                    "tier {tier:?}, workdir_exec {workdir_exec}"
3185                );
3186                assert_eq!(with.enforcement_tier, without.enforcement_tier);
3187                assert_eq!(with.floor_met, without.floor_met);
3188                assert_eq!(with.shortfall_reason, without.shortfall_reason);
3189                assert_eq!(
3190                    ScopeReport {
3191                        peer_files: None,
3192                        peer_fetch_allow: Vec::new(),
3193                        ..with.clone()
3194                    },
3195                    without
3196                );
3197                assert_eq!(
3198                    with.peer_files,
3199                    Some(PeerFilesReport {
3200                        root: "out/".to_string(),
3201                        max_ttl_secs: 900,
3202                        max_bytes: 10 * 1024 * 1024,
3203                    })
3204                );
3205                assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
3206                // Declaring `peer_fetch` does not widen `network.allow`, on either side of the
3207                // report: the two lists are carried separately and neither is derived from the
3208                // other.
3209                assert_eq!(with.network_allow, without.network_allow);
3210            }
3211        }
3212    }
3213
3214    /// Both peer fields are written whether or not they were declared, on the same terms as
3215    /// `exports_files`: an absent key identifies an older runtime, a `null`/`[]` identifies a
3216    /// capsule that declined.
3217    #[test]
3218    fn the_peer_fields_are_always_serialized() {
3219        let undeclared: serde_json::Value = serde_json::to_value(report_for(
3220            &sample_policy(),
3221            ContainmentClass::Advisory,
3222            EnforcementTier::KernelSealed,
3223            None,
3224            None,
3225            None,
3226            None,
3227            Vec::new(),
3228            Vec::new(),
3229            Vec::new(),
3230            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3231            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3232                EnforcementTier::KernelSealed,
3233                ContainmentClass::Advisory,
3234            )),
3235            IoMaxReport::default(),
3236        ))
3237        .unwrap();
3238        assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
3239        assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
3240
3241        let declared: serde_json::Value = serde_json::to_value(report_for(
3242            &CapabilityPolicy {
3243                peer_fetch_allow: vec!["localhost:41234".to_string()],
3244                ..sample_policy()
3245            },
3246            ContainmentClass::Advisory,
3247            EnforcementTier::KernelSealed,
3248            None,
3249            None,
3250            None,
3251            Some(&Exports {
3252                files: None,
3253                peer_files: Some(PeerFilesExport {
3254                    root: "out/".to_string(),
3255                    max_ttl_secs: None,
3256                    max_bytes: 10 * 1024 * 1024,
3257                }),
3258                peer_tasks: None,
3259            }),
3260            Vec::new(),
3261            Vec::new(),
3262            Vec::new(),
3263            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3264            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3265                EnforcementTier::KernelSealed,
3266                ContainmentClass::Advisory,
3267            )),
3268            IoMaxReport::default(),
3269        ))
3270        .unwrap();
3271        assert_eq!(
3272            declared["peer_files"],
3273            serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
3274        );
3275        assert_eq!(
3276            declared["peer_fetch_allow"],
3277            serde_json::json!(["localhost:41234"])
3278        );
3279    }
3280
3281    #[test]
3282    fn render_names_peer_handoff_in_both_directions() {
3283        let undeclared = report_for(
3284            &sample_policy(),
3285            ContainmentClass::Advisory,
3286            EnforcementTier::KernelSealed,
3287            None,
3288            None,
3289            None,
3290            None,
3291            Vec::new(),
3292            Vec::new(),
3293            Vec::new(),
3294            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3295            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3296                EnforcementTier::KernelSealed,
3297                ContainmentClass::Advisory,
3298            )),
3299            IoMaxReport::default(),
3300        )
3301        .render();
3302        assert!(undeclared.contains("Peer handoff"));
3303        assert!(undeclared.contains("exports.peer_files: <none>"));
3304        assert!(undeclared.contains("peer_fetch allow: <none>"));
3305
3306        let declared = report_for(
3307            &CapabilityPolicy {
3308                peer_fetch_allow: vec!["localhost:41234".to_string()],
3309                ..sample_policy()
3310            },
3311            ContainmentClass::Advisory,
3312            EnforcementTier::KernelSealed,
3313            None,
3314            None,
3315            None,
3316            Some(&Exports {
3317                files: None,
3318                peer_files: Some(PeerFilesExport {
3319                    root: "out/".to_string(),
3320                    max_ttl_secs: Some(900),
3321                    max_bytes: 4096,
3322                }),
3323                peer_tasks: None,
3324            }),
3325            Vec::new(),
3326            Vec::new(),
3327            Vec::new(),
3328            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3329            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3330                EnforcementTier::KernelSealed,
3331                ContainmentClass::Advisory,
3332            )),
3333            IoMaxReport::default(),
3334        )
3335        .render();
3336        assert!(declared.contains("exports.peer_files root: out/"));
3337        assert!(declared.contains("max handle ttl:          900s"));
3338        assert!(declared.contains("max bytes:               4096 (per file)"));
3339        assert!(declared.contains("- localhost:41234"));
3340    }
3341
3342    fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
3343        report_for(
3344            &sample_policy(),
3345            ContainmentClass::Scoped,
3346            tier,
3347            None,
3348            None,
3349            None,
3350            peer_tasks
3351                .map(|accept| Exports {
3352                    files: None,
3353                    peer_files: None,
3354                    peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
3355                })
3356                .as_ref(),
3357            Vec::new(),
3358            Vec::new(),
3359            Vec::new(),
3360            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3361            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3362                tier,
3363                ContainmentClass::Scoped,
3364            )),
3365            IoMaxReport::default(),
3366        )
3367    }
3368
3369    /// Consenting to peer tasks is an export like the others: it moves no containment field and
3370    /// no other field of the report but its own. Absent and `accept: false` are one report.
3371    #[test]
3372    fn peer_tasks_is_reported_and_moves_no_other_field() {
3373        for tier in ALL_TIERS {
3374            let absent = report_with_peer_tasks(*tier, None);
3375            let refused = report_with_peer_tasks(*tier, Some(false));
3376            let accepted = report_with_peer_tasks(*tier, Some(true));
3377            assert!(!absent.peer_tasks);
3378            assert_eq!(refused, absent, "tier {tier:?}");
3379            assert!(accepted.peer_tasks);
3380            assert_eq!(accepted.achieved_containment, absent.achieved_containment);
3381            assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
3382            assert_eq!(accepted.floor_met, absent.floor_met);
3383            assert_eq!(
3384                ScopeReport {
3385                    peer_tasks: false,
3386                    ..accepted.clone()
3387                },
3388                absent
3389            );
3390        }
3391    }
3392
3393    /// `peer_tasks` is always written, as a boolean, so an absent key identifies an older runtime.
3394    #[test]
3395    fn peer_tasks_is_always_serialized() {
3396        let tier = EnforcementTier::KernelFull;
3397        let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
3398        assert_eq!(absent["peer_tasks"], serde_json::json!(false));
3399        let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
3400        assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
3401    }
3402
3403    #[test]
3404    fn render_names_the_peer_task_posture_under_peer_handoff() {
3405        let tier = EnforcementTier::KernelFull;
3406        for (declared, line) in [
3407            (None, "  exports.peer_tasks: refuse\n"),
3408            (Some(false), "  exports.peer_tasks: refuse\n"),
3409            (Some(true), "  exports.peer_tasks: accept\n"),
3410        ] {
3411            let rendered = report_with_peer_tasks(tier, declared).render();
3412            let section = rendered
3413                .split("\nPeer handoff\n")
3414                .nth(1)
3415                .and_then(|rest| rest.split("\n\n").next())
3416                .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
3417            assert!(
3418                section.contains(line.trim_start_matches(' ').trim_end()),
3419                "{declared:?}:\n{section}"
3420            );
3421            assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
3422        }
3423    }
3424
3425    /// A declared state store is reported, and moves nothing else. Same discipline the export
3426    /// reports are held to: it is copied through, never consulted, so a durable directory grant
3427    /// cannot talk a host into reporting a class it does not back.
3428    #[test]
3429    fn a_state_store_is_reported_and_moves_no_other_field() {
3430        let policy = sample_policy();
3431        let stores = vec![StateStoreReport {
3432            artifact: "murmur/tool-corpus".to_string(),
3433            store: "shey".to_string(),
3434            host_path: "/home/dev/.murmur/state/shey".to_string(),
3435        }];
3436
3437        let without = report_for(
3438            &policy,
3439            ContainmentClass::Scoped,
3440            EnforcementTier::KernelFull,
3441            None,
3442            None,
3443            None,
3444            None,
3445            Vec::new(),
3446            Vec::new(),
3447            Vec::new(),
3448            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3449            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3450                EnforcementTier::KernelFull,
3451                ContainmentClass::Scoped,
3452            )),
3453            IoMaxReport::default(),
3454        );
3455        let with = report_for(
3456            &policy,
3457            ContainmentClass::Scoped,
3458            EnforcementTier::KernelFull,
3459            None,
3460            None,
3461            None,
3462            None,
3463            stores.clone(),
3464            Vec::new(),
3465            Vec::new(),
3466            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3467            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3468                EnforcementTier::KernelFull,
3469                ContainmentClass::Scoped,
3470            )),
3471            IoMaxReport::default(),
3472        );
3473
3474        assert_eq!(with.state_stores, stores);
3475        assert!(without.state_stores.is_empty());
3476        assert_eq!(with.achieved_containment, without.achieved_containment);
3477        assert_eq!(with.floor_met, without.floor_met);
3478        assert_eq!(with.enforcement_tier, without.enforcement_tier);
3479        assert_eq!(with.shortfall_reason, without.shortfall_reason);
3480        // The only field that moved is its own.
3481        assert_eq!(
3482            ScopeReport {
3483                state_stores: Vec::new(),
3484                ..with.clone()
3485            },
3486            without
3487        );
3488
3489        // Rendered under the grants, one line per declaring artifact; `<none>` when empty.
3490        assert!(with
3491            .render()
3492            .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
3493        assert!(without.render().contains("state stores: <none>"));
3494
3495        // Always an array in JSON, never skipped: an absent key means an older runtime, not a
3496        // capsule that declined a store.
3497        let json = serde_json::to_value(&without).unwrap();
3498        assert_eq!(json["state_stores"], serde_json::json!([]));
3499        assert_eq!(
3500            serde_json::to_value(&with).unwrap()["state_stores"],
3501            serde_json::json!([{
3502                "artifact": "murmur/tool-corpus",
3503                "store": "shey",
3504                "host_path": "/home/dev/.murmur/state/shey",
3505            }])
3506        );
3507    }
3508
3509    /// The preopen set is reported and moves nothing else, on the same terms the state-store
3510    /// report beside it is held to. It is also the field that answers what `filesystem_scope`
3511    /// does not: a capsule-wide scope is joined into no guest preopen root, so both can be
3512    /// present and disagree without either being wrong.
3513    #[test]
3514    fn preopens_are_reported_and_move_no_other_field() {
3515        let policy = sample_policy();
3516        let preopens = vec![
3517            PreopenReport {
3518                artifact: "notes-tool".to_string(),
3519                role: "tool".to_string(),
3520                scope: None,
3521                surface: PreopenSurface::WholeWorkdir,
3522                runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
3523            },
3524            PreopenReport {
3525                artifact: "cache-tool".to_string(),
3526                role: "tool".to_string(),
3527                scope: Some("cache".to_string()),
3528                surface: PreopenSurface::ScopedSubtree,
3529                runtime_dir_reach: None,
3530            },
3531            PreopenReport {
3532                artifact: "telemetry-hook".to_string(),
3533                role: "hook".to_string(),
3534                scope: None,
3535                surface: PreopenSurface::Nothing,
3536                runtime_dir_reach: None,
3537            },
3538        ];
3539
3540        let without = report_for(
3541            &policy,
3542            ContainmentClass::Scoped,
3543            EnforcementTier::KernelFull,
3544            None,
3545            None,
3546            None,
3547            None,
3548            Vec::new(),
3549            Vec::new(),
3550            Vec::new(),
3551            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3552            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3553                EnforcementTier::KernelFull,
3554                ContainmentClass::Scoped,
3555            )),
3556            IoMaxReport::default(),
3557        );
3558        let with = report_for(
3559            &policy,
3560            ContainmentClass::Scoped,
3561            EnforcementTier::KernelFull,
3562            None,
3563            None,
3564            None,
3565            None,
3566            Vec::new(),
3567            Vec::new(),
3568            preopens.clone(),
3569            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3570            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3571                EnforcementTier::KernelFull,
3572                ContainmentClass::Scoped,
3573            )),
3574            IoMaxReport::default(),
3575        );
3576
3577        assert_eq!(with.preopens, preopens);
3578        assert!(without.preopens.is_empty());
3579        // The only field that moved is its own.
3580        assert_eq!(
3581            ScopeReport {
3582                preopens: Vec::new(),
3583                ..with.clone()
3584            },
3585            without
3586        );
3587
3588        // Three visibly different lines under `Effective grants`; `<none>` when empty.
3589        let rendered = with.render();
3590        assert!(rendered.contains(
3591            "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
3592             declared; runtime directory read-only at murmur-runtime/"
3593        ));
3594        assert!(rendered.contains(
3595            "- cache-tool (tool): one subtree of the accessible workdir — \
3596             capabilities.filesystem.scope: cache"
3597        ));
3598        assert!(rendered.contains(
3599            "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
3600        ));
3601        assert!(without.render().contains("preopens: <none>"));
3602
3603        // Always an array in JSON, never skipped, with an undeclared scope as `null`.
3604        assert_eq!(
3605            serde_json::to_value(&without).unwrap()["preopens"],
3606            serde_json::json!([])
3607        );
3608        assert_eq!(
3609            serde_json::to_value(&with).unwrap()["preopens"],
3610            serde_json::json!([
3611                {
3612                    "artifact": "notes-tool",
3613                    "role": "tool",
3614                    "scope": null,
3615                    "surface": "whole-workdir",
3616                    "runtime_dir_reach": "murmur-runtime",
3617                },
3618                {
3619                    "artifact": "cache-tool",
3620                    "role": "tool",
3621                    "scope": "cache",
3622                    "surface": "scoped-subtree",
3623                    "runtime_dir_reach": null,
3624                },
3625                {
3626                    "artifact": "telemetry-hook",
3627                    "role": "hook",
3628                    "scope": null,
3629                    "surface": "nothing",
3630                    "runtime_dir_reach": null,
3631                },
3632            ])
3633        );
3634    }
3635
3636    /// `exports_files` is written whether or not it was declared, on the same terms as
3637    /// `workdir_exec`: an absent key identifies an older runtime, a `null` a capsule that
3638    /// exported nothing.
3639    #[test]
3640    fn exports_files_is_always_serialized() {
3641        let undeclared = serde_json::to_value(report_for(
3642            &sample_policy(),
3643            ContainmentClass::Advisory,
3644            EnforcementTier::KernelFull,
3645            None,
3646            None,
3647            None,
3648            None,
3649            Vec::new(),
3650            Vec::new(),
3651            Vec::new(),
3652            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3653            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3654                EnforcementTier::KernelFull,
3655                ContainmentClass::Advisory,
3656            )),
3657            IoMaxReport::default(),
3658        ))
3659        .unwrap();
3660        assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
3661
3662        let declared = serde_json::to_value(report_for(
3663            &sample_policy(),
3664            ContainmentClass::Advisory,
3665            EnforcementTier::KernelFull,
3666            None,
3667            None,
3668            None,
3669            Some(&Exports {
3670                files: Some(FileExport {
3671                    root: "out/".to_string(),
3672                    mode: ExportMode::ReadOnly,
3673                    max_bytes: 10_485_760,
3674                }),
3675                peer_files: None,
3676                peer_tasks: None,
3677            }),
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::KernelFull,
3684                ContainmentClass::Advisory,
3685            )),
3686            IoMaxReport::default(),
3687        ))
3688        .unwrap();
3689        assert_eq!(
3690            declared["exports_files"],
3691            serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
3692        );
3693    }
3694
3695    #[test]
3696    fn render_names_the_resource_plane_in_both_directions() {
3697        let undeclared = report_for(
3698            &sample_policy(),
3699            ContainmentClass::Advisory,
3700            EnforcementTier::KernelFull,
3701            None,
3702            None,
3703            None,
3704            None,
3705            Vec::new(),
3706            Vec::new(),
3707            Vec::new(),
3708            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3709            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3710                EnforcementTier::KernelFull,
3711                ContainmentClass::Advisory,
3712            )),
3713            IoMaxReport::default(),
3714        )
3715        .render();
3716        assert!(
3717            undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
3718            "{undeclared}"
3719        );
3720
3721        let declared = report_for(
3722            &sample_policy(),
3723            ContainmentClass::Advisory,
3724            EnforcementTier::KernelFull,
3725            None,
3726            None,
3727            None,
3728            Some(&Exports {
3729                files: Some(FileExport {
3730                    root: "out/".to_string(),
3731                    mode: ExportMode::ReadOnly,
3732                    max_bytes: 10_485_760,
3733                }),
3734                peer_files: None,
3735                peer_tasks: None,
3736            }),
3737            Vec::new(),
3738            Vec::new(),
3739            Vec::new(),
3740            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3741            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3742                EnforcementTier::KernelFull,
3743                ContainmentClass::Advisory,
3744            )),
3745            IoMaxReport::default(),
3746        )
3747        .render();
3748        assert!(declared.contains("exports.files root: out/"), "{declared}");
3749        assert!(
3750            declared.contains("mode:               read-only"),
3751            "{declared}"
3752        );
3753        assert!(
3754            declared.contains("max bytes:          10485760 (per file)"),
3755            "{declared}"
3756        );
3757    }
3758
3759    // ── read_only reporting ───────────────────────────────────────────────────
3760
3761    fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
3762        let policy = CapabilityPolicy {
3763            read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
3764            shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
3765            ..CapabilityPolicy::default()
3766        };
3767        report_for(
3768            &policy,
3769            ContainmentClass::Scoped,
3770            EnforcementTier::KernelFull,
3771            None,
3772            None,
3773            None,
3774            None,
3775            Vec::new(),
3776            Vec::new(),
3777            Vec::new(),
3778            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3779            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3780                EnforcementTier::KernelFull,
3781                ContainmentClass::Scoped,
3782            )),
3783            IoMaxReport::default(),
3784        )
3785    }
3786
3787    /// With no allowlisted interpreter the declaration holds everywhere the check can reach, and
3788    /// the report says exactly that and nothing stronger.
3789    #[test]
3790    fn declared_read_only_with_no_interpreter_reports_as_enforced() {
3791        let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
3792        assert_eq!(
3793            report.read_only_paths,
3794            vec!["tests".to_string(), "bench/fixtures".to_string()]
3795        );
3796        assert!(report.read_only_advisory_for.is_empty());
3797
3798        let rendered = report.render();
3799        assert!(rendered.contains("  read_only:\n    - tests\n    - bench/fixtures\n"));
3800        assert!(rendered.contains(
3801            "read_only enforcement: enforced for every tool call and every shell command the \
3802             dispatch check can read"
3803        ));
3804        assert!(!rendered.contains("advisory against"));
3805    }
3806
3807    /// An allowlisted interpreter is named, and the enforcement line is qualified — the report
3808    /// must never claim more than `W-SEC-017` already withdraws.
3809    #[test]
3810    fn an_allowlisted_interpreter_is_named_as_advisory() {
3811        let report = read_only_report(&["tests"], &["git", "python3"]);
3812        assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
3813
3814        let rendered = report.render();
3815        assert!(rendered.contains("read_only enforcement: advisory against python3"));
3816        assert!(rendered
3817            .contains("enforced for every tool call and for every shell command it can read"));
3818    }
3819
3820    /// The report's advisory set is the set `W-SEC-017` fires for, because both call one
3821    /// resolver. Asserted through the resolver rather than by re-listing interpreters, so adding
3822    /// one to `protected_paths::ADVISORY_INTERPRETERS` cannot make the two disagree.
3823    #[test]
3824    fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
3825        let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
3826            .iter()
3827            .map(|b| (*b).to_string())
3828            .collect();
3829        let declared = vec!["tests".to_string()];
3830        assert_eq!(
3831            read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
3832                .read_only_advisory_for,
3833            read_only_advisory_for(&declared, &shell_allow)
3834        );
3835        // Nothing is advisory against a protection that was never declared, whatever is allowed.
3836        assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
3837    }
3838
3839    /// Both keys are always arrays, including for a capsule with no `capabilities.filesystem`
3840    /// block at all: an absent key must identify a runtime that predates the field, never a
3841    /// capsule that declared nothing.
3842    #[test]
3843    fn both_read_only_keys_are_always_serialized_arrays() {
3844        let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
3845        assert_eq!(json["read_only_paths"], serde_json::json!([]));
3846        assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
3847
3848        let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
3849        assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
3850        assert_eq!(
3851            json["read_only_advisory_for"],
3852            serde_json::json!(["python3"])
3853        );
3854    }
3855
3856    /// An undeclared `read_only` renders one `<none>` line and asks no enforcement question,
3857    /// because there is no protection to qualify.
3858    #[test]
3859    fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
3860        let rendered = read_only_report(&[], &["python3"]).render();
3861        assert!(rendered.contains("  read_only: <none>\n"));
3862        assert!(!rendered.contains("read_only enforcement:"));
3863    }
3864
3865    // ── Grant coverage ────────────────────────────────────────────────────────
3866
3867    /// Every `pub` field of [`CapabilityPolicy`] is either reported by [`ScopeReport`] or
3868    /// exempted here with a written reason.
3869    ///
3870    /// The field list is derived from `types.rs`'s own source text rather than written out, so a
3871    /// grant added later fails this test until someone decides which it is.
3872    ///
3873    /// The mapping is checked in both directions — every policy field is accounted for, *and*
3874    /// every report key the mapping names is really present in a serialized report — so the
3875    /// table cannot claim coverage it does not have.
3876    #[test]
3877    fn every_capability_policy_field_is_reported_or_exempted() {
3878        /// Policy fields deliberately absent from the scope report, each with the reason.
3879        const EXEMPT: &[(&str, &str)] = &[
3880            (
3881                "shell_strip_env",
3882                "Names host variables removed from the shell child's environment on top of the \
3883                 credential patterns the runtime always strips. It subtracts from what the \
3884                 capsule sees and can open nothing, so reporting it beside the grants would pad \
3885                 the list an operator audits for reach.",
3886            ),
3887            (
3888                "shell_baseline_env",
3889                "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
3890                 environment of a subprocess the capsule was already granted, and names no host \
3891                 resource `shell allow` and `env allow` do not already account for.",
3892            ),
3893            (
3894                "limits",
3895                "Execution ceilings for a WASM guest — memory, table elements, instance count, \
3896                 deadline. A bound on how much the capsule may consume, never a reach it gains.",
3897            ),
3898            (
3899                "resources",
3900                "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
3901                 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
3902            ),
3903            (
3904                "declared_deadline_seconds",
3905                "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
3906                 can apply a lower default. Reporting it would report one ceiling twice.",
3907            ),
3908            (
3909                "state_declared",
3910                "Records that a capsule-wide `capabilities.state` block was declared where \
3911                 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
3912                 exist are reported per artifact in `state_stores`.",
3913            ),
3914            (
3915                "conversation_declared",
3916                "Records a capsule-wide `capabilities.conversation` block that reaches no \
3917                 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
3918                 and the grant itself is per artifact.",
3919            ),
3920        ];
3921
3922        /// Policy field → the key it is reported under in a serialized [`ScopeReport`].
3923        const REPORTED: &[(&str, &str)] = &[
3924            ("network_allow", "network_allow"),
3925            ("peer_fetch_allow", "peer_fetch_allow"),
3926            ("unix_sockets_allowed", "unix_sockets"),
3927            ("filesystem_scope", "filesystem_scope"),
3928            ("workdir_exec_allowed", "workdir_exec"),
3929            ("read_only_paths", "read_only_paths"),
3930            ("shell_allow", "shell_allow"),
3931            ("spawn_allow", "spawn_allow"),
3932            ("shell_interpreter_runtime", "interpreter_runtime_grants"),
3933            (
3934                "shell_derived_interpreter_runtime",
3935                "derived_interpreter_runtime",
3936            ),
3937            ("shell_trust_store", "trust_store"),
3938            ("shell_staged_runtime", "staged_runtime_grants"),
3939            ("env_allow", "env_allow"),
3940            ("plan_submit", "plan_submit"),
3941            ("install_skill", "install_skill"),
3942            ("install_tool", "install_tool"),
3943            ("install_from_sources", "install_from"),
3944            ("containment_floor", "declared_containment"),
3945        ];
3946
3947        let declared = capability_policy_field_names(include_str!("types.rs"));
3948        assert!(
3949            declared.len() > 10,
3950            "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
3951        );
3952
3953        for field in &declared {
3954            let reported = REPORTED.iter().any(|(policy, _)| policy == field);
3955            let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
3956            assert!(
3957                reported || exempt,
3958                "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
3959                 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
3960                 with a written reason for why an operator does not need to see it."
3961            );
3962            assert!(
3963                !(reported && exempt),
3964                "CapabilityPolicy::{field} is listed as both reported and exempt"
3965            );
3966        }
3967
3968        for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
3969            assert!(
3970                declared.iter().any(|field| field == policy_field),
3971                "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
3972                 declares — remove the stale row"
3973            );
3974        }
3975
3976        // The other direction: a report key the table claims must really be in the JSON, so no
3977        // row can cover a field with a key that does not exist.
3978        let json = serde_json::to_value(report_for(
3979            &sample_policy(),
3980            ContainmentClass::Scoped,
3981            EnforcementTier::KernelFull,
3982            None,
3983            None,
3984            None,
3985            None,
3986            Vec::new(),
3987            Vec::new(),
3988            Vec::new(),
3989            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3990            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3991                EnforcementTier::KernelFull,
3992                ContainmentClass::Scoped,
3993            )),
3994            IoMaxReport::default(),
3995        ))
3996        .unwrap();
3997        let object = json.as_object().expect("a scope report is a JSON object");
3998        for (policy_field, report_key) in REPORTED {
3999            assert!(
4000                object.contains_key(*report_key),
4001                "the table maps CapabilityPolicy::{policy_field} to a report key \
4002                 '{report_key}' that a serialized ScopeReport does not carry"
4003            );
4004        }
4005    }
4006
4007    /// A report off [`sample_policy`] at a chosen host tier and declared floor, so the mechanism
4008    /// this session would install is the only thing that varies between the cases below.
4009    fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
4010        report_for(
4011            &sample_policy(),
4012            declared,
4013            tier,
4014            None,
4015            None,
4016            // A kernel that mediates `truncate(2)`, so a Landlock tier's runtime directory is
4017            // read-only and adds no statement of its own.
4018            Some(6),
4019            None,
4020            Vec::new(),
4021            Vec::new(),
4022            Vec::new(),
4023            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4024            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
4025            IoMaxReport::default(),
4026        )
4027    }
4028
4029    /// Every tier names the same three subprocess routes it confines, on the line under
4030    /// `mechanism:` and as an always-serialized JSON array a consumer can key on.
4031    #[test]
4032    fn the_mechanism_names_every_subprocess_route_it_covers() {
4033        for tier in [
4034            EnforcementTier::KernelSealed,
4035            EnforcementTier::KernelFull,
4036            EnforcementTier::KernelSeccompOnly,
4037            EnforcementTier::EnvironmentOnly,
4038        ] {
4039            let report = boundary_report(ContainmentClass::Advisory, tier);
4040            assert_eq!(
4041                report.mechanism_covers,
4042                vec!["shell", "plan-shell-steps", "native-tools"]
4043            );
4044
4045            let rendered = report.render();
4046            let mechanism = format!("  mechanism: {}\n", report.enforcement_tier);
4047            let covers = "  covers:    shell commands, plan shell steps, native tools\n";
4048            assert!(
4049                rendered.contains(&format!("{mechanism}{covers}")),
4050                "covers: must follow mechanism: directly:\n{rendered}"
4051            );
4052
4053            let json = serde_json::to_string(&report).unwrap();
4054            assert!(
4055                json.contains(r#""mechanism_covers":["shell","plan-shell-steps","native-tools"]"#),
4056                "{json}"
4057            );
4058        }
4059    }
4060
4061    /// A host that mediates nothing says so, in the block an operator reads before the grants.
4062    #[test]
4063    fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
4064        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
4065
4066        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4067        assert_eq!(
4068            report.filesystem_boundary.restriction,
4069            FilesystemRestriction::Advisory
4070        );
4071        // The filesystem, path rewriting, and the runtime and records directories no command is
4072        // kept from.
4073        assert_eq!(report.filesystem_boundary.not_protected.len(), 4);
4074        assert_eq!(
4075            Some(&report.filesystem_boundary.not_protected[2]),
4076            report.runtime_dir.not_protected_because.as_ref()
4077        );
4078        assert_eq!(
4079            Some(&report.filesystem_boundary.not_protected[3]),
4080            report.records_dir.not_protected_because.as_ref()
4081        );
4082        assert!(report.filesystem_boundary.not_protected[3].contains("the records directory"));
4083
4084        let filesystem = &report.filesystem_boundary.not_protected[0];
4085        assert!(filesystem.contains("advisory"), "{filesystem}");
4086        assert!(
4087            filesystem.contains("denies a read or a write outside the work folder"),
4088            "{filesystem}"
4089        );
4090
4091        let rewriting = &report.filesystem_boundary.not_protected[1];
4092        assert!(rewriting.contains("not a boundary"), "{rewriting}");
4093        assert!(rewriting.contains("HOME"), "{rewriting}");
4094
4095        let rendered = report.render();
4096        let heading = rendered
4097            .find("\nNot protected here\n")
4098            .expect("a session that composes no root prints the block");
4099        assert!(
4100            heading > rendered.find("Containment\n").unwrap(),
4101            "the block belongs under Containment:\n{rendered}"
4102        );
4103        assert!(
4104            heading < rendered.find("\nEffective grants\n").unwrap(),
4105            "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
4106        );
4107        for statement in &report.filesystem_boundary.not_protected {
4108            assert!(rendered.contains(statement.as_str()), "{rendered}");
4109        }
4110    }
4111
4112    fn inside_placement() -> RuntimeDirPlacement {
4113        RuntimeDirPlacement {
4114            sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
4115            records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
4116            workdir: std::path::PathBuf::from("/home/dev"),
4117            sessions_inside_workdir: true,
4118            records_inside_workdir: true,
4119        }
4120    }
4121
4122    /// One claim per applied tier, from the tier, the ruleset ABI and the placement alone.
4123    #[test]
4124    fn runtime_dir_report_makes_one_claim_per_tier() {
4125        let outside = RuntimeDirPlacement::outside_for_test();
4126        for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
4127            let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
4128            assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
4129            assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
4130            assert_eq!(sealed.not_protected_because, None);
4131        }
4132
4133        let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
4134        assert!(full.read_only_to_commands);
4135        assert_eq!(full.enforced_by, Some("landlock"));
4136        assert_eq!(full.not_protected_because, None);
4137
4138        for abi in [Some(1), Some(2)] {
4139            let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
4140            assert!(!old.read_only_to_commands);
4141            assert_eq!(old.enforced_by, None);
4142            let reason = old.not_protected_because.unwrap();
4143            assert!(
4144                reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
4145                    && reason.contains("truncate(2)")
4146                    && reason.contains("6.2"),
4147                "{reason}"
4148            );
4149        }
4150
4151        let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
4152        assert!(!seccomp.read_only_to_commands);
4153        assert!(seccomp
4154            .not_protected_because
4155            .unwrap()
4156            .contains("no Landlock"));
4157
4158        let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
4159        assert!(!none.read_only_to_commands);
4160        let reason = none.not_protected_because.unwrap();
4161        assert!(
4162            reason.contains("no kernel containment") && reason.contains("macOS"),
4163            "{reason}"
4164        );
4165
4166        assert_eq!(
4167            full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
4168            "the path carries the literal session-id segment"
4169        );
4170    }
4171
4172    /// Inside the workdir, nothing protects it on any tier, and the reason names both paths.
4173    #[test]
4174    fn runtime_dir_report_inside_the_workdir_is_never_protected() {
4175        for tier in ALL_TIERS {
4176            let report = runtime_dir_report(*tier, Some(6), &inside_placement());
4177            assert!(!report.read_only_to_commands, "{tier:?}");
4178            assert_eq!(report.enforced_by, None);
4179        }
4180        let reason =
4181            runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
4182                .not_protected_because
4183                .unwrap();
4184        assert!(
4185            reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
4186            "{reason}"
4187        );
4188        assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
4189    }
4190
4191    /// A runtime directory inside the workdir caps the class at `advisory`, refuses a `scoped` or
4192    /// `sealed` floor naming both paths and the fix, and lets an `advisory` launch through.
4193    #[test]
4194    fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
4195        let inside = inside_placement();
4196        assert_eq!(
4197            achieved_containment_class(EnforcementTier::KernelSealed, false, true),
4198            ContainmentClass::Advisory
4199        );
4200        assert_eq!(
4201            achieved_containment_class(EnforcementTier::KernelSealed, false, false),
4202            ContainmentClass::Sealed
4203        );
4204        for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
4205            match check_containment_floor(
4206                declared,
4207                ContainmentClass::Advisory,
4208                None,
4209                false,
4210                inside.inside(),
4211            ) {
4212                Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
4213                    assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
4214                    assert!(reason.contains("the workdir /home/dev"), "{reason}");
4215                    assert!(
4216                        reason.contains("run with a --workdir that does not contain ~/.murmur"),
4217                        "{reason}"
4218                    );
4219                }
4220                other => panic!("{declared:?}: {other:?}"),
4221            }
4222        }
4223        assert!(check_containment_floor(
4224            ContainmentClass::Advisory,
4225            ContainmentClass::Advisory,
4226            None,
4227            false,
4228            inside.inside()
4229        )
4230        .is_ok());
4231
4232        let report = report_for(
4233            &sample_policy(),
4234            ContainmentClass::Advisory,
4235            EnforcementTier::KernelFull,
4236            None,
4237            None,
4238            Some(6),
4239            None,
4240            Vec::new(),
4241            Vec::new(),
4242            Vec::new(),
4243            &inside,
4244            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4245                EnforcementTier::KernelFull,
4246                ContainmentClass::Advisory,
4247            )),
4248            IoMaxReport::default(),
4249        );
4250        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4251        let reason = report.runtime_dir.not_protected_because.clone().unwrap();
4252        assert!(report.filesystem_boundary.not_protected.contains(&reason));
4253        let rendered = report.render();
4254        assert!(
4255            rendered.contains(&format!("  commands:  can change it — {reason}\n")),
4256            "{rendered}"
4257        );
4258    }
4259
4260    /// The block `--explain-scope` prints after `Containment`, and the JSON object it renders.
4261    #[test]
4262    fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
4263        let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
4264        let rendered = report.render();
4265        let block =
4266            "Runtime directory\n  path:      /home/test/.murmur/sessions/<session-id>/runtime\n  \
4267                     commands:  read only (landlock)\n";
4268        let at = rendered.find(block).expect(&rendered);
4269        assert!(at > rendered.find("Containment\n").unwrap());
4270        assert!(at < rendered.find("Effective grants").unwrap());
4271
4272        let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
4273        assert!(sealed
4274            .render()
4275            .contains("  commands:  read only (mountns+landlock)\n"));
4276
4277        let json = serde_json::to_string(&report.runtime_dir).unwrap();
4278        assert_eq!(
4279            json,
4280            r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
4281        );
4282    }
4283
4284    /// A Landlock domain really does mediate the filesystem, so its statement says what Landlock
4285    /// leaves open rather than repeating the advisory one.
4286    #[test]
4287    fn a_landlock_session_reports_denial_rather_than_absence() {
4288        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
4289
4290        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
4291        assert_eq!(
4292            report.filesystem_boundary.restriction,
4293            FilesystemRestriction::Enforced
4294        );
4295        assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
4296
4297        let filesystem = &report.filesystem_boundary.not_protected[0];
4298        assert!(
4299            !filesystem.contains("advisory"),
4300            "Landlock mediates the filesystem here: {filesystem}"
4301        );
4302        assert!(
4303            filesystem.contains("denied rather than absent"),
4304            "{filesystem}"
4305        );
4306        for probe in ["stat", "access", "readlink"] {
4307            assert!(filesystem.contains(probe), "{filesystem}");
4308        }
4309    }
4310
4311    /// A capsule that declares less than `sealed` composes no root on a sealed-capable host: it
4312    /// runs Landlock-scoped, is reported `scoped` on `landlock+seccomp`, and the block says so
4313    /// rather than claim the paths outside its grants are absent.
4314    #[test]
4315    fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
4316        let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
4317
4318        assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
4319        assert_eq!(report.enforcement_tier, "landlock+seccomp");
4320        assert_eq!(
4321            report.host_containment,
4322            ContainmentClass::Sealed,
4323            "the host can back sealed, which only `host_containment` says"
4324        );
4325        assert_eq!(
4326            report.filesystem_boundary.restriction,
4327            FilesystemRestriction::Enforced,
4328            "no composed root is installed for a capsule that did not declare one"
4329        );
4330        assert_eq!(
4331            report.filesystem_boundary.not_protected,
4332            boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
4333                .filesystem_boundary
4334                .not_protected,
4335            "the same mechanism gets the same statements on either host"
4336        );
4337        assert!(report.render().contains("Not protected here"));
4338    }
4339
4340    /// The half of the block that is genuinely mechanism-independent: `HOME` is rewritten on every
4341    /// tier, so the statement about it is one string, not one per mechanism.
4342    #[test]
4343    fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
4344        let advisory =
4345            boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
4346        let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
4347
4348        assert_eq!(
4349            advisory.filesystem_boundary.not_protected[1],
4350            enforced.filesystem_boundary.not_protected[1]
4351        );
4352        assert!(
4353            advisory.filesystem_boundary.not_protected[1]
4354                .contains("home/ in its records directory"),
4355            "the statement must name the directory `shell::build_shell_env` really sets HOME to"
4356        );
4357    }
4358
4359    /// A composed root prints no heading — not a heading with an empty list under it — and still
4360    /// carries the key, because an absent key must identify a runtime that predates the field.
4361    #[test]
4362    fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
4363        let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
4364
4365        assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
4366        assert_eq!(
4367            report.filesystem_boundary.restriction,
4368            FilesystemRestriction::Absent
4369        );
4370        assert!(report.filesystem_boundary.not_protected.is_empty());
4371        assert!(
4372            !report.render().contains("Not protected here"),
4373            "{}",
4374            report.render()
4375        );
4376
4377        let json = serde_json::to_value(&report).unwrap();
4378        assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
4379        assert_eq!(
4380            json["filesystem_boundary"]["not_protected"],
4381            serde_json::json!([]),
4382            "the key is present and empty at absent, never missing"
4383        );
4384    }
4385
4386    /// `capabilities.filesystem.workdir_exec` caps the achieved *class* at `advisory` and leaves
4387    /// the *mechanism* alone: Landlock is still installed, so the block prints the Landlock
4388    /// statement rather than claiming the host denies nothing.
4389    #[test]
4390    fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
4391        let policy = CapabilityPolicy {
4392            workdir_exec_allowed: true,
4393            ..CapabilityPolicy::default()
4394        };
4395
4396        let report = report_for(
4397            &policy,
4398            ContainmentClass::Advisory,
4399            EnforcementTier::KernelSealed,
4400            None,
4401            None,
4402            Some(6),
4403            None,
4404            Vec::new(),
4405            Vec::new(),
4406            Vec::new(),
4407            &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4408            crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4409                EnforcementTier::KernelSealed,
4410                ContainmentClass::Advisory,
4411            )),
4412            IoMaxReport::default(),
4413        );
4414
4415        assert_eq!(report.enforcement_tier, "landlock+seccomp");
4416        assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4417        assert_eq!(
4418            report.filesystem_boundary.restriction,
4419            FilesystemRestriction::Enforced
4420        );
4421        assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
4422        assert!(report.render().contains("Not protected here"));
4423    }
4424
4425    /// The restriction follows the tier the session applies, at every combination of host tier and
4426    /// declared floor — which is what `runtime_writes` beside it is already keyed on.
4427    #[test]
4428    fn the_restriction_follows_the_tier_the_session_would_install() {
4429        for tier in ALL_TIERS {
4430            for declared in [
4431                ContainmentClass::Advisory,
4432                ContainmentClass::Scoped,
4433                ContainmentClass::Sealed,
4434            ] {
4435                let report = boundary_report(declared, *tier);
4436                let expected = match crate::sandbox::applied_tier(*tier, declared) {
4437                    EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
4438                    EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
4439                    EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
4440                        FilesystemRestriction::Advisory
4441                    }
4442                };
4443                assert_eq!(
4444                    report.filesystem_boundary.restriction, expected,
4445                    "{tier:?} / {declared:?}"
4446                );
4447                assert_eq!(
4448                    report.filesystem_boundary.not_protected.is_empty(),
4449                    expected == FilesystemRestriction::Absent,
4450                    "{tier:?} / {declared:?}"
4451                );
4452                let wire = match expected {
4453                    FilesystemRestriction::Advisory => "advisory",
4454                    FilesystemRestriction::Enforced => "enforced",
4455                    FilesystemRestriction::Absent => "absent",
4456                };
4457                assert_eq!(
4458                    serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
4459                    serde_json::Value::String(wire.to_string())
4460                );
4461            }
4462        }
4463    }
4464
4465    /// The one assertion the report cannot satisfy by agreeing with itself: what a subprocess in a
4466    /// real session actually gets back when it probes a host file outside its workdir, compared
4467    /// against the `restriction` the report prints for that same session.
4468    ///
4469    /// Runs on whatever tier this host reaches, and asserts against the restriction it observes
4470    /// rather than against a tier it assumes. `bash` reads the file with a redirect and tests its
4471    /// existence with `[[ -e ]]` — both are syscalls the shell makes itself, so nothing here needs
4472    /// a second binary on the exec allowlist.
4473    ///
4474    /// Driving `execute_shell` needs an egress network namespace, so it stands down where the host
4475    /// withholds one rather than failing: a host that restricts unprivileged user namespaces
4476    /// refuses the uid_map write every spawn goes through, whatever the filesystem tier.
4477    #[test]
4478    fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
4479        if crate::network_namespace::skip_without_egress_namespace(
4480            "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
4481        ) {
4482            return;
4483        }
4484        if crate::sandbox::find_on_path("bash").is_none() {
4485            eprintln!(
4486                "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
4487                 no bash on PATH"
4488            );
4489            return;
4490        }
4491        let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
4492        let secret = outside.path().join("host-file");
4493        std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
4494        let secret = secret.to_str().unwrap().to_string();
4495
4496        for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
4497            let workdir = tempfile::tempdir().unwrap();
4498            let policy = CapabilityPolicy {
4499                shell_allow: vec!["bash".to_string()],
4500                ..CapabilityPolicy::default()
4501            };
4502            let probe = crate::sandbox::HostProbe::probe();
4503            let host_tier = probe.tier();
4504            let runtime = tempfile::tempdir().unwrap();
4505            let enforcement =
4506                crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
4507                    .unwrap()
4508                    .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
4509            let report = report_for(
4510                &policy,
4511                declared,
4512                host_tier,
4513                None,
4514                None,
4515                None,
4516                None,
4517                Vec::new(),
4518                Vec::new(),
4519                Vec::new(),
4520                &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4521                crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4522                    host_tier, declared,
4523                )),
4524                IoMaxReport::default(),
4525            );
4526
4527            let command = format!(
4528                "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
4529                 else echo read=refused; fi; \
4530                 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
4531            );
4532            let result = crate::shell::execute_shell(
4533                "bash",
4534                &["-c", &command],
4535                &[],
4536                &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
4537                &policy,
4538                &enforcement,
4539            )
4540            .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
4541            let seen = result.stdout;
4542
4543            match report.filesystem_boundary.restriction {
4544                FilesystemRestriction::Advisory => assert!(
4545                    seen.contains("read=PROBE-CONTENTS"),
4546                    "reported advisory, so nothing denies the read: {seen}"
4547                ),
4548                FilesystemRestriction::Enforced => assert!(
4549                    seen.contains("read=refused") && seen.contains("exists=yes"),
4550                    "reported enforced, so the read is denied and the path still exists: {seen}"
4551                ),
4552                FilesystemRestriction::Absent => assert!(
4553                    seen.contains("read=refused") && seen.contains("exists=no"),
4554                    "reported absent, so the path is not in the composed root: {seen}"
4555                ),
4556            }
4557        }
4558    }
4559
4560    /// The `pub` field names of `CapabilityPolicy`, read out of `types.rs`'s own text.
4561    fn capability_policy_field_names(source: &str) -> Vec<String> {
4562        source
4563            .split_once("pub struct CapabilityPolicy {")
4564            .expect("types.rs declares CapabilityPolicy")
4565            .1
4566            .split_once("\n}")
4567            .expect("CapabilityPolicy's declaration is closed at column 0")
4568            .0
4569            .lines()
4570            .filter_map(|line| {
4571                line.strip_prefix("    pub ")
4572                    .and_then(|rest| rest.split_once(':'))
4573                    .map(|(name, _)| name.to_string())
4574            })
4575            .collect()
4576    }
4577
4578    /// Every row of the per-tier `/tmp` table, through the decision staging makes and the report
4579    /// built from it: exact `tmp_mapped`, `mapped_by` and `tmpdir`, and a reason exactly when not
4580    /// mapped. The report the session records is the one `scope_report_for_tier` builds from the
4581    /// same answer.
4582    #[test]
4583    fn the_temp_dir_report_follows_the_per_tier_table() {
4584        use crate::temp_dir::temp_mapping;
4585
4586        let placement = RuntimeDirPlacement::outside_for_test();
4587        let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
4588        let tmp = crate::temp_dir::canonical_tmp();
4589        let elsewhere = std::path::Path::new("/work");
4590        // (host tier, declared, mount namespace usable, workdir, tmp_mapped, mapped_by)
4591        let rows = [
4592            (
4593                EnforcementTier::KernelSealed,
4594                ContainmentClass::Sealed,
4595                true,
4596                elsewhere,
4597                true,
4598                Some("composed-root"),
4599            ),
4600            (
4601                EnforcementTier::KernelSealed,
4602                ContainmentClass::Scoped,
4603                true,
4604                elsewhere,
4605                true,
4606                Some("mount-namespace"),
4607            ),
4608            (
4609                EnforcementTier::KernelSealed,
4610                ContainmentClass::Advisory,
4611                true,
4612                elsewhere,
4613                true,
4614                Some("mount-namespace"),
4615            ),
4616            (
4617                EnforcementTier::KernelFull,
4618                ContainmentClass::Scoped,
4619                true,
4620                elsewhere,
4621                true,
4622                Some("mount-namespace"),
4623            ),
4624            (
4625                EnforcementTier::KernelFull,
4626                ContainmentClass::Scoped,
4627                false,
4628                elsewhere,
4629                false,
4630                None,
4631            ),
4632            (
4633                EnforcementTier::KernelSeccompOnly,
4634                ContainmentClass::Advisory,
4635                true,
4636                elsewhere,
4637                true,
4638                Some("mount-namespace"),
4639            ),
4640            (
4641                EnforcementTier::KernelSeccompOnly,
4642                ContainmentClass::Advisory,
4643                false,
4644                elsewhere,
4645                false,
4646                None,
4647            ),
4648            (
4649                EnforcementTier::EnvironmentOnly,
4650                ContainmentClass::Advisory,
4651                false,
4652                elsewhere,
4653                false,
4654                None,
4655            ),
4656            (
4657                EnforcementTier::KernelFull,
4658                ContainmentClass::Scoped,
4659                true,
4660                tmp.as_path(),
4661                false,
4662                None,
4663            ),
4664            (
4665                EnforcementTier::KernelSeccompOnly,
4666                ContainmentClass::Advisory,
4667                true,
4668                tmp.as_path(),
4669                false,
4670                None,
4671            ),
4672        ];
4673        for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
4674            let applied = crate::sandbox::applied_tier(tier, declared);
4675            let mapping = temp_mapping(applied, usable, workdir);
4676            let report = temp_dir_report(mapping, &placement);
4677            let row = format!(
4678                "{tier:?} {declared:?} usable={usable} workdir={}",
4679                workdir.display()
4680            );
4681            assert_eq!(report.path, folder, "{row}");
4682            assert_eq!(report.tmp_mapped, mapped, "{row}");
4683            assert_eq!(report.mapped_by, mapped_by, "{row}");
4684            assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
4685            assert_eq!(
4686                report
4687                    .not_mapped_because
4688                    .as_deref()
4689                    .is_some_and(|reason| !reason.is_empty()),
4690                !mapped,
4691                "{row}"
4692            );
4693
4694            let scope = report_for(
4695                &CapabilityPolicy::default(),
4696                declared,
4697                tier,
4698                None,
4699                None,
4700                None,
4701                None,
4702                Vec::new(),
4703                Vec::new(),
4704                Vec::new(),
4705                &placement,
4706                mapping,
4707                IoMaxReport::default(),
4708            );
4709            assert_eq!(scope.temp_dir, report, "{row}");
4710        }
4711
4712        let reason = |tier, usable, workdir| {
4713            temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
4714                .not_mapped_because
4715                .unwrap()
4716        };
4717        assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
4718            .starts_with("this host has no kernel containment for capsule commands"));
4719        assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
4720            .contains("neither a private mount namespace nor Landlock"));
4721        assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
4722            .contains("a literal /tmp path is refused"));
4723        assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
4724            .starts_with("the workdir is /tmp itself"));
4725    }
4726
4727    /// The `Temporary folder` block: its shape mapped and not, its JSON keys, and its place after
4728    /// the records directory in `--explain-scope`.
4729    #[test]
4730    fn the_temp_folder_block_renders_after_the_records_directory() {
4731        use crate::temp_dir::{NotMappedReason, TempMapping};
4732
4733        let placement = RuntimeDirPlacement::outside_for_test();
4734        let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
4735        assert_eq!(
4736            render_temp_dir(&mapped),
4737            "Temporary folder\n  path:      /home/test/.murmur/records/work/<session-id>/tmp\n  \
4738             TMPDIR:    /tmp\n  /tmp:      mapped (mount-namespace)\n"
4739        );
4740        let unmapped = temp_dir_report(
4741            TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
4742            &placement,
4743        );
4744        assert_eq!(
4745            render_temp_dir(&unmapped),
4746            format!(
4747                "Temporary folder\n  path:      {path}\n  TMPDIR:    {path}\n  /tmp:      not \
4748                 mapped — {}\n",
4749                NotMappedReason::NoMountNamespace.describe(),
4750                path = "/home/test/.murmur/records/work/<session-id>/tmp"
4751            )
4752        );
4753        assert_eq!(
4754            serde_json::to_value(&mapped).unwrap(),
4755            serde_json::json!({
4756                "path": "/home/test/.murmur/records/work/<session-id>/tmp",
4757                "tmpdir": "/tmp",
4758                "tmp_mapped": true,
4759                "mapped_by": "mount-namespace",
4760                "not_mapped_because": null,
4761            })
4762        );
4763
4764        let scope = report_for(
4765            &CapabilityPolicy::default(),
4766            ContainmentClass::Scoped,
4767            EnforcementTier::KernelFull,
4768            None,
4769            None,
4770            None,
4771            None,
4772            Vec::new(),
4773            Vec::new(),
4774            Vec::new(),
4775            &placement,
4776            TempMapping::MountNamespace,
4777            IoMaxReport::default(),
4778        );
4779        let rendered = scope.render();
4780        let records = rendered.find("Records directory\n").expect("records block");
4781        let temp = rendered.find("Temporary folder\n").expect("temp block");
4782        assert!(records < temp, "{rendered}");
4783        assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
4784        assert!(serde_json::to_value(&scope)
4785            .unwrap()
4786            .get("temp_dir")
4787            .is_some());
4788    }
4789}