Skip to main content

capsule_runtime/
containment.rs

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