1use murmur_artifact::{ContainmentClass, ExportMode, Exports, FileExport, PeerFilesExport};
30use serde::Serialize;
31
32use crate::{
33 cgroup::{IoMaxReport, IoMaxStatus},
34 errors::RuntimeError,
35 runtime_dir::RuntimeDirPlacement,
36 sandbox::{detect_enforcement_tier, EnforcementTier},
37 sealed::{SealedBlocker, UsernsGrant},
38 types::CapabilityPolicy,
39};
40
41pub use crate::sandbox::{detect_sealed_blocker, detect_userns_grant};
42
43pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
49 match tier {
50 EnforcementTier::KernelSealed => ContainmentClass::Sealed,
55 EnforcementTier::KernelFull => ContainmentClass::Scoped,
58 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
62 ContainmentClass::Advisory
63 }
64 }
65}
66
67pub(crate) fn achieved_containment_class(
87 tier: EnforcementTier,
88 workdir_exec: bool,
89 runtime_dir_inside_workdir: bool,
90) -> ContainmentClass {
91 let host_ceiling = achieved_class_for_tier(tier);
92 if workdir_exec || runtime_dir_inside_workdir {
93 host_ceiling.min(ContainmentClass::Advisory)
94 } else {
95 host_ceiling
96 }
97}
98
99pub fn detect_achieved_containment() -> ContainmentClass {
107 achieved_class_for_tier(detect_enforcement_tier())
108}
109
110pub fn containment_shortfall_reason(
128 declared: ContainmentClass,
129 achieved: ContainmentClass,
130 sealed_blocker: Option<SealedBlocker>,
131 workdir_exec: bool,
132 runtime_dir_inside: Option<&RuntimeDirPlacement>,
133) -> Option<String> {
134 containment_shortfall(
135 declared,
136 achieved,
137 sealed_blocker,
138 workdir_exec,
139 runtime_dir_inside,
140 )
141 .map(|(reason, _)| reason)
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ShortfallCause {
148 Host,
150 WorkdirExec,
152 InsideWorkdir,
155}
156
157fn containment_shortfall(
159 declared: ContainmentClass,
160 achieved: ContainmentClass,
161 sealed_blocker: Option<SealedBlocker>,
162 workdir_exec: bool,
163 runtime_dir_inside: Option<&RuntimeDirPlacement>,
164) -> Option<(String, ShortfallCause)> {
165 if achieved >= declared {
166 return None;
167 }
168
169 if workdir_exec && declared > ContainmentClass::Advisory {
170 return Some((
171 WORKDIR_EXEC_SHORTFALL.to_string(),
172 ShortfallCause::WorkdirExec,
173 ));
174 }
175
176 if let Some(placement) = runtime_dir_inside.filter(|_| declared > ContainmentClass::Advisory) {
177 return Some((placement.inside_shortfall(), ShortfallCause::InsideWorkdir));
178 }
179
180 let reason = match declared {
181 ContainmentClass::Advisory => {
185 "advisory is the weakest containment class and is satisfied by every host".to_string()
186 }
187 ContainmentClass::Scoped => {
188 "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
189 ABI); this host provides no kernel filesystem mediation, so paths outside the \
190 workdir are constrained by convention only"
191 .to_string()
192 }
193 ContainmentClass::Sealed => match sealed_blocker {
196 Some(blocker) => blocker.reason(),
197 None => "sealed requires a private mount namespace pivoted onto a composed root \
198 (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
199 provide"
200 .to_string(),
201 },
202 };
203
204 Some((reason, ShortfallCause::Host))
205}
206
207const WORKDIR_EXEC_SHORTFALL: &str =
212 "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
213 workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
214 capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
215 No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
216 then enforced by the kernel on the resolved path) or lower the declared containment floor to \
217 advisory";
218
219pub fn check_containment_floor(
225 declared: ContainmentClass,
226 achieved: ContainmentClass,
227 sealed_blocker: Option<SealedBlocker>,
228 workdir_exec: bool,
229 runtime_dir_inside: Option<&RuntimeDirPlacement>,
230) -> Result<(), RuntimeError> {
231 match containment_shortfall(
232 declared,
233 achieved,
234 sealed_blocker,
235 workdir_exec,
236 runtime_dir_inside,
237 ) {
238 None => Ok(()),
239 Some((reason, cause)) => Err(RuntimeError::ContainmentFloorUnmet {
240 declared,
241 achieved,
242 reason,
243 cause,
244 }),
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
255pub struct ExportsFilesReport {
256 pub root: String,
258 pub mode: ExportMode,
259 pub max_bytes: u64,
261}
262
263impl From<&FileExport> for ExportsFilesReport {
264 fn from(export: &FileExport) -> Self {
265 Self {
266 root: export.root.clone(),
267 mode: export.mode,
268 max_bytes: export.max_bytes,
269 }
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
279pub struct PeerFilesReport {
280 pub root: String,
282 pub max_ttl_secs: u64,
286 pub max_bytes: u64,
288}
289
290impl From<&PeerFilesExport> for PeerFilesReport {
291 fn from(export: &PeerFilesExport) -> Self {
292 Self {
293 root: export.root.clone(),
294 max_ttl_secs: export.effective_max_ttl_secs(),
295 max_bytes: export.max_bytes,
296 }
297 }
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct StateStoreReport {
307 pub artifact: String,
309 pub store: String,
312 pub host_path: String,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
326#[serde(rename_all = "kebab-case")]
327pub enum PreopenSurface {
328 WholeWorkdir,
334 ScopedSubtree,
337 Nothing,
341 RecordsHooks,
345}
346
347impl PreopenSurface {
348 fn summary(self) -> &'static str {
352 match self {
353 PreopenSurface::WholeWorkdir => "the whole accessible workdir",
354 PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
355 PreopenSurface::Nothing => "nothing preopened",
356 PreopenSurface::RecordsHooks => "the records directory's hooks/",
357 }
358 }
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
370pub struct PreopenReport {
371 pub artifact: String,
373 pub role: String,
376 pub scope: Option<String>,
379 pub surface: PreopenSurface,
382 pub runtime_dir_reach: Option<RuntimeDirReach>,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "kebab-case")]
394pub enum RuntimeDirReach {
395 MurmurRuntime,
397 Environment,
399 Unresolved,
401}
402
403#[must_use]
407pub fn resolve_runtime_dir_reach(
408 reach: Option<RuntimeDirReach>,
409 implementation: Option<murmur_artifact::ArtifactImplementation>,
410) -> Option<RuntimeDirReach> {
411 reach?;
412 Some(match implementation {
413 Some(murmur_artifact::ArtifactImplementation::Native) => RuntimeDirReach::Environment,
414 Some(murmur_artifact::ArtifactImplementation::Wasm) => RuntimeDirReach::MurmurRuntime,
415 None => RuntimeDirReach::Unresolved,
416 })
417}
418
419pub fn resolve_runtime_dir_reaches(
422 reports: &mut [PreopenReport],
423 implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
424) {
425 for report in reports {
426 report.runtime_dir_reach = resolve_runtime_dir_reach(
427 report.runtime_dir_reach,
428 implementation_of(&report.artifact),
429 );
430 }
431}
432
433impl PreopenReport {
434 pub fn render(&self, runtime_dir_protected: bool) -> String {
441 let artifact = &self.artifact;
442 let role = &self.role;
443 let surface = self.surface.summary();
444 let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
445 let env = crate::runtime_dir::RUNTIME_DIR_ENV;
446 let runtime_dir = match (self.runtime_dir_reach, runtime_dir_protected) {
447 (None, _) => String::new(),
448 (Some(RuntimeDirReach::MurmurRuntime), _) => {
449 format!("; runtime directory read-only at {guest}/")
450 }
451 (Some(RuntimeDirReach::Environment), true) => {
452 format!("; runtime directory read-only at ${env}")
453 }
454 (Some(RuntimeDirReach::Environment), false) => {
455 format!("; runtime directory at ${env}, which commands can change on this host")
456 }
457 (Some(RuntimeDirReach::Unresolved), true) => {
458 format!("; runtime directory read-only at {guest}/ if WASM, at ${env} if native")
459 }
460 (Some(RuntimeDirReach::Unresolved), false) => format!(
461 "; runtime directory read-only at {guest}/ if WASM, at ${env} if native, which \
462 commands can change on this host"
463 ),
464 };
465 match &self.scope {
466 Some(scope) => format!(
467 "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
468 {scope}{runtime_dir}"
469 ),
470 None => format!(
471 "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
472 declared{runtime_dir}"
473 ),
474 }
475 }
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
486#[serde(rename_all = "kebab-case")]
487pub enum FilesystemRestriction {
488 Advisory,
490 Enforced,
492 Absent,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
506pub struct FilesystemBoundaryReport {
507 pub restriction: FilesystemRestriction,
509 pub not_protected: Vec<String>,
514}
515
516const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
521 "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
522 outside the session workdir, so capabilities.filesystem.scope bounds what the runtime hands a \
523 subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
524 ABI installs a Landlock domain instead, where the kernel refuses the open.";
525
526const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
532 "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
533 stat, access and readlink on that path still succeed and still report the real file's \
534 metadata, because Landlock mediates the operations that touch a file and not path resolution. \
535 A probe that only asks whether a path exists therefore learns nothing about what was denied — \
536 read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
537 reaches the sealed tier composes a root the path is not in at all.";
538
539const PATH_REWRITING_NOT_PROTECTED: &str =
545 "Path rewriting is a convenience, not a boundary: HOME is set to <workdir>/.capsule-home on \
546 every tier, so `cat ~/.ssh/id_rsa` answers \"No such file or directory\" whether or not \
547 anything was denied — the tilde resolved into the workdir and the host file was never looked \
548 for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
549 to a real file outside the workdir instead.";
550
551pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
564 let (restriction, filesystem) = match applied {
568 EnforcementTier::KernelSealed => (FilesystemRestriction::Absent, None),
569 EnforcementTier::KernelFull => (
570 FilesystemRestriction::Enforced,
571 Some(ENFORCED_FILESYSTEM_NOT_PROTECTED),
572 ),
573 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => (
574 FilesystemRestriction::Advisory,
575 Some(ADVISORY_FILESYSTEM_NOT_PROTECTED),
576 ),
577 };
578
579 let not_protected = match filesystem {
580 Some(filesystem) => vec![
581 filesystem.to_string(),
582 PATH_REWRITING_NOT_PROTECTED.to_string(),
583 ],
584 None => Vec::new(),
585 };
586
587 FilesystemBoundaryReport {
588 restriction,
589 not_protected,
590 }
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
598pub struct RuntimeDirReport {
599 pub path: String,
603 pub read_only_to_commands: bool,
605 pub enforced_by: Option<&'static str>,
608 pub not_protected_because: Option<String>,
610}
611
612const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
614
615const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
617
618const LANDLOCK_TRUNCATE_ABI: u8 = 3;
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624enum ProtectedDir {
625 Runtime,
626 Records,
627}
628
629impl ProtectedDir {
630 fn noun(self) -> &'static str {
631 match self {
632 Self::Runtime => "runtime directory",
633 Self::Records => "records directory",
634 }
635 }
636
637 fn file(self) -> &'static str {
639 match self {
640 Self::Runtime => "runtime file",
641 Self::Records => "record",
642 }
643 }
644
645 fn heading(self) -> &'static str {
646 match self {
647 Self::Runtime => "Runtime directory",
648 Self::Records => "Records directory",
649 }
650 }
651}
652
653#[must_use]
661pub(crate) fn runtime_dir_report(
662 applied: EnforcementTier,
663 landlock_abi: Option<u8>,
664 placement: &RuntimeDirPlacement,
665) -> RuntimeDirReport {
666 protected_dir_report(
667 ProtectedDir::Runtime,
668 applied,
669 landlock_abi,
670 placement.report_path(),
671 placement.sessions_inside_workdir,
672 &placement.workdir,
673 )
674}
675
676#[must_use]
679pub(crate) fn records_dir_report(
680 applied: EnforcementTier,
681 landlock_abi: Option<u8>,
682 placement: &RuntimeDirPlacement,
683) -> RuntimeDirReport {
684 protected_dir_report(
685 ProtectedDir::Records,
686 applied,
687 landlock_abi,
688 placement.records_report_path(),
689 placement.records_inside_workdir,
690 &placement.workdir,
691 )
692}
693
694fn protected_dir_report(
695 dir: ProtectedDir,
696 applied: EnforcementTier,
697 landlock_abi: Option<u8>,
698 path: String,
699 inside_workdir: bool,
700 workdir: &std::path::Path,
701) -> RuntimeDirReport {
702 let noun = dir.noun();
703 let not_protected = |reason: String| RuntimeDirReport {
704 path: path.clone(),
705 read_only_to_commands: false,
706 enforced_by: None,
707 not_protected_because: Some(reason),
708 };
709 match applied {
710 EnforcementTier::EnvironmentOnly => not_protected(format!(
711 "this host has no kernel containment for capsule commands — macOS and every \
712 non-Linux host run them as the operator's own processes, so a command can change \
713 the {noun}"
714 )),
715 EnforcementTier::KernelSeccompOnly => not_protected(format!(
716 "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
717 {noun}"
718 )),
719 _ if inside_workdir => not_protected(format!(
720 "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
721 it; run with a --workdir that does not contain ~/.murmur",
722 workdir.display()
723 )),
724 EnforcementTier::KernelSealed => RuntimeDirReport {
725 path,
726 read_only_to_commands: true,
727 enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
728 not_protected_because: None,
729 },
730 EnforcementTier::KernelFull => {
731 let abi = landlock_abi.unwrap_or(1);
732 if abi < LANDLOCK_TRUNCATE_ABI {
733 not_protected(format!(
734 "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
735 {} though not replace it; kernel 6.2 or later refuses it",
736 dir.file()
737 ))
738 } else {
739 RuntimeDirReport {
740 path,
741 read_only_to_commands: true,
742 enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
743 not_protected_because: None,
744 }
745 }
746 }
747 }
748}
749
750#[must_use]
753pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
754 render_protected_dir(ProtectedDir::Runtime, report)
755}
756
757#[must_use]
760pub fn render_records_dir(report: &RuntimeDirReport) -> String {
761 render_protected_dir(ProtectedDir::Records, report)
762}
763
764fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
765 let commands = match (&report.not_protected_because, report.enforced_by) {
766 (Some(reason), _) => format!("can change it — {reason}"),
767 (None, Some(mechanism)) => format!("read only ({mechanism})"),
768 (None, None) => "read only".to_string(),
769 };
770 format!(
771 "{}\n path: {}\n commands: {commands}\n",
772 dir.heading(),
773 report.path
774 )
775}
776
777#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
783pub struct ScopeReport {
784 pub declared_containment: ContainmentClass,
786 pub achieved_containment: ContainmentClass,
788 pub floor_met: bool,
790 #[serde(skip_serializing_if = "Option::is_none")]
792 pub shortfall_reason: Option<String>,
793 pub enforcement_tier: &'static str,
795 pub mechanism_covers: Vec<&'static str>,
802 pub userns_grant: Option<UsernsGrant>,
812 pub landlock_abi: Option<u8>,
822 pub filesystem_scope: Option<String>,
824 pub workdir_exec: bool,
832 pub read_only_paths: Vec<String>,
842 pub read_only_advisory_for: Vec<String>,
852 pub preopens: Vec<PreopenReport>,
865 pub network_allow: Vec<String>,
867 pub unix_sockets: bool,
869 pub shell_allow: Vec<String>,
871 pub spawn_allow: Vec<String>,
873 pub env_allow: Vec<String>,
875 pub plan_submit: bool,
883 pub install_skill: Vec<String>,
891 pub install_tool: Vec<String>,
895 pub install_from: Vec<String>,
900 pub interpreter_runtime_grants: Vec<String>,
904 pub exports_files: Option<ExportsFilesReport>,
914 pub peer_files: Option<PeerFilesReport>,
922 pub peer_tasks: bool,
928 pub peer_fetch_allow: Vec<String>,
935 pub state_stores: Vec<StateStoreReport>,
945 pub configured_artifacts: Vec<String>,
959 pub staged_runtime_grants: Vec<String>,
968 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
986 pub io_max: IoMaxReport,
1002 pub filesystem_boundary: FilesystemBoundaryReport,
1016 pub runtime_dir: RuntimeDirReport,
1021 pub records_dir: RuntimeDirReport,
1025}
1026
1027impl ScopeReport {
1028 pub fn render(&self) -> String {
1030 let mut out = String::new();
1031 out.push_str("Containment\n");
1032 out.push_str(&format!(" declared: {}\n", self.declared_containment));
1033 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
1034 out.push_str(&format!(
1035 " floor met: {}\n",
1036 if self.floor_met { "yes" } else { "no" }
1037 ));
1038 if let Some(reason) = &self.shortfall_reason {
1039 out.push_str(&format!(" reason: {reason}\n"));
1040 }
1041 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
1042 out.push_str(&format!(
1043 " covers: {}\n",
1044 render_mechanism_covers(&self.mechanism_covers)
1045 ));
1046 out.push_str(&format!(
1047 " userns grant: {}\n",
1048 match self.userns_grant {
1049 Some(grant) => grant.wire_name(),
1050 None => "n/a",
1051 }
1052 ));
1053 out.push_str(&format!(
1054 " landlock ABI: {}\n",
1055 match self.landlock_abi {
1056 Some(abi) => abi.to_string(),
1057 None => "n/a".to_string(),
1058 }
1059 ));
1060
1061 out.push('\n');
1062 out.push_str(&render_runtime_dir(&self.runtime_dir));
1063 out.push('\n');
1064 out.push_str(&render_records_dir(&self.records_dir));
1065
1066 out.push_str(&render_not_protected(&self.filesystem_boundary));
1067
1068 out.push_str("\nEffective grants\n");
1069 out.push_str(&format!(
1070 " filesystem scope: {}\n",
1071 self.filesystem_scope.as_deref().unwrap_or("<none>")
1072 ));
1073 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
1074 out.push_str(&render_read_only(
1075 &self.read_only_paths,
1076 &self.read_only_advisory_for,
1077 ));
1078 push_list(
1079 &mut out,
1080 "preopens",
1081 &self
1082 .preopens
1083 .iter()
1084 .map(|preopen| preopen.render(self.runtime_dir.read_only_to_commands))
1085 .collect::<Vec<_>>(),
1086 );
1087 push_list(&mut out, "network allow", &self.network_allow);
1088 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
1089 push_list(&mut out, "shell allow", &self.shell_allow);
1090 push_list(&mut out, "spawn allow", &self.spawn_allow);
1091 push_list(&mut out, "env allow", &self.env_allow);
1092 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
1093 out.push_str(&render_install(
1094 &self.install_skill,
1095 &self.install_tool,
1096 &self.install_from,
1097 ));
1098 push_list(
1099 &mut out,
1100 "interpreter runtime",
1101 &self.interpreter_runtime_grants,
1102 );
1103 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1104 push_list(
1105 &mut out,
1106 "state stores",
1107 &self
1108 .state_stores
1109 .iter()
1110 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1111 .collect::<Vec<_>>(),
1112 );
1113 push_list(&mut out, "artifact config", &self.configured_artifacts);
1114
1115 out.push_str("\nResource plane\n");
1116 match &self.exports_files {
1117 None => out.push_str(" exports.files: <none>\n"),
1118 Some(export) => {
1119 out.push_str(&format!(" exports.files root: {}\n", export.root));
1120 out.push_str(&format!(" mode: {}\n", export.mode));
1121 out.push_str(&format!(
1122 " max bytes: {} (per file)\n",
1123 export.max_bytes
1124 ));
1125 }
1126 }
1127
1128 out.push_str(&format!(
1129 " io.max: {} ({} bytes/s declared)\n",
1130 match self.io_max.status {
1131 IoMaxStatus::Enforced => "enforced",
1132 IoMaxStatus::Unavailable => "unavailable",
1133 IoMaxStatus::NotRequired => "not-required",
1134 IoMaxStatus::NotProbed => "not-probed",
1135 },
1136 self.io_max.declared_bytes_per_sec
1137 ));
1138 if let Some(reason) = &self.io_max.reason {
1139 out.push_str(&format!(" {reason}\n"));
1140 }
1141
1142 out.push_str("\nPeer handoff\n");
1143 match &self.peer_files {
1144 None => out.push_str(" exports.peer_files: <none>\n"),
1145 Some(peer) => {
1146 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
1147 out.push_str(&format!(
1148 " max handle ttl: {}s\n",
1149 peer.max_ttl_secs
1150 ));
1151 out.push_str(&format!(
1152 " max bytes: {} (per file)\n",
1153 peer.max_bytes
1154 ));
1155 }
1156 }
1157 out.push_str(if self.peer_tasks {
1158 " exports.peer_tasks: accept\n"
1159 } else {
1160 " exports.peer_tasks: refuse\n"
1161 });
1162 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1163
1164 out.push_str("\nRuntime writes\n");
1165 out.push_str(&crate::workdir_writes::render_runtime_writes(
1166 &self.runtime_writes,
1167 ));
1168
1169 if !self.floor_met {
1170 out.push_str(
1171 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1172 launch here.\n",
1173 );
1174 }
1175 out
1176 }
1177}
1178
1179#[must_use]
1187pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
1188 if read_only_paths.is_empty() {
1189 return Vec::new();
1190 }
1191 shell_allow
1192 .iter()
1193 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
1194 .cloned()
1195 .collect()
1196}
1197
1198#[must_use]
1206pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
1207 let mut out = String::new();
1208 push_list(&mut out, "read_only", read_only_paths);
1209 if read_only_paths.is_empty() {
1210 return out;
1211 }
1212 if advisory_for.is_empty() {
1213 out.push_str(
1214 " read_only enforcement: enforced for every tool call and every shell command the \
1215 dispatch check can read\n",
1216 );
1217 } else {
1218 out.push_str(&format!(
1219 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
1220 something the dispatch check can read; enforced for every tool call and for every \
1221 shell command it can read\n",
1222 advisory_for.join(", ")
1223 ));
1224 }
1225 out
1226}
1227
1228#[must_use]
1236pub fn render_install(
1237 install_skill: &[String],
1238 install_tool: &[String],
1239 install_from: &[String],
1240) -> String {
1241 let mut out = String::new();
1242 push_list(&mut out, "install skill", install_skill);
1243 push_list(&mut out, "install tool", install_tool);
1244 if !install_from.is_empty() {
1245 out.push_str(&format!(" install from: {}\n", install_from.join(", ")));
1246 }
1247 out
1248}
1249
1250#[must_use]
1252pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
1253 if !crate::install_grant::is_granted(policy) {
1254 return Vec::new();
1255 }
1256 let mut places = vec!["stores".to_string()];
1257 if policy.install_from_sources {
1258 places.push("sources".to_string());
1259 }
1260 places
1261}
1262
1263fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
1271 if boundary.not_protected.is_empty() {
1272 return String::new();
1273 }
1274 let mut out = String::from("\nNot protected here\n");
1275 for statement in &boundary.not_protected {
1276 out.push_str(&format!(" - {statement}\n"));
1277 }
1278 out
1279}
1280
1281fn push_list(out: &mut String, label: &str, values: &[String]) {
1282 if values.is_empty() {
1283 out.push_str(&format!(" {label}: <none>\n"));
1284 return;
1285 }
1286 out.push_str(&format!(" {label}:\n"));
1287 for value in values {
1288 out.push_str(&format!(" - {value}\n"));
1289 }
1290}
1291
1292#[allow(clippy::too_many_arguments)]
1308pub fn explain_scope(
1309 policy: &CapabilityPolicy,
1310 declared: ContainmentClass,
1311 exports: Option<&Exports>,
1312 state_stores: Vec<StateStoreReport>,
1313 configured_artifacts: Vec<String>,
1314 preopens: Vec<PreopenReport>,
1315 io_max: IoMaxReport,
1316 workdir: &std::path::Path,
1317) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
1318 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1319 let probe = crate::sandbox::HostProbe::probe();
1320 Ok(scope_report_for_tier(
1321 policy,
1322 declared,
1323 probe.tier(),
1324 probe.sealed_blocker(),
1325 probe.userns_grant(),
1326 probe.landlock_kernel_abi(),
1327 exports,
1328 state_stores,
1329 configured_artifacts,
1330 preopens,
1331 &placement,
1332 io_max,
1333 ))
1334}
1335
1336pub fn doctor_runtime_dir(
1340 declared: ContainmentClass,
1341 workdir: &std::path::Path,
1342) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
1343 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1344 let probe = crate::sandbox::HostProbe::probe();
1345 let applied = crate::sandbox::applied_tier(probe.tier(), declared);
1346 let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
1347 Ok(DoctorDirReports {
1348 runtime_dir: runtime_dir_report(applied, abi, &placement),
1349 records_dir: records_dir_report(applied, abi, &placement),
1350 placement,
1351 })
1352}
1353
1354#[derive(Debug, Clone, PartialEq, Eq)]
1356pub struct DoctorDirReports {
1357 pub runtime_dir: RuntimeDirReport,
1358 pub records_dir: RuntimeDirReport,
1359 pub placement: RuntimeDirPlacement,
1360}
1361
1362#[allow(clippy::too_many_arguments)]
1365pub(crate) fn scope_report_for_tier(
1366 policy: &CapabilityPolicy,
1367 declared: ContainmentClass,
1368 tier: EnforcementTier,
1369 sealed_blocker: Option<SealedBlocker>,
1370 userns_grant: Option<UsernsGrant>,
1371 landlock_kernel_abi: Option<u8>,
1372 exports: Option<&Exports>,
1373 state_stores: Vec<StateStoreReport>,
1374 configured_artifacts: Vec<String>,
1375 preopens: Vec<PreopenReport>,
1376 runtime_dir_placement: &RuntimeDirPlacement,
1377 io_max: IoMaxReport,
1378) -> ScopeReport {
1379 let achieved = achieved_containment_class(
1380 tier,
1381 policy.workdir_exec_allowed,
1382 runtime_dir_placement.inside_workdir,
1383 );
1384 let applied = crate::sandbox::applied_tier(tier, declared);
1389 let shortfall_reason = containment_shortfall_reason(
1390 declared,
1391 achieved,
1392 sealed_blocker,
1393 policy.workdir_exec_allowed,
1394 runtime_dir_placement.inside(),
1395 );
1396 let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
1397 let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
1398 let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
1399 let mut filesystem_boundary = filesystem_boundary(applied);
1400 for report in [&runtime_dir, &records_dir] {
1401 if let Some(reason) = &report.not_protected_because {
1402 filesystem_boundary.not_protected.push(reason.clone());
1403 }
1404 }
1405
1406 ScopeReport {
1407 declared_containment: declared,
1408 achieved_containment: achieved,
1409 floor_met: shortfall_reason.is_none(),
1410 shortfall_reason,
1411 enforcement_tier: enforcement_tier_name(tier),
1412 mechanism_covers: MECHANISM_COVERS.to_vec(),
1413 userns_grant,
1414 landlock_abi,
1415 exports_files: exports
1418 .and_then(|exports| exports.files.as_ref())
1419 .map(ExportsFilesReport::from),
1420 peer_files: exports
1421 .and_then(|exports| exports.peer_files.as_ref())
1422 .map(PeerFilesReport::from),
1423 peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
1424 peer_fetch_allow: policy.peer_fetch_allow.clone(),
1425 state_stores,
1429 configured_artifacts,
1433 filesystem_scope: policy.filesystem_scope.clone(),
1434 workdir_exec: policy.workdir_exec_allowed,
1435 read_only_paths: policy.read_only_paths.clone(),
1436 read_only_advisory_for: read_only_advisory_for(
1437 &policy.read_only_paths,
1438 &policy.shell_allow,
1439 ),
1440 preopens,
1444 network_allow: policy.network_allow.clone(),
1445 unix_sockets: policy.unix_sockets_allowed,
1446 shell_allow: policy.shell_allow.clone(),
1447 spawn_allow: policy.spawn_allow.clone(),
1448 env_allow: policy.env_allow.clone(),
1449 plan_submit: policy.plan_submit,
1450 install_skill: policy.install_skill.clone(),
1451 install_tool: policy.install_tool.clone(),
1452 install_from: install_from_places(policy),
1453 interpreter_runtime_grants: policy
1454 .shell_interpreter_runtime
1455 .iter()
1456 .flat_map(|grant| {
1457 grant.dirs.iter().map(move |dir| {
1458 let listable = if dir.list_dir { " (list_dir)" } else { "" };
1459 format!("{}: {}{}", grant.binary, dir.path, listable)
1460 })
1461 })
1462 .collect(),
1463 staged_runtime_grants: policy
1464 .shell_staged_runtime
1465 .iter()
1466 .map(|grant| {
1467 format!(
1468 "{}: {} (pin: {})",
1469 grant.binary, grant.source_path, grant.pin
1470 )
1471 })
1472 .collect(),
1473 runtime_writes: crate::workdir_writes::runtime_writes(applied),
1479 io_max,
1483 filesystem_boundary,
1487 runtime_dir,
1488 records_dir,
1489 }
1490}
1491
1492pub(crate) const MECHANISM_COVERS: &[&str] = &["shell", "plan-shell-steps", "native-tools"];
1495
1496fn render_mechanism_covers(covers: &[&str]) -> String {
1498 covers
1499 .iter()
1500 .map(|cover| match *cover {
1501 "shell" => "shell commands",
1502 "plan-shell-steps" => "plan shell steps",
1503 "native-tools" => "native tools",
1504 other => other,
1505 })
1506 .collect::<Vec<_>>()
1507 .join(", ")
1508}
1509
1510pub fn applied_mechanism(declared: ContainmentClass) -> &'static str {
1515 let probe = crate::sandbox::HostProbe::probe();
1516 enforcement_tier_name(crate::sandbox::applied_tier(probe.tier(), declared))
1517}
1518
1519fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
1522 match tier {
1523 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
1524 EnforcementTier::KernelFull => "landlock+seccomp",
1525 EnforcementTier::KernelSeccompOnly => "seccomp-only",
1526 EnforcementTier::EnvironmentOnly => "none",
1527 }
1528}
1529
1530#[cfg(test)]
1531#[allow(clippy::print_stdout, clippy::print_stderr)]
1532mod tests {
1533 use super::*;
1534 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1535
1536 const ALL_TIERS: &[EnforcementTier] = &[
1538 EnforcementTier::KernelSealed,
1539 EnforcementTier::KernelFull,
1540 EnforcementTier::KernelSeccompOnly,
1541 EnforcementTier::EnvironmentOnly,
1542 ];
1543
1544 #[test]
1545 fn kernel_full_achieves_scoped() {
1546 assert_eq!(
1547 achieved_class_for_tier(EnforcementTier::KernelFull),
1548 ContainmentClass::Scoped
1549 );
1550 }
1551
1552 #[test]
1554 fn only_the_sealed_tier_achieves_sealed() {
1555 assert_eq!(
1556 achieved_class_for_tier(EnforcementTier::KernelSealed),
1557 ContainmentClass::Sealed
1558 );
1559 for tier in ALL_TIERS
1560 .iter()
1561 .filter(|t| **t != EnforcementTier::KernelSealed)
1562 {
1563 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1564 }
1565 }
1566
1567 #[test]
1568 fn tiers_without_landlock_achieve_only_advisory() {
1569 assert_eq!(
1570 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1571 ContainmentClass::Advisory
1572 );
1573 assert_eq!(
1574 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1575 ContainmentClass::Advisory
1576 );
1577 }
1578
1579 #[test]
1585 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1586 assert_eq!(
1588 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
1589 ContainmentClass::Sealed
1590 );
1591 assert_eq!(
1592 achieved_containment_class(EnforcementTier::KernelFull, false, false),
1593 ContainmentClass::Scoped
1594 );
1595 assert_eq!(
1596 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
1597 ContainmentClass::Advisory
1598 );
1599 assert_eq!(
1600 achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
1601 ContainmentClass::Advisory
1602 );
1603
1604 for tier in ALL_TIERS {
1606 assert_eq!(
1607 achieved_containment_class(*tier, true, false),
1608 ContainmentClass::Advisory,
1609 "workdir_exec must cap tier {tier:?} at advisory"
1610 );
1611 }
1612 }
1613
1614 #[test]
1617 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1618 for tier in ALL_TIERS {
1619 assert_eq!(
1620 achieved_class_for_tier(*tier),
1621 achieved_containment_class(*tier, false, false),
1622 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1623 );
1624 }
1625 }
1626
1627 #[test]
1631 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1632 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
1633 assert_eq!(achieved, ContainmentClass::Advisory);
1634
1635 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
1636 .expect_err("workdir_exec + scoped must refuse");
1637 match error {
1638 RuntimeError::ContainmentFloorUnmet {
1639 declared,
1640 achieved,
1641 reason,
1642 ..
1643 } => {
1644 assert_eq!(declared, ContainmentClass::Scoped);
1645 assert_eq!(achieved, ContainmentClass::Advisory);
1646 assert!(
1647 reason.contains("workdir_exec"),
1648 "the reason must name the manifest key, got: {reason}"
1649 );
1650 assert!(
1651 !reason.contains("this host provides no kernel filesystem mediation"),
1652 "the reason must not blame the host for a manifest declaration: {reason}"
1653 );
1654 }
1655 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1656 }
1657 }
1658
1659 #[test]
1663 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1664 let reason = containment_shortfall_reason(
1665 ContainmentClass::Sealed,
1666 ContainmentClass::Advisory,
1667 Some(SealedBlocker::AppArmorProfileMissing),
1668 true,
1669 None,
1670 )
1671 .expect("refusal");
1672 assert!(reason.contains("workdir_exec"));
1673 assert!(!reason.contains("apparmor_parser"));
1674 }
1675
1676 #[test]
1679 fn workdir_exec_alone_refuses_nothing() {
1680 for tier in ALL_TIERS {
1681 let achieved = achieved_containment_class(*tier, true, false);
1682 assert!(
1683 check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
1684 .is_ok(),
1685 "workdir_exec at an advisory floor must launch on tier {tier:?}"
1686 );
1687 }
1688 }
1689
1690 #[test]
1691 fn advisory_floor_is_met_on_every_tier() {
1692 for tier in ALL_TIERS {
1693 let achieved = achieved_class_for_tier(*tier);
1694 assert!(check_containment_floor(
1695 ContainmentClass::Advisory,
1696 achieved,
1697 None,
1698 false,
1699 None
1700 )
1701 .is_ok());
1702 }
1703 }
1704
1705 #[test]
1706 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1707 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1708 let achieved = achieved_class_for_tier(tier);
1709 assert!(
1710 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1711 .is_ok()
1712 );
1713 }
1714
1715 for tier in [
1716 EnforcementTier::KernelSeccompOnly,
1717 EnforcementTier::EnvironmentOnly,
1718 ] {
1719 let achieved = achieved_class_for_tier(tier);
1720 let error =
1721 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1722 .expect_err("scoped must refuse without Landlock");
1723 match error {
1724 RuntimeError::ContainmentFloorUnmet {
1725 declared,
1726 achieved,
1727 reason,
1728 ..
1729 } => {
1730 assert_eq!(declared, ContainmentClass::Scoped);
1731 assert_eq!(achieved, ContainmentClass::Advisory);
1732 assert!(
1733 reason.contains("Landlock"),
1734 "reason must name the missing mechanism, got: {reason}"
1735 );
1736 }
1737 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1738 }
1739 }
1740 }
1741
1742 #[test]
1748 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
1749 let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
1750 assert_eq!(achieved, ContainmentClass::Sealed);
1751 assert!(
1752 check_containment_floor(ContainmentClass::Advisory, achieved, None, false, None)
1753 .is_ok()
1754 );
1755 assert!(
1756 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None).is_ok()
1757 );
1758 assert_eq!(
1759 containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false, None),
1760 None
1761 );
1762 }
1763
1764 #[test]
1765 fn sealed_floor_is_met_only_on_the_sealed_tier() {
1766 assert!(check_containment_floor(
1767 ContainmentClass::Sealed,
1768 achieved_class_for_tier(EnforcementTier::KernelSealed),
1769 None,
1770 false,
1771 None
1772 )
1773 .is_ok());
1774
1775 for tier in ALL_TIERS
1776 .iter()
1777 .filter(|t| **t != EnforcementTier::KernelSealed)
1778 {
1779 let achieved = achieved_class_for_tier(*tier);
1780 let error = check_containment_floor(
1781 ContainmentClass::Sealed,
1782 achieved,
1783 Some(SealedBlocker::NamespaceCreationDenied),
1784 false,
1785 None,
1786 )
1787 .expect_err("sealed must refuse on a host that cannot back it");
1788 match error {
1789 RuntimeError::ContainmentFloorUnmet {
1790 declared, reason, ..
1791 } => {
1792 assert_eq!(declared, ContainmentClass::Sealed);
1793 assert!(
1794 reason.contains("--cap-add SYS_ADMIN"),
1795 "reason must name the blocking mechanism's remediation, got: {reason}"
1796 );
1797 }
1798 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1799 }
1800 }
1801 }
1802
1803 #[test]
1806 fn the_sealed_refusal_names_the_specific_blocker() {
1807 let achieved = ContainmentClass::Scoped;
1808
1809 let apparmor = containment_shortfall_reason(
1810 ContainmentClass::Sealed,
1811 achieved,
1812 Some(SealedBlocker::AppArmorProfileMissing),
1813 false,
1814 None,
1815 )
1816 .expect("refusal");
1817 assert!(apparmor.contains("mur-sealed"));
1818 assert!(apparmor.contains("apparmor_parser -r"));
1819 assert!(!apparmor.contains("--cap-add"));
1820
1821 let container = containment_shortfall_reason(
1822 ContainmentClass::Sealed,
1823 achieved,
1824 Some(SealedBlocker::NamespaceCreationDenied),
1825 false,
1826 None,
1827 )
1828 .expect("refusal");
1829 assert!(container.contains("--cap-add SYS_ADMIN"));
1830 assert!(container.contains("outside the container"));
1831 assert!(!container.contains("apparmor_parser"));
1832
1833 let pivot = containment_shortfall_reason(
1836 ContainmentClass::Sealed,
1837 ContainmentClass::Advisory,
1838 Some(SealedBlocker::PivotRootDenied),
1839 false,
1840 None,
1841 )
1842 .expect("refusal");
1843 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
1844 assert_ne!(
1845 pivot,
1846 containment_shortfall_reason(
1847 ContainmentClass::Sealed,
1848 ContainmentClass::Advisory,
1849 Some(SealedBlocker::MountDenied),
1850 false,
1851 None,
1852 )
1853 .expect("refusal")
1854 );
1855
1856 for blocker in [
1858 SealedBlocker::NotLinux,
1859 SealedBlocker::AppArmorProfileMissing,
1860 SealedBlocker::NamespaceCreationDenied,
1861 SealedBlocker::MountDenied,
1862 SealedBlocker::PivotRootDenied,
1863 SealedBlocker::KernelUnsupported,
1864 SealedBlocker::LandlockUnavailable,
1865 ] {
1866 let reason = containment_shortfall_reason(
1867 ContainmentClass::Sealed,
1868 achieved,
1869 Some(blocker),
1870 false,
1871 None,
1872 )
1873 .expect("refusal");
1874 assert!(
1875 !reason.contains("no host can provide it today"),
1876 "got: {reason}"
1877 );
1878 }
1879 }
1880
1881 #[test]
1884 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
1885 let reason = containment_shortfall_reason(
1886 ContainmentClass::Sealed,
1887 ContainmentClass::Advisory,
1888 None,
1889 false,
1890 None,
1891 )
1892 .expect("refusal");
1893 assert!(reason.contains("pivot_root"));
1894 assert!(reason.contains("CLONE_NEWNS"));
1895 }
1896
1897 #[test]
1898 fn shortfall_reason_is_absent_when_the_floor_is_met() {
1899 assert_eq!(
1900 containment_shortfall_reason(
1901 ContainmentClass::Advisory,
1902 ContainmentClass::Scoped,
1903 None,
1904 false,
1905 None
1906 ),
1907 None
1908 );
1909 assert_eq!(
1910 containment_shortfall_reason(
1911 ContainmentClass::Scoped,
1912 ContainmentClass::Scoped,
1913 None,
1914 false,
1915 None
1916 ),
1917 None
1918 );
1919 assert_eq!(
1920 containment_shortfall_reason(
1921 ContainmentClass::Sealed,
1922 ContainmentClass::Sealed,
1923 Some(SealedBlocker::NamespaceCreationDenied),
1924 false,
1925 None
1926 ),
1927 None,
1928 "a met floor must ignore a stale blocker rather than manufacture a refusal"
1929 );
1930 }
1931
1932 fn sample_policy() -> CapabilityPolicy {
1933 CapabilityPolicy {
1934 network_allow: vec!["https://api.example.com".to_string()],
1935 unix_sockets_allowed: false,
1936 filesystem_scope: Some("workdir".to_string()),
1937 shell_allow: vec!["python3".to_string()],
1938 spawn_allow: vec!["helper".to_string()],
1939 env_allow: vec!["TZ".to_string()],
1940 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
1941 binary: "python3".to_string(),
1942 dirs: vec![InterpreterRuntimeDir {
1943 path: "/usr/lib/python3.11".to_string(),
1944 list_dir: true,
1945 }],
1946 }],
1947 ..CapabilityPolicy::default()
1948 }
1949 }
1950
1951 #[test]
1952 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
1953 let report = scope_report_for_tier(
1954 &sample_policy(),
1955 ContainmentClass::Scoped,
1956 EnforcementTier::KernelFull,
1957 None,
1958 None,
1959 None,
1960 None,
1961 Vec::new(),
1962 Vec::new(),
1963 Vec::new(),
1964 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
1965 IoMaxReport::default(),
1966 );
1967
1968 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
1969 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1970 assert!(report.floor_met);
1971 assert_eq!(report.shortfall_reason, None);
1972 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1973 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
1974 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
1975 assert!(!report.unix_sockets);
1976 assert_eq!(report.shell_allow, vec!["python3"]);
1977 assert_eq!(report.spawn_allow, vec!["helper"]);
1978 assert_eq!(report.env_allow, vec!["TZ"]);
1979 assert!(!report.workdir_exec);
1980 assert_eq!(
1981 report.interpreter_runtime_grants,
1982 vec!["python3: /usr/lib/python3.11 (list_dir)"]
1983 );
1984 }
1985
1986 #[test]
1990 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
1991 let policy = CapabilityPolicy {
1992 shell_allow: vec!["gcc".to_string()],
1993 workdir_exec_allowed: true,
1994 ..CapabilityPolicy::default()
1995 };
1996
1997 let report = scope_report_for_tier(
1998 &policy,
1999 ContainmentClass::Scoped,
2000 EnforcementTier::KernelFull,
2001 None,
2002 None,
2003 None,
2004 None,
2005 Vec::new(),
2006 Vec::new(),
2007 Vec::new(),
2008 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2009 IoMaxReport::default(),
2010 );
2011
2012 assert!(report.workdir_exec);
2013 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2014 assert!(!report.floor_met);
2015 assert_eq!(report.enforcement_tier, "landlock+seccomp");
2018 assert!(report
2019 .shortfall_reason
2020 .as_deref()
2021 .expect("refusal")
2022 .contains("workdir_exec"));
2023
2024 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2025 assert_eq!(value["workdir_exec"], true);
2026
2027 let rendered = report.render();
2028 assert!(rendered.contains("workdir exec: true"));
2029 assert!(rendered.contains("achieved: advisory"));
2030 }
2031
2032 #[test]
2036 fn scope_report_json_always_carries_io_max() {
2037 let unprobed: serde_json::Value = serde_json::to_value(scope_report_for_tier(
2038 &sample_policy(),
2039 ContainmentClass::Scoped,
2040 EnforcementTier::KernelFull,
2041 None,
2042 None,
2043 None,
2044 None,
2045 Vec::new(),
2046 Vec::new(),
2047 Vec::new(),
2048 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2049 IoMaxReport::default(),
2050 ))
2051 .unwrap();
2052 assert_eq!(unprobed["io_max"]["status"], "not-probed");
2053 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
2054 assert!(unprobed["io_max"].get("reason").is_none());
2055
2056 let enforced: serde_json::Value = serde_json::to_value(scope_report_for_tier(
2057 &sample_policy(),
2058 ContainmentClass::Scoped,
2059 EnforcementTier::KernelFull,
2060 None,
2061 None,
2062 None,
2063 None,
2064 Vec::new(),
2065 Vec::new(),
2066 Vec::new(),
2067 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2068 IoMaxReport {
2069 declared_bytes_per_sec: 52_428_800,
2070 status: IoMaxStatus::Enforced,
2071 reason: None,
2072 },
2073 ))
2074 .unwrap();
2075 assert_eq!(enforced["io_max"]["status"], "enforced");
2076 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
2077 }
2078
2079 #[test]
2083 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
2084 let rendered = scope_report_for_tier(
2085 &sample_policy(),
2086 ContainmentClass::Scoped,
2087 EnforcementTier::KernelFull,
2088 None,
2089 None,
2090 None,
2091 None,
2092 Vec::new(),
2093 Vec::new(),
2094 Vec::new(),
2095 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2096 IoMaxReport {
2097 declared_bytes_per_sec: 52_428_800,
2098 status: IoMaxStatus::Unavailable,
2099 reason: Some("the io controller is not delegated".to_string()),
2100 },
2101 )
2102 .render();
2103
2104 assert!(rendered.contains("Resource plane"), "{rendered}");
2105 assert!(rendered.contains("io.max:"), "{rendered}");
2106 assert!(rendered.contains("unavailable"), "{rendered}");
2107 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
2108 assert!(
2109 rendered.contains("the io controller is not delegated"),
2110 "{rendered}"
2111 );
2112 }
2113
2114 #[test]
2117 fn scope_report_json_always_carries_workdir_exec() {
2118 let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
2119 &sample_policy(),
2120 ContainmentClass::Scoped,
2121 EnforcementTier::KernelFull,
2122 None,
2123 None,
2124 None,
2125 None,
2126 Vec::new(),
2127 Vec::new(),
2128 Vec::new(),
2129 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2130 IoMaxReport::default(),
2131 ))
2132 .unwrap();
2133 assert_eq!(value["workdir_exec"], false);
2134 }
2135
2136 #[test]
2140 fn staged_runtime_grants_are_reported_on_every_tier() {
2141 let policy = CapabilityPolicy {
2142 shell_allow: vec!["python3".to_string()],
2143 shell_staged_runtime: vec![StagedRuntimeGrant {
2144 binary: "python3".to_string(),
2145 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
2146 pin: "conda-4.10.3/python-3.9.19".to_string(),
2147 }],
2148 ..CapabilityPolicy::default()
2149 };
2150
2151 for tier in ALL_TIERS {
2152 let report = scope_report_for_tier(
2153 &policy,
2154 ContainmentClass::Sealed,
2155 *tier,
2156 Some(SealedBlocker::NamespaceCreationDenied),
2157 None,
2158 None,
2159 None,
2160 Vec::new(),
2161 Vec::new(),
2162 Vec::new(),
2163 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2164 IoMaxReport::default(),
2165 );
2166 assert_eq!(
2167 report.staged_runtime_grants,
2168 vec![
2169 "python3: /opt/testbed/conda/envs/django__django \
2170 (pin: conda-4.10.3/python-3.9.19)"
2171 ],
2172 "tier {tier:?} must still report the declared staged runtime"
2173 );
2174 assert!(
2175 report.render().contains("staged runtime"),
2176 "the rendered report must carry a staged runtime section on tier {tier:?}"
2177 );
2178 }
2179 }
2180
2181 #[test]
2184 fn absent_staged_runtime_reports_nothing() {
2185 let report = scope_report_for_tier(
2186 &sample_policy(),
2187 ContainmentClass::Scoped,
2188 EnforcementTier::KernelFull,
2189 None,
2190 None,
2191 None,
2192 None,
2193 Vec::new(),
2194 Vec::new(),
2195 Vec::new(),
2196 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2197 IoMaxReport::default(),
2198 );
2199 assert!(report.staged_runtime_grants.is_empty());
2200 assert!(report.render().contains("staged runtime: <none>"));
2201 }
2202
2203 #[test]
2207 fn grant_set_never_raises_the_achieved_class() {
2208 let report = scope_report_for_tier(
2209 &sample_policy(),
2210 ContainmentClass::Sealed,
2211 EnforcementTier::KernelFull,
2212 Some(SealedBlocker::NamespaceCreationDenied),
2213 None,
2214 None,
2215 None,
2216 Vec::new(),
2217 Vec::new(),
2218 Vec::new(),
2219 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2220 IoMaxReport::default(),
2221 );
2222 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2223 assert!(!report.floor_met);
2224 }
2225
2226 #[test]
2227 fn scope_report_still_reports_when_the_floor_is_unmet() {
2228 let report = scope_report_for_tier(
2229 &sample_policy(),
2230 ContainmentClass::Sealed,
2231 EnforcementTier::EnvironmentOnly,
2232 Some(SealedBlocker::NotLinux),
2233 None,
2234 None,
2235 None,
2236 Vec::new(),
2237 Vec::new(),
2238 Vec::new(),
2239 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2240 IoMaxReport::default(),
2241 );
2242
2243 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
2244 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2245 assert!(!report.floor_met);
2246 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
2247 assert_eq!(report.enforcement_tier, "none");
2248 }
2249
2250 #[test]
2251 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
2252 let report = scope_report_for_tier(
2253 &sample_policy(),
2254 ContainmentClass::Sealed,
2255 EnforcementTier::KernelSealed,
2256 None,
2257 None,
2258 None,
2259 None,
2260 Vec::new(),
2261 Vec::new(),
2262 Vec::new(),
2263 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2264 IoMaxReport::default(),
2265 );
2266 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2267 assert!(report.floor_met);
2268 assert_eq!(
2269 report.enforcement_tier,
2270 "mountns+pivot_root+landlock+seccomp"
2271 );
2272 }
2273
2274 #[test]
2275 fn scope_report_json_uses_wire_names() {
2276 let report = scope_report_for_tier(
2277 &CapabilityPolicy::default(),
2278 ContainmentClass::Advisory,
2279 EnforcementTier::KernelSeccompOnly,
2280 None,
2281 None,
2282 None,
2283 None,
2284 Vec::new(),
2285 Vec::new(),
2286 Vec::new(),
2287 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2288 IoMaxReport::default(),
2289 );
2290 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2291
2292 assert_eq!(value["declared_containment"], "advisory");
2293 assert_eq!(value["achieved_containment"], "advisory");
2294 assert_eq!(value["floor_met"], true);
2295 assert_eq!(value["enforcement_tier"], "seccomp-only");
2296 assert!(value.get("shortfall_reason").is_none());
2298 }
2299
2300 #[test]
2303 fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
2304 let report = |tier, kernel| {
2305 scope_report_for_tier(
2306 &sample_policy(),
2307 ContainmentClass::Scoped,
2308 tier,
2309 None,
2310 None,
2311 kernel,
2312 None,
2313 Vec::new(),
2314 Vec::new(),
2315 Vec::new(),
2316 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2317 IoMaxReport::default(),
2318 )
2319 };
2320
2321 for (tier, kernel, expected) in [
2322 (EnforcementTier::KernelFull, Some(8), Some(3)),
2323 (EnforcementTier::KernelSealed, Some(8), Some(3)),
2324 (EnforcementTier::KernelFull, Some(2), Some(2)),
2325 (EnforcementTier::KernelFull, Some(1), Some(1)),
2326 (EnforcementTier::KernelFull, None, None),
2327 (EnforcementTier::KernelSeccompOnly, Some(8), None),
2328 (EnforcementTier::EnvironmentOnly, None, None),
2329 ] {
2330 let scope = report(tier, kernel);
2331 assert_eq!(
2332 scope.landlock_abi, expected,
2333 "{tier:?} with kernel {kernel:?}"
2334 );
2335
2336 let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
2337 assert!(value.as_object().unwrap().contains_key("landlock_abi"));
2338 match expected {
2339 Some(abi) => assert_eq!(value["landlock_abi"], abi),
2340 None => assert!(value["landlock_abi"].is_null()),
2341 }
2342
2343 let rendered = scope.render();
2344 let line = match expected {
2345 Some(abi) => format!(" landlock ABI: {abi}\n"),
2346 None => " landlock ABI: n/a\n".to_string(),
2347 };
2348 assert!(rendered.contains(&line), "{rendered}");
2349 assert!(
2350 rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
2351 );
2352 }
2353 }
2354
2355 #[test]
2358 fn the_userns_grant_is_reported_without_changing_any_verdict() {
2359 let baseline = scope_report_for_tier(
2360 &sample_policy(),
2361 ContainmentClass::Advisory,
2362 EnforcementTier::KernelSealed,
2363 None,
2364 None,
2365 None,
2366 None,
2367 Vec::new(),
2368 Vec::new(),
2369 Vec::new(),
2370 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2371 IoMaxReport::default(),
2372 );
2373
2374 for grant in UsernsGrant::ALL {
2375 let report = scope_report_for_tier(
2376 &sample_policy(),
2377 ContainmentClass::Advisory,
2378 EnforcementTier::KernelSealed,
2379 None,
2380 Some(*grant),
2381 None,
2382 None,
2383 Vec::new(),
2384 Vec::new(),
2385 Vec::new(),
2386 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2387 IoMaxReport::default(),
2388 );
2389 assert_eq!(report.achieved_containment, baseline.achieved_containment);
2390 assert_eq!(report.floor_met, baseline.floor_met);
2391 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
2392 assert_eq!(report.userns_grant, Some(*grant));
2393
2394 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2395 assert_eq!(value["userns_grant"], grant.wire_name());
2396 assert!(report
2397 .render()
2398 .contains(&format!("userns grant: {}", grant.wire_name())));
2399 }
2400
2401 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
2404 assert!(value["userns_grant"].is_null());
2405 assert!(baseline.render().contains("userns grant: n/a"));
2406 }
2407
2408 #[test]
2409 fn rendered_report_names_both_classes_and_the_refusal() {
2410 let rendered = scope_report_for_tier(
2411 &sample_policy(),
2412 ContainmentClass::Sealed,
2413 EnforcementTier::KernelFull,
2414 Some(SealedBlocker::AppArmorProfileMissing),
2415 None,
2416 None,
2417 None,
2418 Vec::new(),
2419 Vec::new(),
2420 Vec::new(),
2421 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2422 IoMaxReport::default(),
2423 )
2424 .render();
2425
2426 assert!(rendered.contains("declared: sealed"));
2427 assert!(rendered.contains("achieved: scoped"));
2428 assert!(rendered.contains("floor met: no"));
2429 assert!(rendered.contains("mur-sealed"));
2430 assert!(rendered.contains("would refuse to launch"));
2431 }
2432
2433 #[test]
2437 fn an_export_never_changes_what_the_report_says_about_containment() {
2438 let export = FileExport {
2439 root: "out/".to_string(),
2440 mode: ExportMode::ReadOnly,
2441 max_bytes: 10 * 1024 * 1024,
2442 };
2443 for tier in ALL_TIERS {
2444 for workdir_exec in [false, true] {
2445 let policy = CapabilityPolicy {
2446 workdir_exec_allowed: workdir_exec,
2447 ..sample_policy()
2448 };
2449 let without = scope_report_for_tier(
2450 &policy,
2451 ContainmentClass::Scoped,
2452 *tier,
2453 None,
2454 None,
2455 None,
2456 None,
2457 Vec::new(),
2458 Vec::new(),
2459 Vec::new(),
2460 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2461 IoMaxReport::default(),
2462 );
2463 let with = scope_report_for_tier(
2464 &policy,
2465 ContainmentClass::Scoped,
2466 *tier,
2467 None,
2468 None,
2469 None,
2470 Some(&Exports {
2471 files: Some(export.clone()),
2472 peer_files: None,
2473 peer_tasks: None,
2474 }),
2475 Vec::new(),
2476 Vec::new(),
2477 Vec::new(),
2478 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2479 IoMaxReport::default(),
2480 );
2481 assert_eq!(
2482 with.achieved_containment, without.achieved_containment,
2483 "tier {tier:?}, workdir_exec {workdir_exec}"
2484 );
2485 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2486 assert_eq!(with.floor_met, without.floor_met);
2487 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2488 assert_eq!(
2491 ScopeReport {
2492 exports_files: None,
2493 ..with.clone()
2494 },
2495 without
2496 );
2497 assert_eq!(
2498 with.exports_files,
2499 Some(ExportsFilesReport {
2500 root: "out/".to_string(),
2501 mode: ExportMode::ReadOnly,
2502 max_bytes: 10 * 1024 * 1024,
2503 })
2504 );
2505 }
2506 }
2507 }
2508
2509 #[test]
2513 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
2514 let peer_files = PeerFilesExport {
2515 root: "out/".to_string(),
2516 max_ttl_secs: Some(900),
2517 max_bytes: 10 * 1024 * 1024,
2518 };
2519 for tier in ALL_TIERS {
2520 for workdir_exec in [false, true] {
2521 let without_policy = CapabilityPolicy {
2522 workdir_exec_allowed: workdir_exec,
2523 ..sample_policy()
2524 };
2525 let with_policy = CapabilityPolicy {
2526 peer_fetch_allow: vec!["localhost:41234".to_string()],
2527 ..without_policy.clone()
2528 };
2529 let without = scope_report_for_tier(
2530 &without_policy,
2531 ContainmentClass::Scoped,
2532 *tier,
2533 None,
2534 None,
2535 None,
2536 None,
2537 Vec::new(),
2538 Vec::new(),
2539 Vec::new(),
2540 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2541 IoMaxReport::default(),
2542 );
2543 let with = scope_report_for_tier(
2544 &with_policy,
2545 ContainmentClass::Scoped,
2546 *tier,
2547 None,
2548 None,
2549 None,
2550 Some(&Exports {
2551 files: None,
2552 peer_files: Some(peer_files.clone()),
2553 peer_tasks: None,
2554 }),
2555 Vec::new(),
2556 Vec::new(),
2557 Vec::new(),
2558 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2559 IoMaxReport::default(),
2560 );
2561 assert_eq!(
2562 with.achieved_containment, without.achieved_containment,
2563 "tier {tier:?}, workdir_exec {workdir_exec}"
2564 );
2565 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2566 assert_eq!(with.floor_met, without.floor_met);
2567 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2568 assert_eq!(
2569 ScopeReport {
2570 peer_files: None,
2571 peer_fetch_allow: Vec::new(),
2572 ..with.clone()
2573 },
2574 without
2575 );
2576 assert_eq!(
2577 with.peer_files,
2578 Some(PeerFilesReport {
2579 root: "out/".to_string(),
2580 max_ttl_secs: 900,
2581 max_bytes: 10 * 1024 * 1024,
2582 })
2583 );
2584 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
2585 assert_eq!(with.network_allow, without.network_allow);
2589 }
2590 }
2591 }
2592
2593 #[test]
2597 fn the_peer_fields_are_always_serialized() {
2598 let undeclared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
2599 &sample_policy(),
2600 ContainmentClass::Advisory,
2601 EnforcementTier::KernelSealed,
2602 None,
2603 None,
2604 None,
2605 None,
2606 Vec::new(),
2607 Vec::new(),
2608 Vec::new(),
2609 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2610 IoMaxReport::default(),
2611 ))
2612 .unwrap();
2613 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
2614 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
2615
2616 let declared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
2617 &CapabilityPolicy {
2618 peer_fetch_allow: vec!["localhost:41234".to_string()],
2619 ..sample_policy()
2620 },
2621 ContainmentClass::Advisory,
2622 EnforcementTier::KernelSealed,
2623 None,
2624 None,
2625 None,
2626 Some(&Exports {
2627 files: None,
2628 peer_files: Some(PeerFilesExport {
2629 root: "out/".to_string(),
2630 max_ttl_secs: None,
2631 max_bytes: 10 * 1024 * 1024,
2632 }),
2633 peer_tasks: None,
2634 }),
2635 Vec::new(),
2636 Vec::new(),
2637 Vec::new(),
2638 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2639 IoMaxReport::default(),
2640 ))
2641 .unwrap();
2642 assert_eq!(
2643 declared["peer_files"],
2644 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
2645 );
2646 assert_eq!(
2647 declared["peer_fetch_allow"],
2648 serde_json::json!(["localhost:41234"])
2649 );
2650 }
2651
2652 #[test]
2653 fn render_names_peer_handoff_in_both_directions() {
2654 let undeclared = scope_report_for_tier(
2655 &sample_policy(),
2656 ContainmentClass::Advisory,
2657 EnforcementTier::KernelSealed,
2658 None,
2659 None,
2660 None,
2661 None,
2662 Vec::new(),
2663 Vec::new(),
2664 Vec::new(),
2665 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2666 IoMaxReport::default(),
2667 )
2668 .render();
2669 assert!(undeclared.contains("Peer handoff"));
2670 assert!(undeclared.contains("exports.peer_files: <none>"));
2671 assert!(undeclared.contains("peer_fetch allow: <none>"));
2672
2673 let declared = scope_report_for_tier(
2674 &CapabilityPolicy {
2675 peer_fetch_allow: vec!["localhost:41234".to_string()],
2676 ..sample_policy()
2677 },
2678 ContainmentClass::Advisory,
2679 EnforcementTier::KernelSealed,
2680 None,
2681 None,
2682 None,
2683 Some(&Exports {
2684 files: None,
2685 peer_files: Some(PeerFilesExport {
2686 root: "out/".to_string(),
2687 max_ttl_secs: Some(900),
2688 max_bytes: 4096,
2689 }),
2690 peer_tasks: None,
2691 }),
2692 Vec::new(),
2693 Vec::new(),
2694 Vec::new(),
2695 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2696 IoMaxReport::default(),
2697 )
2698 .render();
2699 assert!(declared.contains("exports.peer_files root: out/"));
2700 assert!(declared.contains("max handle ttl: 900s"));
2701 assert!(declared.contains("max bytes: 4096 (per file)"));
2702 assert!(declared.contains("- localhost:41234"));
2703 }
2704
2705 fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
2706 scope_report_for_tier(
2707 &sample_policy(),
2708 ContainmentClass::Scoped,
2709 tier,
2710 None,
2711 None,
2712 None,
2713 peer_tasks
2714 .map(|accept| Exports {
2715 files: None,
2716 peer_files: None,
2717 peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
2718 })
2719 .as_ref(),
2720 Vec::new(),
2721 Vec::new(),
2722 Vec::new(),
2723 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2724 IoMaxReport::default(),
2725 )
2726 }
2727
2728 #[test]
2731 fn peer_tasks_is_reported_and_moves_no_other_field() {
2732 for tier in ALL_TIERS {
2733 let absent = report_with_peer_tasks(*tier, None);
2734 let refused = report_with_peer_tasks(*tier, Some(false));
2735 let accepted = report_with_peer_tasks(*tier, Some(true));
2736 assert!(!absent.peer_tasks);
2737 assert_eq!(refused, absent, "tier {tier:?}");
2738 assert!(accepted.peer_tasks);
2739 assert_eq!(accepted.achieved_containment, absent.achieved_containment);
2740 assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
2741 assert_eq!(accepted.floor_met, absent.floor_met);
2742 assert_eq!(
2743 ScopeReport {
2744 peer_tasks: false,
2745 ..accepted.clone()
2746 },
2747 absent
2748 );
2749 }
2750 }
2751
2752 #[test]
2754 fn peer_tasks_is_always_serialized() {
2755 let tier = EnforcementTier::KernelFull;
2756 let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
2757 assert_eq!(absent["peer_tasks"], serde_json::json!(false));
2758 let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
2759 assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
2760 }
2761
2762 #[test]
2763 fn render_names_the_peer_task_posture_under_peer_handoff() {
2764 let tier = EnforcementTier::KernelFull;
2765 for (declared, line) in [
2766 (None, " exports.peer_tasks: refuse\n"),
2767 (Some(false), " exports.peer_tasks: refuse\n"),
2768 (Some(true), " exports.peer_tasks: accept\n"),
2769 ] {
2770 let rendered = report_with_peer_tasks(tier, declared).render();
2771 let section = rendered
2772 .split("\nPeer handoff\n")
2773 .nth(1)
2774 .and_then(|rest| rest.split("\n\n").next())
2775 .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
2776 assert!(
2777 section.contains(line.trim_start_matches(' ').trim_end()),
2778 "{declared:?}:\n{section}"
2779 );
2780 assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
2781 }
2782 }
2783
2784 #[test]
2788 fn a_state_store_is_reported_and_moves_no_other_field() {
2789 let policy = sample_policy();
2790 let stores = vec![StateStoreReport {
2791 artifact: "murmur/tool-corpus".to_string(),
2792 store: "shey".to_string(),
2793 host_path: "/home/dev/.murmur/state/shey".to_string(),
2794 }];
2795
2796 let without = scope_report_for_tier(
2797 &policy,
2798 ContainmentClass::Scoped,
2799 EnforcementTier::KernelFull,
2800 None,
2801 None,
2802 None,
2803 None,
2804 Vec::new(),
2805 Vec::new(),
2806 Vec::new(),
2807 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2808 IoMaxReport::default(),
2809 );
2810 let with = scope_report_for_tier(
2811 &policy,
2812 ContainmentClass::Scoped,
2813 EnforcementTier::KernelFull,
2814 None,
2815 None,
2816 None,
2817 None,
2818 stores.clone(),
2819 Vec::new(),
2820 Vec::new(),
2821 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2822 IoMaxReport::default(),
2823 );
2824
2825 assert_eq!(with.state_stores, stores);
2826 assert!(without.state_stores.is_empty());
2827 assert_eq!(with.achieved_containment, without.achieved_containment);
2828 assert_eq!(with.floor_met, without.floor_met);
2829 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2830 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2831 assert_eq!(
2833 ScopeReport {
2834 state_stores: Vec::new(),
2835 ..with.clone()
2836 },
2837 without
2838 );
2839
2840 assert!(with
2842 .render()
2843 .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
2844 assert!(without.render().contains("state stores: <none>"));
2845
2846 let json = serde_json::to_value(&without).unwrap();
2849 assert_eq!(json["state_stores"], serde_json::json!([]));
2850 assert_eq!(
2851 serde_json::to_value(&with).unwrap()["state_stores"],
2852 serde_json::json!([{
2853 "artifact": "murmur/tool-corpus",
2854 "store": "shey",
2855 "host_path": "/home/dev/.murmur/state/shey",
2856 }])
2857 );
2858 }
2859
2860 #[test]
2865 fn preopens_are_reported_and_move_no_other_field() {
2866 let policy = sample_policy();
2867 let preopens = vec![
2868 PreopenReport {
2869 artifact: "notes-tool".to_string(),
2870 role: "tool".to_string(),
2871 scope: None,
2872 surface: PreopenSurface::WholeWorkdir,
2873 runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
2874 },
2875 PreopenReport {
2876 artifact: "cache-tool".to_string(),
2877 role: "tool".to_string(),
2878 scope: Some("cache".to_string()),
2879 surface: PreopenSurface::ScopedSubtree,
2880 runtime_dir_reach: None,
2881 },
2882 PreopenReport {
2883 artifact: "telemetry-hook".to_string(),
2884 role: "hook".to_string(),
2885 scope: None,
2886 surface: PreopenSurface::Nothing,
2887 runtime_dir_reach: None,
2888 },
2889 ];
2890
2891 let without = scope_report_for_tier(
2892 &policy,
2893 ContainmentClass::Scoped,
2894 EnforcementTier::KernelFull,
2895 None,
2896 None,
2897 None,
2898 None,
2899 Vec::new(),
2900 Vec::new(),
2901 Vec::new(),
2902 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2903 IoMaxReport::default(),
2904 );
2905 let with = scope_report_for_tier(
2906 &policy,
2907 ContainmentClass::Scoped,
2908 EnforcementTier::KernelFull,
2909 None,
2910 None,
2911 None,
2912 None,
2913 Vec::new(),
2914 Vec::new(),
2915 preopens.clone(),
2916 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2917 IoMaxReport::default(),
2918 );
2919
2920 assert_eq!(with.preopens, preopens);
2921 assert!(without.preopens.is_empty());
2922 assert_eq!(
2924 ScopeReport {
2925 preopens: Vec::new(),
2926 ..with.clone()
2927 },
2928 without
2929 );
2930
2931 let rendered = with.render();
2933 assert!(rendered.contains(
2934 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
2935 declared; runtime directory read-only at murmur-runtime/"
2936 ));
2937 assert!(rendered.contains(
2938 "- cache-tool (tool): one subtree of the accessible workdir — \
2939 capabilities.filesystem.scope: cache"
2940 ));
2941 assert!(rendered.contains(
2942 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
2943 ));
2944 assert!(without.render().contains("preopens: <none>"));
2945
2946 assert_eq!(
2948 serde_json::to_value(&without).unwrap()["preopens"],
2949 serde_json::json!([])
2950 );
2951 assert_eq!(
2952 serde_json::to_value(&with).unwrap()["preopens"],
2953 serde_json::json!([
2954 {
2955 "artifact": "notes-tool",
2956 "role": "tool",
2957 "scope": null,
2958 "surface": "whole-workdir",
2959 "runtime_dir_reach": "murmur-runtime",
2960 },
2961 {
2962 "artifact": "cache-tool",
2963 "role": "tool",
2964 "scope": "cache",
2965 "surface": "scoped-subtree",
2966 "runtime_dir_reach": null,
2967 },
2968 {
2969 "artifact": "telemetry-hook",
2970 "role": "hook",
2971 "scope": null,
2972 "surface": "nothing",
2973 "runtime_dir_reach": null,
2974 },
2975 ])
2976 );
2977 }
2978
2979 #[test]
2983 fn exports_files_is_always_serialized() {
2984 let undeclared = serde_json::to_value(scope_report_for_tier(
2985 &sample_policy(),
2986 ContainmentClass::Advisory,
2987 EnforcementTier::KernelFull,
2988 None,
2989 None,
2990 None,
2991 None,
2992 Vec::new(),
2993 Vec::new(),
2994 Vec::new(),
2995 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2996 IoMaxReport::default(),
2997 ))
2998 .unwrap();
2999 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
3000
3001 let declared = serde_json::to_value(scope_report_for_tier(
3002 &sample_policy(),
3003 ContainmentClass::Advisory,
3004 EnforcementTier::KernelFull,
3005 None,
3006 None,
3007 None,
3008 Some(&Exports {
3009 files: Some(FileExport {
3010 root: "out/".to_string(),
3011 mode: ExportMode::ReadOnly,
3012 max_bytes: 10_485_760,
3013 }),
3014 peer_files: None,
3015 peer_tasks: None,
3016 }),
3017 Vec::new(),
3018 Vec::new(),
3019 Vec::new(),
3020 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3021 IoMaxReport::default(),
3022 ))
3023 .unwrap();
3024 assert_eq!(
3025 declared["exports_files"],
3026 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
3027 );
3028 }
3029
3030 #[test]
3031 fn render_names_the_resource_plane_in_both_directions() {
3032 let undeclared = scope_report_for_tier(
3033 &sample_policy(),
3034 ContainmentClass::Advisory,
3035 EnforcementTier::KernelFull,
3036 None,
3037 None,
3038 None,
3039 None,
3040 Vec::new(),
3041 Vec::new(),
3042 Vec::new(),
3043 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3044 IoMaxReport::default(),
3045 )
3046 .render();
3047 assert!(
3048 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
3049 "{undeclared}"
3050 );
3051
3052 let declared = scope_report_for_tier(
3053 &sample_policy(),
3054 ContainmentClass::Advisory,
3055 EnforcementTier::KernelFull,
3056 None,
3057 None,
3058 None,
3059 Some(&Exports {
3060 files: Some(FileExport {
3061 root: "out/".to_string(),
3062 mode: ExportMode::ReadOnly,
3063 max_bytes: 10_485_760,
3064 }),
3065 peer_files: None,
3066 peer_tasks: None,
3067 }),
3068 Vec::new(),
3069 Vec::new(),
3070 Vec::new(),
3071 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3072 IoMaxReport::default(),
3073 )
3074 .render();
3075 assert!(declared.contains("exports.files root: out/"), "{declared}");
3076 assert!(
3077 declared.contains("mode: read-only"),
3078 "{declared}"
3079 );
3080 assert!(
3081 declared.contains("max bytes: 10485760 (per file)"),
3082 "{declared}"
3083 );
3084 }
3085
3086 fn report_for(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
3089 let policy = CapabilityPolicy {
3090 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
3091 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
3092 ..CapabilityPolicy::default()
3093 };
3094 scope_report_for_tier(
3095 &policy,
3096 ContainmentClass::Scoped,
3097 EnforcementTier::KernelFull,
3098 None,
3099 None,
3100 None,
3101 None,
3102 Vec::new(),
3103 Vec::new(),
3104 Vec::new(),
3105 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3106 IoMaxReport::default(),
3107 )
3108 }
3109
3110 #[test]
3113 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
3114 let report = report_for(&["tests", "bench/fixtures"], &["git"]);
3115 assert_eq!(
3116 report.read_only_paths,
3117 vec!["tests".to_string(), "bench/fixtures".to_string()]
3118 );
3119 assert!(report.read_only_advisory_for.is_empty());
3120
3121 let rendered = report.render();
3122 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
3123 assert!(rendered.contains(
3124 "read_only enforcement: enforced for every tool call and every shell command the \
3125 dispatch check can read"
3126 ));
3127 assert!(!rendered.contains("advisory against"));
3128 }
3129
3130 #[test]
3133 fn an_allowlisted_interpreter_is_named_as_advisory() {
3134 let report = report_for(&["tests"], &["git", "python3"]);
3135 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
3136
3137 let rendered = report.render();
3138 assert!(rendered.contains("read_only enforcement: advisory against python3"));
3139 assert!(rendered
3140 .contains("enforced for every tool call and for every shell command it can read"));
3141 }
3142
3143 #[test]
3147 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
3148 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
3149 .iter()
3150 .map(|b| (*b).to_string())
3151 .collect();
3152 let declared = vec!["tests".to_string()];
3153 assert_eq!(
3154 report_for(&["tests"], &["git", "python3", "node", "make", "bash"])
3155 .read_only_advisory_for,
3156 read_only_advisory_for(&declared, &shell_allow)
3157 );
3158 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
3160 }
3161
3162 #[test]
3166 fn both_read_only_keys_are_always_serialized_arrays() {
3167 let json = serde_json::to_value(report_for(&[], &["python3"])).unwrap();
3168 assert_eq!(json["read_only_paths"], serde_json::json!([]));
3169 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
3170
3171 let json = serde_json::to_value(report_for(&["tests"], &["python3"])).unwrap();
3172 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
3173 assert_eq!(
3174 json["read_only_advisory_for"],
3175 serde_json::json!(["python3"])
3176 );
3177 }
3178
3179 #[test]
3182 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
3183 let rendered = report_for(&[], &["python3"]).render();
3184 assert!(rendered.contains(" read_only: <none>\n"));
3185 assert!(!rendered.contains("read_only enforcement:"));
3186 }
3187
3188 #[test]
3200 fn every_capability_policy_field_is_reported_or_exempted() {
3201 const EXEMPT: &[(&str, &str)] = &[
3203 (
3204 "shell_strip_env",
3205 "Names host variables removed from the shell child's environment on top of the \
3206 credential patterns the runtime always strips. It subtracts from what the \
3207 capsule sees and can open nothing, so reporting it beside the grants would pad \
3208 the list an operator audits for reach.",
3209 ),
3210 (
3211 "shell_baseline_env",
3212 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
3213 environment of a subprocess the capsule was already granted, and names no host \
3214 resource `shell allow` and `env allow` do not already account for.",
3215 ),
3216 (
3217 "limits",
3218 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
3219 deadline. A bound on how much the capsule may consume, never a reach it gains.",
3220 ),
3221 (
3222 "resources",
3223 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
3224 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
3225 ),
3226 (
3227 "declared_deadline_seconds",
3228 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
3229 can apply a lower default. Reporting it would report one ceiling twice.",
3230 ),
3231 (
3232 "state_declared",
3233 "Records that a capsule-wide `capabilities.state` block was declared where \
3234 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
3235 exist are reported per artifact in `state_stores`.",
3236 ),
3237 (
3238 "conversation_declared",
3239 "Records a capsule-wide `capabilities.conversation` block that reaches no \
3240 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
3241 and the grant itself is per artifact.",
3242 ),
3243 ];
3244
3245 const REPORTED: &[(&str, &str)] = &[
3247 ("network_allow", "network_allow"),
3248 ("peer_fetch_allow", "peer_fetch_allow"),
3249 ("unix_sockets_allowed", "unix_sockets"),
3250 ("filesystem_scope", "filesystem_scope"),
3251 ("workdir_exec_allowed", "workdir_exec"),
3252 ("read_only_paths", "read_only_paths"),
3253 ("shell_allow", "shell_allow"),
3254 ("spawn_allow", "spawn_allow"),
3255 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
3256 ("shell_staged_runtime", "staged_runtime_grants"),
3257 ("env_allow", "env_allow"),
3258 ("plan_submit", "plan_submit"),
3259 ("install_skill", "install_skill"),
3260 ("install_tool", "install_tool"),
3261 ("install_from_sources", "install_from"),
3262 ("containment_floor", "declared_containment"),
3263 ];
3264
3265 let declared = capability_policy_field_names(include_str!("types.rs"));
3266 assert!(
3267 declared.len() > 10,
3268 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
3269 );
3270
3271 for field in &declared {
3272 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
3273 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
3274 assert!(
3275 reported || exempt,
3276 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
3277 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
3278 with a written reason for why an operator does not need to see it."
3279 );
3280 assert!(
3281 !(reported && exempt),
3282 "CapabilityPolicy::{field} is listed as both reported and exempt"
3283 );
3284 }
3285
3286 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
3287 assert!(
3288 declared.iter().any(|field| field == policy_field),
3289 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
3290 declares — remove the stale row"
3291 );
3292 }
3293
3294 let json = serde_json::to_value(scope_report_for_tier(
3297 &sample_policy(),
3298 ContainmentClass::Scoped,
3299 EnforcementTier::KernelFull,
3300 None,
3301 None,
3302 None,
3303 None,
3304 Vec::new(),
3305 Vec::new(),
3306 Vec::new(),
3307 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3308 IoMaxReport::default(),
3309 ))
3310 .unwrap();
3311 let object = json.as_object().expect("a scope report is a JSON object");
3312 for (policy_field, report_key) in REPORTED {
3313 assert!(
3314 object.contains_key(*report_key),
3315 "the table maps CapabilityPolicy::{policy_field} to a report key \
3316 '{report_key}' that a serialized ScopeReport does not carry"
3317 );
3318 }
3319 }
3320
3321 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
3324 scope_report_for_tier(
3325 &sample_policy(),
3326 declared,
3327 tier,
3328 None,
3329 None,
3330 Some(6),
3333 None,
3334 Vec::new(),
3335 Vec::new(),
3336 Vec::new(),
3337 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3338 IoMaxReport::default(),
3339 )
3340 }
3341
3342 #[test]
3345 fn the_mechanism_names_every_subprocess_route_it_covers() {
3346 for tier in [
3347 EnforcementTier::KernelSealed,
3348 EnforcementTier::KernelFull,
3349 EnforcementTier::KernelSeccompOnly,
3350 EnforcementTier::EnvironmentOnly,
3351 ] {
3352 let report = boundary_report(ContainmentClass::Advisory, tier);
3353 assert_eq!(
3354 report.mechanism_covers,
3355 vec!["shell", "plan-shell-steps", "native-tools"]
3356 );
3357
3358 let rendered = report.render();
3359 let mechanism = format!(" mechanism: {}\n", report.enforcement_tier);
3360 let covers = " covers: shell commands, plan shell steps, native tools\n";
3361 assert!(
3362 rendered.contains(&format!("{mechanism}{covers}")),
3363 "covers: must follow mechanism: directly:\n{rendered}"
3364 );
3365
3366 let json = serde_json::to_string(&report).unwrap();
3367 assert!(
3368 json.contains(r#""mechanism_covers":["shell","plan-shell-steps","native-tools"]"#),
3369 "{json}"
3370 );
3371 }
3372 }
3373
3374 #[test]
3376 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
3377 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
3378
3379 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3380 assert_eq!(
3381 report.filesystem_boundary.restriction,
3382 FilesystemRestriction::Advisory
3383 );
3384 assert_eq!(report.filesystem_boundary.not_protected.len(), 4);
3387 assert_eq!(
3388 Some(&report.filesystem_boundary.not_protected[2]),
3389 report.runtime_dir.not_protected_because.as_ref()
3390 );
3391 assert_eq!(
3392 Some(&report.filesystem_boundary.not_protected[3]),
3393 report.records_dir.not_protected_because.as_ref()
3394 );
3395 assert!(report.filesystem_boundary.not_protected[3].contains("the records directory"));
3396
3397 let filesystem = &report.filesystem_boundary.not_protected[0];
3398 assert!(filesystem.contains("advisory"), "{filesystem}");
3399 assert!(
3400 filesystem.contains("denies a read or a write outside the session workdir"),
3401 "{filesystem}"
3402 );
3403
3404 let rewriting = &report.filesystem_boundary.not_protected[1];
3405 assert!(rewriting.contains("not a boundary"), "{rewriting}");
3406 assert!(rewriting.contains("HOME"), "{rewriting}");
3407
3408 let rendered = report.render();
3409 let heading = rendered
3410 .find("\nNot protected here\n")
3411 .expect("a session that composes no root prints the block");
3412 assert!(
3413 heading > rendered.find("Containment\n").unwrap(),
3414 "the block belongs under Containment:\n{rendered}"
3415 );
3416 assert!(
3417 heading < rendered.find("\nEffective grants\n").unwrap(),
3418 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
3419 );
3420 for statement in &report.filesystem_boundary.not_protected {
3421 assert!(rendered.contains(statement.as_str()), "{rendered}");
3422 }
3423 }
3424
3425 fn inside_placement() -> RuntimeDirPlacement {
3426 RuntimeDirPlacement {
3427 sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
3428 records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
3429 workdir: std::path::PathBuf::from("/home/dev"),
3430 sessions_inside_workdir: true,
3431 records_inside_workdir: true,
3432 inside_workdir: true,
3433 }
3434 }
3435
3436 #[test]
3438 fn runtime_dir_report_makes_one_claim_per_tier() {
3439 let outside = RuntimeDirPlacement::outside_for_test();
3440 for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
3441 let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
3442 assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
3443 assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
3444 assert_eq!(sealed.not_protected_because, None);
3445 }
3446
3447 let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
3448 assert!(full.read_only_to_commands);
3449 assert_eq!(full.enforced_by, Some("landlock"));
3450 assert_eq!(full.not_protected_because, None);
3451
3452 for abi in [Some(1), Some(2)] {
3453 let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
3454 assert!(!old.read_only_to_commands);
3455 assert_eq!(old.enforced_by, None);
3456 let reason = old.not_protected_because.unwrap();
3457 assert!(
3458 reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
3459 && reason.contains("truncate(2)")
3460 && reason.contains("6.2"),
3461 "{reason}"
3462 );
3463 }
3464
3465 let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
3466 assert!(!seccomp.read_only_to_commands);
3467 assert!(seccomp
3468 .not_protected_because
3469 .unwrap()
3470 .contains("no Landlock"));
3471
3472 let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
3473 assert!(!none.read_only_to_commands);
3474 let reason = none.not_protected_because.unwrap();
3475 assert!(
3476 reason.contains("no kernel containment") && reason.contains("macOS"),
3477 "{reason}"
3478 );
3479
3480 assert_eq!(
3481 full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
3482 "the path carries the literal session-id segment"
3483 );
3484 }
3485
3486 #[test]
3488 fn runtime_dir_report_inside_the_workdir_is_never_protected() {
3489 for tier in ALL_TIERS {
3490 let report = runtime_dir_report(*tier, Some(6), &inside_placement());
3491 assert!(!report.read_only_to_commands, "{tier:?}");
3492 assert_eq!(report.enforced_by, None);
3493 }
3494 let reason =
3495 runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
3496 .not_protected_because
3497 .unwrap();
3498 assert!(
3499 reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
3500 "{reason}"
3501 );
3502 assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
3503 }
3504
3505 #[test]
3508 fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
3509 let inside = inside_placement();
3510 assert_eq!(
3511 achieved_containment_class(EnforcementTier::KernelSealed, false, true),
3512 ContainmentClass::Advisory
3513 );
3514 assert_eq!(
3515 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
3516 ContainmentClass::Sealed
3517 );
3518 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
3519 match check_containment_floor(
3520 declared,
3521 ContainmentClass::Advisory,
3522 None,
3523 false,
3524 inside.inside(),
3525 ) {
3526 Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
3527 assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
3528 assert!(reason.contains("the workdir /home/dev"), "{reason}");
3529 assert!(
3530 reason.contains("run with a --workdir that does not contain ~/.murmur"),
3531 "{reason}"
3532 );
3533 }
3534 other => panic!("{declared:?}: {other:?}"),
3535 }
3536 }
3537 assert!(check_containment_floor(
3538 ContainmentClass::Advisory,
3539 ContainmentClass::Advisory,
3540 None,
3541 false,
3542 inside.inside()
3543 )
3544 .is_ok());
3545
3546 let report = scope_report_for_tier(
3547 &sample_policy(),
3548 ContainmentClass::Advisory,
3549 EnforcementTier::KernelFull,
3550 None,
3551 None,
3552 Some(6),
3553 None,
3554 Vec::new(),
3555 Vec::new(),
3556 Vec::new(),
3557 &inside,
3558 IoMaxReport::default(),
3559 );
3560 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3561 let reason = report.runtime_dir.not_protected_because.clone().unwrap();
3562 assert!(report.filesystem_boundary.not_protected.contains(&reason));
3563 let rendered = report.render();
3564 assert!(
3565 rendered.contains(&format!(" commands: can change it — {reason}\n")),
3566 "{rendered}"
3567 );
3568 }
3569
3570 #[test]
3572 fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
3573 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
3574 let rendered = report.render();
3575 let block =
3576 "Runtime directory\n path: /home/test/.murmur/sessions/<session-id>/runtime\n \
3577 commands: read only (landlock)\n";
3578 let at = rendered.find(block).expect(&rendered);
3579 assert!(at > rendered.find("Containment\n").unwrap());
3580 assert!(at < rendered.find("Effective grants").unwrap());
3581
3582 let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
3583 assert!(sealed
3584 .render()
3585 .contains(" commands: read only (mountns+landlock)\n"));
3586
3587 let json = serde_json::to_string(&report.runtime_dir).unwrap();
3588 assert_eq!(
3589 json,
3590 r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
3591 );
3592 }
3593
3594 #[test]
3597 fn a_landlock_session_reports_denial_rather_than_absence() {
3598 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
3599
3600 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3601 assert_eq!(
3602 report.filesystem_boundary.restriction,
3603 FilesystemRestriction::Enforced
3604 );
3605 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
3606
3607 let filesystem = &report.filesystem_boundary.not_protected[0];
3608 assert!(
3609 !filesystem.contains("advisory"),
3610 "Landlock mediates the filesystem here: {filesystem}"
3611 );
3612 assert!(
3613 filesystem.contains("denied rather than absent"),
3614 "{filesystem}"
3615 );
3616 for probe in ["stat", "access", "readlink"] {
3617 assert!(filesystem.contains(probe), "{filesystem}");
3618 }
3619 }
3620
3621 #[test]
3626 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
3627 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
3628
3629 assert_eq!(
3630 report.achieved_containment,
3631 ContainmentClass::Sealed,
3632 "the host can back sealed, which is what `achieved` answers"
3633 );
3634 assert_eq!(
3635 report.filesystem_boundary.restriction,
3636 FilesystemRestriction::Enforced,
3637 "no composed root is installed for a capsule that did not declare one"
3638 );
3639 assert_eq!(
3640 report.filesystem_boundary.not_protected,
3641 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
3642 .filesystem_boundary
3643 .not_protected,
3644 "the same mechanism gets the same statements on either host"
3645 );
3646 assert!(report.render().contains("Not protected here"));
3647 }
3648
3649 #[test]
3652 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
3653 let advisory =
3654 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
3655 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
3656
3657 assert_eq!(
3658 advisory.filesystem_boundary.not_protected[1],
3659 enforced.filesystem_boundary.not_protected[1]
3660 );
3661 assert!(
3662 advisory.filesystem_boundary.not_protected[1]
3663 .contains(crate::shell::SYNTHETIC_HOME_DIR_NAME),
3664 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
3665 );
3666 }
3667
3668 #[test]
3671 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
3672 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
3673
3674 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
3675 assert_eq!(
3676 report.filesystem_boundary.restriction,
3677 FilesystemRestriction::Absent
3678 );
3679 assert!(report.filesystem_boundary.not_protected.is_empty());
3680 assert!(
3681 !report.render().contains("Not protected here"),
3682 "{}",
3683 report.render()
3684 );
3685
3686 let json = serde_json::to_value(&report).unwrap();
3687 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
3688 assert_eq!(
3689 json["filesystem_boundary"]["not_protected"],
3690 serde_json::json!([]),
3691 "the key is present and empty at absent, never missing"
3692 );
3693 }
3694
3695 #[test]
3699 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
3700 let policy = CapabilityPolicy {
3701 workdir_exec_allowed: true,
3702 ..CapabilityPolicy::default()
3703 };
3704
3705 let report = scope_report_for_tier(
3706 &policy,
3707 ContainmentClass::Advisory,
3708 EnforcementTier::KernelSealed,
3709 None,
3710 None,
3711 Some(6),
3712 None,
3713 Vec::new(),
3714 Vec::new(),
3715 Vec::new(),
3716 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3717 IoMaxReport::default(),
3718 );
3719
3720 assert_eq!(
3721 report.enforcement_tier,
3722 "mountns+pivot_root+landlock+seccomp"
3723 );
3724 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3725 assert_eq!(
3726 report.filesystem_boundary.restriction,
3727 FilesystemRestriction::Enforced
3728 );
3729 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
3730 assert!(report.render().contains("Not protected here"));
3731 }
3732
3733 #[test]
3736 fn the_restriction_follows_the_tier_the_session_would_install() {
3737 for tier in ALL_TIERS {
3738 for declared in [
3739 ContainmentClass::Advisory,
3740 ContainmentClass::Scoped,
3741 ContainmentClass::Sealed,
3742 ] {
3743 let report = boundary_report(declared, *tier);
3744 let expected = match crate::sandbox::applied_tier(*tier, declared) {
3745 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
3746 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
3747 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
3748 FilesystemRestriction::Advisory
3749 }
3750 };
3751 assert_eq!(
3752 report.filesystem_boundary.restriction, expected,
3753 "{tier:?} / {declared:?}"
3754 );
3755 assert_eq!(
3756 report.filesystem_boundary.not_protected.is_empty(),
3757 expected == FilesystemRestriction::Absent,
3758 "{tier:?} / {declared:?}"
3759 );
3760 let wire = match expected {
3761 FilesystemRestriction::Advisory => "advisory",
3762 FilesystemRestriction::Enforced => "enforced",
3763 FilesystemRestriction::Absent => "absent",
3764 };
3765 assert_eq!(
3766 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
3767 serde_json::Value::String(wire.to_string())
3768 );
3769 }
3770 }
3771 }
3772
3773 #[test]
3786 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
3787 if crate::network_namespace::skip_without_egress_namespace(
3788 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
3789 ) {
3790 return;
3791 }
3792 if crate::sandbox::find_on_path("bash").is_none() {
3793 eprintln!(
3794 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
3795 no bash on PATH"
3796 );
3797 return;
3798 }
3799 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
3800 let secret = outside.path().join("host-file");
3801 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
3802 let secret = secret.to_str().unwrap().to_string();
3803
3804 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
3805 let workdir = tempfile::tempdir().unwrap();
3806 let policy = CapabilityPolicy {
3807 shell_allow: vec!["bash".to_string()],
3808 ..CapabilityPolicy::default()
3809 };
3810 let probe = crate::sandbox::HostProbe::probe();
3811 let runtime = tempfile::tempdir().unwrap();
3812 let enforcement = crate::sandbox::ShellEnforcement::resolve(&policy, declared, probe)
3813 .unwrap()
3814 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
3815 let report = scope_report_for_tier(
3816 &policy,
3817 declared,
3818 probe.tier(),
3819 None,
3820 None,
3821 None,
3822 None,
3823 Vec::new(),
3824 Vec::new(),
3825 Vec::new(),
3826 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3827 IoMaxReport::default(),
3828 );
3829
3830 let command = format!(
3831 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
3832 else echo read=refused; fi; \
3833 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
3834 );
3835 let result = crate::shell::execute_shell(
3836 "bash",
3837 &["-c", &command],
3838 &[],
3839 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
3840 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
3841 &policy,
3842 &enforcement,
3843 )
3844 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
3845 let seen = result.stdout;
3846
3847 match report.filesystem_boundary.restriction {
3848 FilesystemRestriction::Advisory => assert!(
3849 seen.contains("read=PROBE-CONTENTS"),
3850 "reported advisory, so nothing denies the read: {seen}"
3851 ),
3852 FilesystemRestriction::Enforced => assert!(
3853 seen.contains("read=refused") && seen.contains("exists=yes"),
3854 "reported enforced, so the read is denied and the path still exists: {seen}"
3855 ),
3856 FilesystemRestriction::Absent => assert!(
3857 seen.contains("read=refused") && seen.contains("exists=no"),
3858 "reported absent, so the path is not in the composed root: {seen}"
3859 ),
3860 }
3861 }
3862 }
3863
3864 fn capability_policy_field_names(source: &str) -> Vec<String> {
3866 source
3867 .split_once("pub struct CapabilityPolicy {")
3868 .expect("types.rs declares CapabilityPolicy")
3869 .1
3870 .split_once("\n}")
3871 .expect("CapabilityPolicy's declaration is closed at column 0")
3872 .0
3873 .lines()
3874 .filter_map(|line| {
3875 line.strip_prefix(" pub ")
3876 .and_then(|rest| rest.split_once(':'))
3877 .map(|(name, _)| name.to_string())
3878 })
3879 .collect()
3880 }
3881}