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