1use murmur_artifact::{ContainmentClass, ExportMode, Exports, FileExport, PeerFilesExport};
34use serde::Serialize;
35
36use crate::{
37 cgroup::{IoMaxReport, IoMaxStatus},
38 errors::RuntimeError,
39 runtime_dir::RuntimeDirPlacement,
40 sandbox::{EnforcementTier, HostProbe},
41 sealed::{SealedShortfall, UsernsGrant},
42 types::CapabilityPolicy,
43};
44
45pub use crate::sandbox::{detect_sealed_shortfall, detect_userns_grant};
46
47pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
52 match tier {
53 EnforcementTier::KernelSealed => ContainmentClass::Sealed,
58 EnforcementTier::KernelFull => ContainmentClass::Scoped,
61 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
65 ContainmentClass::Advisory
66 }
67 }
68}
69
70fn achieved_containment_class(
89 tier: EnforcementTier,
90 workdir_exec: bool,
91 runtime_dir_inside_workdir: bool,
92) -> ContainmentClass {
93 let class = achieved_class_for_tier(tier);
94 if workdir_exec || runtime_dir_inside_workdir {
95 class.min(ContainmentClass::Advisory)
96 } else {
97 class
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct ContainmentDecision {
111 declared: ContainmentClass,
112 host_tier: EnforcementTier,
113 applied_tier: EnforcementTier,
114 achieved: ContainmentClass,
115}
116
117impl ContainmentDecision {
118 pub(crate) fn decide(
122 host_tier: EnforcementTier,
123 declared: ContainmentClass,
124 workdir_exec: bool,
125 runtime_dir_inside_workdir: bool,
126 ) -> Self {
127 let applied_tier = crate::sandbox::applied_tier(host_tier, declared);
128 Self {
129 declared,
130 host_tier,
131 applied_tier,
132 achieved: achieved_containment_class(
133 applied_tier,
134 workdir_exec,
135 runtime_dir_inside_workdir,
136 ),
137 }
138 }
139
140 pub fn declared(&self) -> ContainmentClass {
142 self.declared
143 }
144
145 pub fn achieved(&self) -> ContainmentClass {
147 self.achieved
148 }
149
150 pub fn host_containment(&self) -> ContainmentClass {
153 achieved_class_for_tier(self.host_tier)
154 }
155
156 pub fn enforcement_tier(&self) -> &'static str {
159 enforcement_tier_name(self.applied_tier)
160 }
161
162 pub(crate) fn applied_tier(&self) -> EnforcementTier {
164 self.applied_tier
165 }
166}
167
168pub fn detect_containment_decision(
176 declared: ContainmentClass,
177) -> (ContainmentDecision, Option<SealedShortfall>) {
178 let probe = HostProbe::probe();
179 (
180 ContainmentDecision::decide(probe.tier(), declared, false, false),
181 probe.sealed_shortfall(),
182 )
183}
184
185pub fn containment_shortfall_reason(
203 declared: ContainmentClass,
204 achieved: ContainmentClass,
205 sealed_shortfall: Option<&SealedShortfall>,
206 workdir_exec: bool,
207 runtime_dir_inside: Option<&RuntimeDirPlacement>,
208) -> Option<String> {
209 containment_shortfall(
210 declared,
211 achieved,
212 sealed_shortfall,
213 workdir_exec,
214 runtime_dir_inside,
215 )
216 .map(|(reason, _)| reason)
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum ShortfallCause {
223 Host,
225 WorkdirExec,
227 InsideWorkdir,
230}
231
232fn containment_shortfall(
234 declared: ContainmentClass,
235 achieved: ContainmentClass,
236 sealed_shortfall: Option<&SealedShortfall>,
237 workdir_exec: bool,
238 runtime_dir_inside: Option<&RuntimeDirPlacement>,
239) -> Option<(String, ShortfallCause)> {
240 if achieved >= declared {
241 return None;
242 }
243
244 if workdir_exec && declared > ContainmentClass::Advisory {
245 return Some((
246 WORKDIR_EXEC_SHORTFALL.to_string(),
247 ShortfallCause::WorkdirExec,
248 ));
249 }
250
251 if let Some(placement) = runtime_dir_inside.filter(|_| declared > ContainmentClass::Advisory) {
252 return Some((placement.inside_shortfall(), ShortfallCause::InsideWorkdir));
253 }
254
255 let reason = match declared {
256 ContainmentClass::Advisory => {
260 "advisory is the weakest containment class and is satisfied by every host".to_string()
261 }
262 ContainmentClass::Scoped => {
263 "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
264 ABI); this host provides no kernel filesystem mediation, so paths outside the \
265 workdir are constrained by convention only"
266 .to_string()
267 }
268 ContainmentClass::Sealed => match sealed_shortfall {
271 Some(shortfall) => shortfall.reason(),
272 None => "sealed requires a private mount namespace pivoted onto a composed root \
273 (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
274 provide"
275 .to_string(),
276 },
277 };
278
279 Some((reason, ShortfallCause::Host))
280}
281
282const WORKDIR_EXEC_SHORTFALL: &str =
287 "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
288 workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
289 capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
290 No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
291 then enforced by the kernel on the resolved path) or lower the declared containment floor to \
292 advisory";
293
294pub fn check_containment_floor(
301 declared: ContainmentClass,
302 achieved: ContainmentClass,
303 sealed_shortfall: Option<&SealedShortfall>,
304 workdir_exec: bool,
305 runtime_dir_inside: Option<&RuntimeDirPlacement>,
306) -> Result<(), RuntimeError> {
307 match containment_shortfall(
308 declared,
309 achieved,
310 sealed_shortfall,
311 workdir_exec,
312 runtime_dir_inside,
313 ) {
314 None => Ok(()),
315 Some((reason, cause)) => Err(RuntimeError::ContainmentFloorUnmet {
316 declared,
317 achieved,
318 reason,
319 cause,
320 }),
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
331pub struct ExportsFilesReport {
332 pub root: String,
334 pub mode: ExportMode,
335 pub max_bytes: u64,
337}
338
339impl From<&FileExport> for ExportsFilesReport {
340 fn from(export: &FileExport) -> Self {
341 Self {
342 root: export.root.clone(),
343 mode: export.mode,
344 max_bytes: export.max_bytes,
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct PeerFilesReport {
356 pub root: String,
358 pub max_ttl_secs: u64,
362 pub max_bytes: u64,
364}
365
366impl From<&PeerFilesExport> for PeerFilesReport {
367 fn from(export: &PeerFilesExport) -> Self {
368 Self {
369 root: export.root.clone(),
370 max_ttl_secs: export.effective_max_ttl_secs(),
371 max_bytes: export.max_bytes,
372 }
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
382pub struct StateStoreReport {
383 pub artifact: String,
385 pub store: String,
388 pub host_path: String,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum PreopenSurface {
404 WholeWorkdir,
410 ScopedSubtree,
413 Nothing,
417 RecordsHooks,
421}
422
423impl PreopenSurface {
424 fn summary(self) -> &'static str {
428 match self {
429 PreopenSurface::WholeWorkdir => "the whole accessible workdir",
430 PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
431 PreopenSurface::Nothing => "nothing preopened",
432 PreopenSurface::RecordsHooks => "the records directory's hooks/",
433 }
434 }
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
446pub struct PreopenReport {
447 pub artifact: String,
449 pub role: String,
452 pub scope: Option<String>,
455 pub surface: PreopenSurface,
458 pub runtime_dir_reach: Option<RuntimeDirReach>,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
469#[serde(rename_all = "kebab-case")]
470pub enum RuntimeDirReach {
471 MurmurRuntime,
473 Environment,
475 Unresolved,
477}
478
479#[must_use]
483pub fn resolve_runtime_dir_reach(
484 reach: Option<RuntimeDirReach>,
485 implementation: Option<murmur_artifact::ArtifactImplementation>,
486) -> Option<RuntimeDirReach> {
487 reach?;
488 Some(match implementation {
489 Some(murmur_artifact::ArtifactImplementation::Native) => RuntimeDirReach::Environment,
490 Some(murmur_artifact::ArtifactImplementation::Wasm) => RuntimeDirReach::MurmurRuntime,
491 None => RuntimeDirReach::Unresolved,
492 })
493}
494
495pub fn resolve_runtime_dir_reaches(
498 reports: &mut [PreopenReport],
499 implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
500) {
501 for report in reports {
502 report.runtime_dir_reach = resolve_runtime_dir_reach(
503 report.runtime_dir_reach,
504 implementation_of(&report.artifact),
505 );
506 }
507}
508
509impl PreopenReport {
510 pub fn render(&self, runtime_dir_protected: bool) -> String {
517 let artifact = &self.artifact;
518 let role = &self.role;
519 let surface = self.surface.summary();
520 let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
521 let env = crate::runtime_dir::RUNTIME_DIR_ENV;
522 let runtime_dir = match (self.runtime_dir_reach, runtime_dir_protected) {
523 (None, _) => String::new(),
524 (Some(RuntimeDirReach::MurmurRuntime), _) => {
525 format!("; runtime directory read-only at {guest}/")
526 }
527 (Some(RuntimeDirReach::Environment), true) => {
528 format!("; runtime directory read-only at ${env}")
529 }
530 (Some(RuntimeDirReach::Environment), false) => {
531 format!("; runtime directory at ${env}, which commands can change on this host")
532 }
533 (Some(RuntimeDirReach::Unresolved), true) => {
534 format!("; runtime directory read-only at {guest}/ if WASM, at ${env} if native")
535 }
536 (Some(RuntimeDirReach::Unresolved), false) => format!(
537 "; runtime directory read-only at {guest}/ if WASM, at ${env} if native, which \
538 commands can change on this host"
539 ),
540 };
541 match &self.scope {
542 Some(scope) => format!(
543 "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
544 {scope}{runtime_dir}"
545 ),
546 None => format!(
547 "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
548 declared{runtime_dir}"
549 ),
550 }
551 }
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
562#[serde(rename_all = "kebab-case")]
563pub enum FilesystemRestriction {
564 Advisory,
566 Enforced,
568 Absent,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
582pub struct FilesystemBoundaryReport {
583 pub restriction: FilesystemRestriction,
585 pub not_protected: Vec<String>,
590}
591
592const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
597 "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
598 outside the work folder, so capabilities.filesystem.scope bounds what the runtime hands a \
599 subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
600 ABI installs a Landlock domain instead, where the kernel refuses the open.";
601
602const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
608 "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
609 stat, access and readlink on that path still succeed and still report the real file's \
610 metadata, because Landlock mediates the operations that touch a file and not path resolution. \
611 A probe that only asks whether a path exists therefore learns nothing about what was denied — \
612 read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
613 reaches the sealed tier composes a root the path is not in at all.";
614
615const PATH_REWRITING_NOT_PROTECTED: &str =
621 "Path rewriting is a convenience, not a boundary: HOME is set to the session's home folder, \
622 home/ in its records directory, on every tier, so `cat ~/.ssh/id_rsa` answers \"No such file \
623 or directory\" whether or not anything was denied — the tilde resolved into that folder and \
624 the host file was never looked for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
625 to a real file outside the workdir instead.";
626
627pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
640 let (restriction, filesystem) = match applied {
644 EnforcementTier::KernelSealed => (FilesystemRestriction::Absent, None),
645 EnforcementTier::KernelFull => (
646 FilesystemRestriction::Enforced,
647 Some(ENFORCED_FILESYSTEM_NOT_PROTECTED),
648 ),
649 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => (
650 FilesystemRestriction::Advisory,
651 Some(ADVISORY_FILESYSTEM_NOT_PROTECTED),
652 ),
653 };
654
655 let not_protected = match filesystem {
656 Some(filesystem) => vec![
657 filesystem.to_string(),
658 PATH_REWRITING_NOT_PROTECTED.to_string(),
659 ],
660 None => Vec::new(),
661 };
662
663 FilesystemBoundaryReport {
664 restriction,
665 not_protected,
666 }
667}
668
669#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
674pub struct RuntimeDirReport {
675 pub path: String,
679 pub read_only_to_commands: bool,
681 pub enforced_by: Option<&'static str>,
684 pub not_protected_because: Option<String>,
686}
687
688const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
690
691const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
693
694const LANDLOCK_TRUNCATE_ABI: u8 = 3;
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum ProtectedDir {
701 Runtime,
702 Records,
703}
704
705impl ProtectedDir {
706 fn noun(self) -> &'static str {
707 match self {
708 Self::Runtime => "runtime directory",
709 Self::Records => "records directory",
710 }
711 }
712
713 fn file(self) -> &'static str {
715 match self {
716 Self::Runtime => "runtime file",
717 Self::Records => "record",
718 }
719 }
720
721 fn heading(self) -> &'static str {
722 match self {
723 Self::Runtime => "Runtime directory",
724 Self::Records => "Records directory",
725 }
726 }
727}
728
729#[must_use]
737pub(crate) fn runtime_dir_report(
738 applied: EnforcementTier,
739 landlock_abi: Option<u8>,
740 placement: &RuntimeDirPlacement,
741) -> RuntimeDirReport {
742 protected_dir_report(
743 ProtectedDir::Runtime,
744 applied,
745 landlock_abi,
746 placement.report_path(),
747 placement.sessions_inside_workdir,
748 &placement.workdir,
749 )
750}
751
752#[must_use]
755pub(crate) fn records_dir_report(
756 applied: EnforcementTier,
757 landlock_abi: Option<u8>,
758 placement: &RuntimeDirPlacement,
759) -> RuntimeDirReport {
760 protected_dir_report(
761 ProtectedDir::Records,
762 applied,
763 landlock_abi,
764 placement.records_report_path(),
765 placement.records_inside_workdir,
766 &placement.workdir,
767 )
768}
769
770fn protected_dir_report(
771 dir: ProtectedDir,
772 applied: EnforcementTier,
773 landlock_abi: Option<u8>,
774 path: String,
775 inside_workdir: bool,
776 workdir: &std::path::Path,
777) -> RuntimeDirReport {
778 let noun = dir.noun();
779 let not_protected = |reason: String| RuntimeDirReport {
780 path: path.clone(),
781 read_only_to_commands: false,
782 enforced_by: None,
783 not_protected_because: Some(reason),
784 };
785 match applied {
786 EnforcementTier::EnvironmentOnly => not_protected(format!(
787 "this host has no kernel containment for capsule commands — macOS and every \
788 non-Linux host run them as the operator's own processes, so a command can change \
789 the {noun}"
790 )),
791 EnforcementTier::KernelSeccompOnly => not_protected(format!(
792 "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
793 {noun}"
794 )),
795 _ if inside_workdir => not_protected(format!(
796 "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
797 it; run with a --workdir that does not contain ~/.murmur",
798 workdir.display()
799 )),
800 EnforcementTier::KernelSealed => RuntimeDirReport {
801 path,
802 read_only_to_commands: true,
803 enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
804 not_protected_because: None,
805 },
806 EnforcementTier::KernelFull => {
807 let abi = landlock_abi.unwrap_or(1);
808 if abi < LANDLOCK_TRUNCATE_ABI {
809 not_protected(format!(
810 "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
811 {} though not replace it; kernel 6.2 or later refuses it",
812 dir.file()
813 ))
814 } else {
815 RuntimeDirReport {
816 path,
817 read_only_to_commands: true,
818 enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
819 not_protected_because: None,
820 }
821 }
822 }
823 }
824}
825
826#[must_use]
829pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
830 render_protected_dir(ProtectedDir::Runtime, report)
831}
832
833#[must_use]
836pub fn render_records_dir(report: &RuntimeDirReport) -> String {
837 render_protected_dir(ProtectedDir::Records, report)
838}
839
840fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
841 let commands = match (&report.not_protected_because, report.enforced_by) {
842 (Some(reason), _) => format!("can change it — {reason}"),
843 (None, Some(mechanism)) => format!("read only ({mechanism})"),
844 (None, None) => "read only".to_string(),
845 };
846 format!(
847 "{}\n path: {}\n commands: {commands}\n",
848 dir.heading(),
849 report.path
850 )
851}
852
853#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
857pub struct TempDirReport {
858 pub path: String,
861 pub tmpdir: String,
864 pub tmp_mapped: bool,
866 pub mapped_by: Option<&'static str>,
869 pub not_mapped_because: Option<String>,
872}
873
874#[must_use]
877pub(crate) fn temp_dir_report(
878 mapping: crate::temp_dir::TempMapping,
879 placement: &RuntimeDirPlacement,
880) -> TempDirReport {
881 let path = std::path::Path::new(&placement.records_report_path())
882 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
883 .display()
884 .to_string();
885 TempDirReport {
886 tmpdir: mapping.tmpdir_value(std::path::Path::new(&path)),
887 path,
888 tmp_mapped: mapping.is_mapped(),
889 mapped_by: mapping.mapped_by(),
890 not_mapped_because: match mapping {
891 crate::temp_dir::TempMapping::NotMapped(reason) => Some(reason.describe().to_string()),
892 _ => None,
893 },
894 }
895}
896
897#[must_use]
900pub fn render_temp_dir(report: &TempDirReport) -> String {
901 let tmp = match (report.mapped_by, &report.not_mapped_because) {
902 (Some(mechanism), _) => format!("mapped ({mechanism})"),
903 (None, Some(reason)) => format!("not mapped — {reason}"),
904 (None, None) => "not mapped".to_string(),
905 };
906 format!(
907 "Temporary folder\n path: {}\n TMPDIR: {}\n /tmp: {tmp}\n",
908 report.path, report.tmpdir
909 )
910}
911
912#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
918pub struct ScopeReport {
919 pub declared_containment: ContainmentClass,
921 pub achieved_containment: ContainmentClass,
925 pub host_containment: ContainmentClass,
929 pub floor_met: bool,
931 #[serde(skip_serializing_if = "Option::is_none")]
933 pub shortfall_reason: Option<String>,
934 pub enforcement_tier: &'static str,
937 pub mechanism_covers: Vec<&'static str>,
944 pub userns_grant: Option<UsernsGrant>,
954 pub landlock_abi: Option<u8>,
964 pub filesystem_scope: Option<String>,
966 pub workdir_exec: bool,
974 pub read_only_paths: Vec<String>,
984 pub read_only_advisory_for: Vec<String>,
994 pub preopens: Vec<PreopenReport>,
1007 pub network_allow: Vec<String>,
1009 pub unix_sockets: bool,
1011 pub shell_allow: Vec<String>,
1013 pub spawn_allow: Vec<String>,
1015 pub env_allow: Vec<String>,
1017 pub plan_submit: bool,
1025 pub install_skill: Vec<String>,
1033 pub install_tool: Vec<String>,
1037 pub install_from: Vec<String>,
1042 pub interpreter_runtime_grants: Vec<String>,
1046 pub derived_interpreter_runtime: Vec<String>,
1051 pub trust_store: Vec<String>,
1057 pub exports_files: Option<ExportsFilesReport>,
1067 pub peer_files: Option<PeerFilesReport>,
1075 pub peer_tasks: bool,
1081 pub peer_fetch_allow: Vec<String>,
1088 pub state_stores: Vec<StateStoreReport>,
1098 pub configured_artifacts: Vec<String>,
1112 pub staged_runtime_grants: Vec<String>,
1121 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1136 pub io_max: IoMaxReport,
1152 pub filesystem_boundary: FilesystemBoundaryReport,
1164 pub runtime_dir: RuntimeDirReport,
1169 pub records_dir: RuntimeDirReport,
1173 pub temp_dir: TempDirReport,
1177}
1178
1179impl ScopeReport {
1180 pub fn render(&self) -> String {
1182 let mut out = String::new();
1183 out.push_str("Containment\n");
1184 out.push_str(&format!(" declared: {}\n", self.declared_containment));
1185 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
1186 out.push_str(&format!(" host can back: {}\n", self.host_containment));
1187 out.push_str(&format!(
1188 " floor met: {}\n",
1189 if self.floor_met { "yes" } else { "no" }
1190 ));
1191 if let Some(reason) = &self.shortfall_reason {
1192 out.push_str(&format!(" reason: {reason}\n"));
1193 }
1194 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
1195 out.push_str(&format!(
1196 " covers: {}\n",
1197 render_mechanism_covers(&self.mechanism_covers)
1198 ));
1199 out.push_str(&format!(
1200 " userns grant: {}\n",
1201 match self.userns_grant {
1202 Some(grant) => grant.wire_name(),
1203 None => "n/a",
1204 }
1205 ));
1206 out.push_str(&format!(
1207 " landlock ABI: {}\n",
1208 match self.landlock_abi {
1209 Some(abi) => abi.to_string(),
1210 None => "n/a".to_string(),
1211 }
1212 ));
1213
1214 out.push('\n');
1215 out.push_str(&render_runtime_dir(&self.runtime_dir));
1216 out.push('\n');
1217 out.push_str(&render_records_dir(&self.records_dir));
1218 out.push('\n');
1219 out.push_str(&render_temp_dir(&self.temp_dir));
1220
1221 out.push_str(&render_not_protected(&self.filesystem_boundary));
1222
1223 out.push_str("\nEffective grants\n");
1224 out.push_str(&format!(
1225 " filesystem scope: {}\n",
1226 self.filesystem_scope.as_deref().unwrap_or("<none>")
1227 ));
1228 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
1229 out.push_str(&render_read_only(
1230 &self.read_only_paths,
1231 &self.read_only_advisory_for,
1232 ));
1233 push_list(
1234 &mut out,
1235 "preopens",
1236 &self
1237 .preopens
1238 .iter()
1239 .map(|preopen| preopen.render(self.runtime_dir.read_only_to_commands))
1240 .collect::<Vec<_>>(),
1241 );
1242 push_list(&mut out, "network allow", &self.network_allow);
1243 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
1244 push_list(&mut out, "shell allow", &self.shell_allow);
1245 push_list(&mut out, "spawn allow", &self.spawn_allow);
1246 push_list(&mut out, "env allow", &self.env_allow);
1247 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
1248 out.push_str(&render_install(
1249 &self.install_skill,
1250 &self.install_tool,
1251 &self.install_from,
1252 ));
1253 push_list(
1254 &mut out,
1255 "interpreter runtime",
1256 &self.interpreter_runtime_grants,
1257 );
1258 push_list(
1259 &mut out,
1260 "derived interpreter runtime",
1261 &self.derived_interpreter_runtime,
1262 );
1263 push_list(&mut out, "trust store", &self.trust_store);
1264 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1265 push_list(
1266 &mut out,
1267 "state stores",
1268 &self
1269 .state_stores
1270 .iter()
1271 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1272 .collect::<Vec<_>>(),
1273 );
1274 push_list(&mut out, "artifact config", &self.configured_artifacts);
1275
1276 out.push_str("\nResource plane\n");
1277 match &self.exports_files {
1278 None => out.push_str(" exports.files: <none>\n"),
1279 Some(export) => {
1280 out.push_str(&format!(" exports.files root: {}\n", export.root));
1281 out.push_str(&format!(" mode: {}\n", export.mode));
1282 out.push_str(&format!(
1283 " max bytes: {} (per file)\n",
1284 export.max_bytes
1285 ));
1286 }
1287 }
1288
1289 out.push_str(&format!(
1290 " io.max: {} ({} bytes/s declared)\n",
1291 match self.io_max.status {
1292 IoMaxStatus::Enforced => "enforced",
1293 IoMaxStatus::Unavailable => "unavailable",
1294 IoMaxStatus::NotRequired => "not-required",
1295 IoMaxStatus::NotProbed => "not-probed",
1296 },
1297 self.io_max.declared_bytes_per_sec
1298 ));
1299 if let Some(reason) = &self.io_max.reason {
1300 out.push_str(&format!(" {reason}\n"));
1301 }
1302
1303 out.push_str("\nPeer handoff\n");
1304 match &self.peer_files {
1305 None => out.push_str(" exports.peer_files: <none>\n"),
1306 Some(peer) => {
1307 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
1308 out.push_str(&format!(
1309 " max handle ttl: {}s\n",
1310 peer.max_ttl_secs
1311 ));
1312 out.push_str(&format!(
1313 " max bytes: {} (per file)\n",
1314 peer.max_bytes
1315 ));
1316 }
1317 }
1318 out.push_str(if self.peer_tasks {
1319 " exports.peer_tasks: accept\n"
1320 } else {
1321 " exports.peer_tasks: refuse\n"
1322 });
1323 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1324
1325 out.push_str("\nRuntime writes\n");
1326 out.push_str(&crate::workdir_writes::render_runtime_writes(
1327 &self.runtime_writes,
1328 ));
1329
1330 if !self.floor_met {
1331 out.push_str(
1332 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1333 launch here.\n",
1334 );
1335 }
1336 out
1337 }
1338}
1339
1340#[must_use]
1348pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
1349 if read_only_paths.is_empty() {
1350 return Vec::new();
1351 }
1352 shell_allow
1353 .iter()
1354 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
1355 .cloned()
1356 .collect()
1357}
1358
1359#[must_use]
1367pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
1368 let mut out = String::new();
1369 push_list(&mut out, "read_only", read_only_paths);
1370 if read_only_paths.is_empty() {
1371 return out;
1372 }
1373 if advisory_for.is_empty() {
1374 out.push_str(
1375 " read_only enforcement: enforced for every tool call and every shell command the \
1376 dispatch check can read\n",
1377 );
1378 } else {
1379 out.push_str(&format!(
1380 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
1381 something the dispatch check can read; enforced for every tool call and for every \
1382 shell command it can read\n",
1383 advisory_for.join(", ")
1384 ));
1385 }
1386 out
1387}
1388
1389#[must_use]
1397pub fn render_install(
1398 install_skill: &[String],
1399 install_tool: &[String],
1400 install_from: &[String],
1401) -> String {
1402 let mut out = String::new();
1403 push_list(&mut out, "install skill", install_skill);
1404 push_list(&mut out, "install tool", install_tool);
1405 if !install_from.is_empty() {
1406 out.push_str(&format!(" install from: {}\n", install_from.join(", ")));
1407 }
1408 out
1409}
1410
1411#[must_use]
1413pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
1414 if !crate::install_grant::is_granted(policy) {
1415 return Vec::new();
1416 }
1417 let mut places = vec!["stores".to_string()];
1418 if policy.install_from_sources {
1419 places.push("sources".to_string());
1420 }
1421 places
1422}
1423
1424fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
1432 if boundary.not_protected.is_empty() {
1433 return String::new();
1434 }
1435 let mut out = String::from("\nNot protected here\n");
1436 for statement in &boundary.not_protected {
1437 out.push_str(&format!(" - {statement}\n"));
1438 }
1439 out
1440}
1441
1442fn push_list(out: &mut String, label: &str, values: &[String]) {
1443 if values.is_empty() {
1444 out.push_str(&format!(" {label}: <none>\n"));
1445 return;
1446 }
1447 out.push_str(&format!(" {label}:\n"));
1448 for value in values {
1449 out.push_str(&format!(" - {value}\n"));
1450 }
1451}
1452
1453#[allow(clippy::too_many_arguments)]
1469pub fn explain_scope(
1470 policy: &CapabilityPolicy,
1471 declared: ContainmentClass,
1472 exports: Option<&Exports>,
1473 state_stores: Vec<StateStoreReport>,
1474 configured_artifacts: Vec<String>,
1475 preopens: Vec<PreopenReport>,
1476 io_max: IoMaxReport,
1477 workdir: &std::path::Path,
1478) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
1479 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1480 let probe = HostProbe::probe();
1481 let decision = ContainmentDecision::decide(
1482 probe.tier(),
1483 declared,
1484 policy.workdir_exec_allowed,
1485 placement.inside_workdir(),
1486 );
1487 let temp_mapping = crate::temp_dir::temp_mapping(
1488 decision.applied_tier(),
1489 probe.mount_namespace_usable(),
1490 &placement.workdir,
1491 );
1492 Ok(scope_report_for_tier(
1493 policy,
1494 &decision,
1495 probe.sealed_shortfall().as_ref(),
1496 probe.userns_grant(),
1497 probe.landlock_kernel_abi(),
1498 exports,
1499 state_stores,
1500 configured_artifacts,
1501 preopens,
1502 &placement,
1503 temp_mapping,
1504 io_max,
1505 ))
1506}
1507
1508pub fn doctor_runtime_dir(
1516 probe: &HostProbe,
1517 declared: ContainmentClass,
1518 workdir: &std::path::Path,
1519) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
1520 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1521 let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
1523 let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
1524 let temp_mapping =
1525 crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
1526 Ok(DoctorDirReports {
1527 runtime_dir: runtime_dir_report(applied, abi, &placement),
1528 records_dir: records_dir_report(applied, abi, &placement),
1529 temp_dir: temp_dir_report(temp_mapping, &placement),
1530 placement,
1531 })
1532}
1533
1534#[derive(Debug, Clone, PartialEq, Eq)]
1536pub struct DoctorDirReports {
1537 pub runtime_dir: RuntimeDirReport,
1538 pub records_dir: RuntimeDirReport,
1539 pub temp_dir: TempDirReport,
1540 pub placement: RuntimeDirPlacement,
1541}
1542
1543#[allow(clippy::too_many_arguments)]
1550pub(crate) fn scope_report_for_tier(
1551 policy: &CapabilityPolicy,
1552 decision: &ContainmentDecision,
1553 sealed_shortfall: Option<&SealedShortfall>,
1554 userns_grant: Option<UsernsGrant>,
1555 landlock_kernel_abi: Option<u8>,
1556 exports: Option<&Exports>,
1557 state_stores: Vec<StateStoreReport>,
1558 configured_artifacts: Vec<String>,
1559 preopens: Vec<PreopenReport>,
1560 runtime_dir_placement: &RuntimeDirPlacement,
1561 temp_mapping: crate::temp_dir::TempMapping,
1562 io_max: IoMaxReport,
1563) -> ScopeReport {
1564 debug_assert_eq!(
1565 *decision,
1566 ContainmentDecision::decide(
1567 decision.host_tier,
1568 decision.declared,
1569 policy.workdir_exec_allowed,
1570 runtime_dir_placement.inside_workdir(),
1571 ),
1572 "the decision must be made from the policy and placement this report states"
1573 );
1574 let declared = decision.declared();
1575 let achieved = decision.achieved();
1576 let applied = decision.applied_tier();
1580 let shortfall_reason = containment_shortfall_reason(
1581 declared,
1582 achieved,
1583 sealed_shortfall,
1584 policy.workdir_exec_allowed,
1585 runtime_dir_placement.inside(),
1586 );
1587 let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
1588 let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
1589 let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
1590 let mut filesystem_boundary = filesystem_boundary(applied);
1591 for report in [&runtime_dir, &records_dir] {
1592 if let Some(reason) = &report.not_protected_because {
1593 filesystem_boundary.not_protected.push(reason.clone());
1594 }
1595 }
1596
1597 ScopeReport {
1598 declared_containment: declared,
1599 achieved_containment: achieved,
1600 host_containment: decision.host_containment(),
1601 floor_met: shortfall_reason.is_none(),
1602 shortfall_reason,
1603 enforcement_tier: decision.enforcement_tier(),
1604 mechanism_covers: MECHANISM_COVERS.to_vec(),
1605 userns_grant,
1606 landlock_abi,
1607 exports_files: exports
1610 .and_then(|exports| exports.files.as_ref())
1611 .map(ExportsFilesReport::from),
1612 peer_files: exports
1613 .and_then(|exports| exports.peer_files.as_ref())
1614 .map(PeerFilesReport::from),
1615 peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
1616 peer_fetch_allow: policy.peer_fetch_allow.clone(),
1617 state_stores,
1621 configured_artifacts,
1625 filesystem_scope: policy.filesystem_scope.clone(),
1626 workdir_exec: policy.workdir_exec_allowed,
1627 read_only_paths: policy.read_only_paths.clone(),
1628 read_only_advisory_for: read_only_advisory_for(
1629 &policy.read_only_paths,
1630 &policy.shell_allow,
1631 ),
1632 preopens,
1636 network_allow: policy.network_allow.clone(),
1637 unix_sockets: policy.unix_sockets_allowed,
1638 shell_allow: policy.shell_allow.clone(),
1639 spawn_allow: policy.spawn_allow.clone(),
1640 env_allow: policy.env_allow.clone(),
1641 plan_submit: policy.plan_submit,
1642 install_skill: policy.install_skill.clone(),
1643 install_tool: policy.install_tool.clone(),
1644 install_from: install_from_places(policy),
1645 interpreter_runtime_grants: policy
1646 .shell_interpreter_runtime
1647 .iter()
1648 .flat_map(|grant| {
1649 grant.dirs.iter().map(move |dir| {
1650 let listable = if dir.list_dir { " (list_dir)" } else { "" };
1651 format!("{}: {}{}", grant.binary, dir.path, listable)
1652 })
1653 })
1654 .collect(),
1655 derived_interpreter_runtime: policy
1656 .shell_derived_interpreter_runtime
1657 .iter()
1658 .flat_map(|runtime| {
1659 let binary = runtime.binary.display();
1660 runtime
1661 .entries()
1662 .map(|(path, is_dir)| {
1663 let listable = if is_dir { " (list_dir)" } else { "" };
1664 format!("{binary}: {}{listable}", path.display())
1665 })
1666 .chain(
1667 runtime
1668 .libraries
1669 .iter()
1670 .map(|library| format!("{binary}: {}", library.display())),
1671 )
1672 .collect::<Vec<_>>()
1673 })
1674 .collect(),
1675 trust_store: policy
1676 .shell_trust_store
1677 .iter()
1678 .map(|entry| {
1679 let listable = if entry.directory { " (list_dir)" } else { "" };
1680 format!("{}{listable}", entry.path.display())
1681 })
1682 .collect(),
1683 staged_runtime_grants: policy
1684 .shell_staged_runtime
1685 .iter()
1686 .map(|grant| {
1687 format!(
1688 "{}: {} (pin: {})",
1689 grant.binary, grant.source_path, grant.pin
1690 )
1691 })
1692 .collect(),
1693 runtime_writes: crate::workdir_writes::runtime_writes(),
1697 io_max,
1701 filesystem_boundary,
1705 runtime_dir,
1706 records_dir,
1707 temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
1708 }
1709}
1710
1711#[cfg(test)]
1714#[allow(clippy::too_many_arguments)]
1715pub(crate) fn report_for(
1716 policy: &CapabilityPolicy,
1717 declared: ContainmentClass,
1718 tier: EnforcementTier,
1719 sealed_shortfall: Option<&SealedShortfall>,
1720 userns_grant: Option<UsernsGrant>,
1721 landlock_kernel_abi: Option<u8>,
1722 exports: Option<&Exports>,
1723 state_stores: Vec<StateStoreReport>,
1724 configured_artifacts: Vec<String>,
1725 preopens: Vec<PreopenReport>,
1726 runtime_dir_placement: &RuntimeDirPlacement,
1727 temp_mapping: crate::temp_dir::TempMapping,
1728 io_max: IoMaxReport,
1729) -> ScopeReport {
1730 let decision = ContainmentDecision::decide(
1731 tier,
1732 declared,
1733 policy.workdir_exec_allowed,
1734 runtime_dir_placement.inside_workdir(),
1735 );
1736 scope_report_for_tier(
1737 policy,
1738 &decision,
1739 sealed_shortfall,
1740 userns_grant,
1741 landlock_kernel_abi,
1742 exports,
1743 state_stores,
1744 configured_artifacts,
1745 preopens,
1746 runtime_dir_placement,
1747 temp_mapping,
1748 io_max,
1749 )
1750}
1751
1752pub(crate) const MECHANISM_COVERS: &[&str] = &["shell", "plan-shell-steps", "native-tools"];
1755
1756fn render_mechanism_covers(covers: &[&str]) -> String {
1758 covers
1759 .iter()
1760 .map(|cover| match *cover {
1761 "shell" => "shell commands",
1762 "plan-shell-steps" => "plan shell steps",
1763 "native-tools" => "native tools",
1764 other => other,
1765 })
1766 .collect::<Vec<_>>()
1767 .join(", ")
1768}
1769
1770pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
1775 ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
1777}
1778
1779fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
1782 match tier {
1783 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
1784 EnforcementTier::KernelFull => "landlock+seccomp",
1785 EnforcementTier::KernelSeccompOnly => "seccomp-only",
1786 EnforcementTier::EnvironmentOnly => "none",
1787 }
1788}
1789
1790#[cfg(test)]
1791#[allow(clippy::print_stdout, clippy::print_stderr)]
1792mod tests {
1793 use super::*;
1794 use crate::sealed::SealedBlocker;
1795
1796 fn blocked(blocker: SealedBlocker) -> SealedShortfall {
1798 SealedShortfall {
1799 blocker,
1800 failed_call: None,
1801 errno: None,
1802 userns_grant: UsernsGrant::Withheld,
1803 attachment: crate::sealed::ProfileAttachment::NotReported,
1804 }
1805 }
1806 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1807
1808 const ALL_TIERS: &[EnforcementTier] = &[
1810 EnforcementTier::KernelSealed,
1811 EnforcementTier::KernelFull,
1812 EnforcementTier::KernelSeccompOnly,
1813 EnforcementTier::EnvironmentOnly,
1814 ];
1815
1816 #[test]
1817 fn kernel_full_achieves_scoped() {
1818 assert_eq!(
1819 achieved_class_for_tier(EnforcementTier::KernelFull),
1820 ContainmentClass::Scoped
1821 );
1822 }
1823
1824 #[test]
1826 fn only_the_sealed_tier_achieves_sealed() {
1827 assert_eq!(
1828 achieved_class_for_tier(EnforcementTier::KernelSealed),
1829 ContainmentClass::Sealed
1830 );
1831 for tier in ALL_TIERS
1832 .iter()
1833 .filter(|t| **t != EnforcementTier::KernelSealed)
1834 {
1835 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1836 }
1837 }
1838
1839 #[test]
1840 fn tiers_without_landlock_achieve_only_advisory() {
1841 assert_eq!(
1842 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1843 ContainmentClass::Advisory
1844 );
1845 assert_eq!(
1846 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1847 ContainmentClass::Advisory
1848 );
1849 }
1850
1851 #[test]
1857 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1858 assert_eq!(
1860 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
1861 ContainmentClass::Sealed
1862 );
1863 assert_eq!(
1864 achieved_containment_class(EnforcementTier::KernelFull, false, false),
1865 ContainmentClass::Scoped
1866 );
1867 assert_eq!(
1868 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
1869 ContainmentClass::Advisory
1870 );
1871 assert_eq!(
1872 achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
1873 ContainmentClass::Advisory
1874 );
1875
1876 for tier in ALL_TIERS {
1878 assert_eq!(
1879 achieved_containment_class(*tier, true, false),
1880 ContainmentClass::Advisory,
1881 "workdir_exec must cap tier {tier:?} at advisory"
1882 );
1883 }
1884 }
1885
1886 #[test]
1889 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1890 for tier in ALL_TIERS {
1891 assert_eq!(
1892 achieved_class_for_tier(*tier),
1893 achieved_containment_class(*tier, false, false),
1894 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1895 );
1896 }
1897 }
1898
1899 #[test]
1903 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1904 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
1905 assert_eq!(achieved, ContainmentClass::Advisory);
1906
1907 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
1908 .expect_err("workdir_exec + scoped must refuse");
1909 match error {
1910 RuntimeError::ContainmentFloorUnmet {
1911 declared,
1912 achieved,
1913 reason,
1914 ..
1915 } => {
1916 assert_eq!(declared, ContainmentClass::Scoped);
1917 assert_eq!(achieved, ContainmentClass::Advisory);
1918 assert!(
1919 reason.contains("workdir_exec"),
1920 "the reason must name the manifest key, got: {reason}"
1921 );
1922 assert!(
1923 !reason.contains("this host provides no kernel filesystem mediation"),
1924 "the reason must not blame the host for a manifest declaration: {reason}"
1925 );
1926 }
1927 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1928 }
1929 }
1930
1931 #[test]
1935 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1936 let reason = containment_shortfall_reason(
1937 ContainmentClass::Sealed,
1938 ContainmentClass::Advisory,
1939 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
1940 true,
1941 None,
1942 )
1943 .expect("refusal");
1944 assert!(reason.contains("workdir_exec"));
1945 assert!(!reason.contains("apparmor_parser"));
1946 }
1947
1948 #[test]
1951 fn workdir_exec_alone_refuses_nothing() {
1952 for tier in ALL_TIERS {
1953 let achieved = achieved_containment_class(*tier, true, false);
1954 assert!(
1955 check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
1956 .is_ok(),
1957 "workdir_exec at an advisory floor must launch on tier {tier:?}"
1958 );
1959 }
1960 }
1961
1962 #[test]
1963 fn advisory_floor_is_met_on_every_tier() {
1964 for tier in ALL_TIERS {
1965 let achieved = achieved_class_for_tier(*tier);
1966 assert!(check_containment_floor(
1967 ContainmentClass::Advisory,
1968 achieved,
1969 None,
1970 false,
1971 None
1972 )
1973 .is_ok());
1974 }
1975 }
1976
1977 #[test]
1978 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1979 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1980 let achieved = achieved_class_for_tier(tier);
1981 assert!(
1982 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1983 .is_ok()
1984 );
1985 }
1986
1987 for tier in [
1988 EnforcementTier::KernelSeccompOnly,
1989 EnforcementTier::EnvironmentOnly,
1990 ] {
1991 let achieved = achieved_class_for_tier(tier);
1992 let error =
1993 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
1994 .expect_err("scoped must refuse without Landlock");
1995 match error {
1996 RuntimeError::ContainmentFloorUnmet {
1997 declared,
1998 achieved,
1999 reason,
2000 ..
2001 } => {
2002 assert_eq!(declared, ContainmentClass::Scoped);
2003 assert_eq!(achieved, ContainmentClass::Advisory);
2004 assert!(
2005 reason.contains("Landlock"),
2006 "reason must name the missing mechanism, got: {reason}"
2007 );
2008 }
2009 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2010 }
2011 }
2012 }
2013
2014 #[test]
2018 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
2019 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
2020 let decision =
2021 ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
2022 assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
2023 assert_eq!(decision.achieved(), ContainmentClass::Scoped);
2024 assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
2025 assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
2026 assert!(
2027 check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
2028 );
2029 assert_eq!(
2030 containment_shortfall_reason(declared, decision.achieved(), None, false, None),
2031 None
2032 );
2033 }
2034 }
2035
2036 const ALL_CLASSES: &[ContainmentClass] = &[
2037 ContainmentClass::Advisory,
2038 ContainmentClass::Scoped,
2039 ContainmentClass::Sealed,
2040 ];
2041
2042 #[test]
2046 fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
2047 use ContainmentClass::{Advisory, Scoped, Sealed};
2048 use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
2049 let table = [
2051 (
2052 (KernelSealed, Advisory),
2053 (KernelFull, Scoped, "landlock+seccomp"),
2054 ),
2055 (
2056 (KernelSealed, Scoped),
2057 (KernelFull, Scoped, "landlock+seccomp"),
2058 ),
2059 (
2060 (KernelSealed, Sealed),
2061 (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
2062 ),
2063 (
2064 (KernelFull, Advisory),
2065 (KernelFull, Scoped, "landlock+seccomp"),
2066 ),
2067 (
2068 (KernelFull, Scoped),
2069 (KernelFull, Scoped, "landlock+seccomp"),
2070 ),
2071 (
2072 (KernelFull, Sealed),
2073 (KernelFull, Scoped, "landlock+seccomp"),
2074 ),
2075 (
2076 (KernelSeccompOnly, Advisory),
2077 (KernelSeccompOnly, Advisory, "seccomp-only"),
2078 ),
2079 (
2080 (KernelSeccompOnly, Scoped),
2081 (KernelSeccompOnly, Advisory, "seccomp-only"),
2082 ),
2083 (
2084 (KernelSeccompOnly, Sealed),
2085 (KernelSeccompOnly, Advisory, "seccomp-only"),
2086 ),
2087 (
2088 (EnvironmentOnly, Advisory),
2089 (EnvironmentOnly, Advisory, "none"),
2090 ),
2091 (
2092 (EnvironmentOnly, Scoped),
2093 (EnvironmentOnly, Advisory, "none"),
2094 ),
2095 (
2096 (EnvironmentOnly, Sealed),
2097 (EnvironmentOnly, Advisory, "none"),
2098 ),
2099 ];
2100 assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
2101 for ((host_tier, declared), (applied, class, wire)) in table {
2102 let host_class = match host_tier {
2103 KernelSealed => Sealed,
2104 KernelFull => Scoped,
2105 KernelSeccompOnly | EnvironmentOnly => Advisory,
2106 };
2107 for workdir_exec in [false, true] {
2108 for inside in [false, true] {
2109 let decision =
2110 ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
2111 let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
2112 assert_eq!(decision.declared(), declared, "{what}");
2113 assert_eq!(decision.applied_tier(), applied, "{what}");
2114 assert_eq!(decision.enforcement_tier(), wire, "{what}");
2115 assert_eq!(decision.host_containment(), host_class, "{what}");
2116 let expected = if workdir_exec || inside {
2117 Advisory
2118 } else {
2119 class
2120 };
2121 assert_eq!(decision.achieved(), expected, "{what}");
2122 assert!(decision.achieved() <= decision.host_containment(), "{what}");
2125 assert!(
2126 decision.achieved() != Sealed || declared == Sealed,
2127 "{what}"
2128 );
2129 }
2130 }
2131 }
2132
2133 let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
2135 assert_eq!(
2136 (
2137 scoped.achieved(),
2138 scoped.enforcement_tier(),
2139 scoped.host_containment()
2140 ),
2141 (Scoped, "landlock+seccomp", Sealed)
2142 );
2143 let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
2144 assert_eq!(
2145 (sealed.achieved(), sealed.enforcement_tier()),
2146 (Sealed, "mountns+pivot_root+landlock+seccomp")
2147 );
2148 let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
2149 assert_eq!(
2150 (advisory.achieved(), advisory.enforcement_tier()),
2151 (Scoped, "landlock+seccomp")
2152 );
2153 }
2154
2155 #[test]
2159 fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
2160 for &host_tier in ALL_TIERS {
2161 for &declared in ALL_CLASSES {
2162 for workdir_exec in [false, true] {
2163 for inside in [None, Some(inside_placement())] {
2164 let in_workdir = inside.is_some();
2165 let decision = ContainmentDecision::decide(
2166 host_tier,
2167 declared,
2168 workdir_exec,
2169 in_workdir,
2170 );
2171 let host_class =
2172 achieved_containment_class(host_tier, workdir_exec, in_workdir);
2173 let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
2174 assert_eq!(
2175 containment_shortfall_reason(
2176 declared,
2177 decision.achieved(),
2178 Some(&shortfall),
2179 workdir_exec,
2180 inside.as_ref(),
2181 ),
2182 containment_shortfall_reason(
2183 declared,
2184 host_class,
2185 Some(&shortfall),
2186 workdir_exec,
2187 inside.as_ref(),
2188 ),
2189 "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
2190 );
2191 }
2192 }
2193 }
2194 }
2195 }
2196
2197 #[test]
2198 fn sealed_floor_is_met_only_on_the_sealed_tier() {
2199 assert!(check_containment_floor(
2200 ContainmentClass::Sealed,
2201 achieved_class_for_tier(EnforcementTier::KernelSealed),
2202 None,
2203 false,
2204 None
2205 )
2206 .is_ok());
2207
2208 for tier in ALL_TIERS
2209 .iter()
2210 .filter(|t| **t != EnforcementTier::KernelSealed)
2211 {
2212 let achieved = achieved_class_for_tier(*tier);
2213 let error = check_containment_floor(
2214 ContainmentClass::Sealed,
2215 achieved,
2216 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2217 false,
2218 None,
2219 )
2220 .expect_err("sealed must refuse on a host that cannot back it");
2221 match error {
2222 RuntimeError::ContainmentFloorUnmet {
2223 declared, reason, ..
2224 } => {
2225 assert_eq!(declared, ContainmentClass::Sealed);
2226 assert!(
2227 reason.contains("--cap-add SYS_ADMIN"),
2228 "reason must name the blocking mechanism's remediation, got: {reason}"
2229 );
2230 }
2231 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2232 }
2233 }
2234 }
2235
2236 #[test]
2239 fn the_sealed_refusal_names_the_specific_blocker() {
2240 let achieved = ContainmentClass::Scoped;
2241
2242 let apparmor = containment_shortfall_reason(
2243 ContainmentClass::Sealed,
2244 achieved,
2245 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2246 false,
2247 None,
2248 )
2249 .expect("refusal");
2250 assert!(apparmor.contains("mur-sealed"));
2251 assert!(apparmor.contains("apparmor_parser -r"));
2252 assert!(!apparmor.contains("--cap-add"));
2253
2254 let container = containment_shortfall_reason(
2255 ContainmentClass::Sealed,
2256 achieved,
2257 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2258 false,
2259 None,
2260 )
2261 .expect("refusal");
2262 assert!(container.contains("--cap-add SYS_ADMIN"));
2263 assert!(container.contains("outside the container"));
2264 assert!(!container.contains("apparmor_parser"));
2265
2266 let pivot = containment_shortfall_reason(
2269 ContainmentClass::Sealed,
2270 ContainmentClass::Advisory,
2271 Some(&blocked(SealedBlocker::PivotRootDenied)),
2272 false,
2273 None,
2274 )
2275 .expect("refusal");
2276 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
2277 assert_ne!(
2278 pivot,
2279 containment_shortfall_reason(
2280 ContainmentClass::Sealed,
2281 ContainmentClass::Advisory,
2282 Some(&blocked(SealedBlocker::MountDenied)),
2283 false,
2284 None,
2285 )
2286 .expect("refusal")
2287 );
2288
2289 for blocker in [
2291 SealedBlocker::NotLinux,
2292 SealedBlocker::AppArmorProfileMissing,
2293 SealedBlocker::NamespaceCreationDenied,
2294 SealedBlocker::MountDenied,
2295 SealedBlocker::PivotRootDenied,
2296 SealedBlocker::KernelUnsupported,
2297 SealedBlocker::LandlockUnavailable,
2298 ] {
2299 let reason = containment_shortfall_reason(
2300 ContainmentClass::Sealed,
2301 achieved,
2302 Some(&blocked(blocker)),
2303 false,
2304 None,
2305 )
2306 .expect("refusal");
2307 assert!(
2308 !reason.contains("no host can provide it today"),
2309 "got: {reason}"
2310 );
2311 }
2312 }
2313
2314 #[test]
2317 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
2318 let reason = containment_shortfall_reason(
2319 ContainmentClass::Sealed,
2320 ContainmentClass::Advisory,
2321 None,
2322 false,
2323 None,
2324 )
2325 .expect("refusal");
2326 assert!(reason.contains("pivot_root"));
2327 assert!(reason.contains("CLONE_NEWNS"));
2328 }
2329
2330 #[test]
2331 fn shortfall_reason_is_absent_when_the_floor_is_met() {
2332 assert_eq!(
2333 containment_shortfall_reason(
2334 ContainmentClass::Advisory,
2335 ContainmentClass::Scoped,
2336 None,
2337 false,
2338 None
2339 ),
2340 None
2341 );
2342 assert_eq!(
2343 containment_shortfall_reason(
2344 ContainmentClass::Scoped,
2345 ContainmentClass::Scoped,
2346 None,
2347 false,
2348 None
2349 ),
2350 None
2351 );
2352 assert_eq!(
2353 containment_shortfall_reason(
2354 ContainmentClass::Sealed,
2355 ContainmentClass::Sealed,
2356 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2357 false,
2358 None
2359 ),
2360 None,
2361 "a met floor must ignore a stale blocker rather than manufacture a refusal"
2362 );
2363 }
2364
2365 fn sample_policy() -> CapabilityPolicy {
2366 CapabilityPolicy {
2367 network_allow: vec!["https://api.example.com".to_string()],
2368 unix_sockets_allowed: false,
2369 filesystem_scope: Some("workdir".to_string()),
2370 shell_allow: vec!["python3".to_string()],
2371 spawn_allow: vec!["helper".to_string()],
2372 env_allow: vec!["TZ".to_string()],
2373 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
2374 binary: "python3".to_string(),
2375 dirs: vec![InterpreterRuntimeDir {
2376 path: "/usr/lib/python3.11".to_string(),
2377 list_dir: true,
2378 }],
2379 }],
2380 ..CapabilityPolicy::default()
2381 }
2382 }
2383
2384 #[test]
2385 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
2386 let report = report_for(
2387 &sample_policy(),
2388 ContainmentClass::Scoped,
2389 EnforcementTier::KernelFull,
2390 None,
2391 None,
2392 None,
2393 None,
2394 Vec::new(),
2395 Vec::new(),
2396 Vec::new(),
2397 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2398 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2399 EnforcementTier::KernelFull,
2400 ContainmentClass::Scoped,
2401 )),
2402 IoMaxReport::default(),
2403 );
2404
2405 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
2406 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2407 assert!(report.floor_met);
2408 assert_eq!(report.shortfall_reason, None);
2409 assert_eq!(report.enforcement_tier, "landlock+seccomp");
2410 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
2411 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
2412 assert!(!report.unix_sockets);
2413 assert_eq!(report.shell_allow, vec!["python3"]);
2414 assert_eq!(report.spawn_allow, vec!["helper"]);
2415 assert_eq!(report.env_allow, vec!["TZ"]);
2416 assert!(!report.workdir_exec);
2417 assert_eq!(
2418 report.interpreter_runtime_grants,
2419 vec!["python3: /usr/lib/python3.11 (list_dir)"]
2420 );
2421 }
2422
2423 #[test]
2427 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
2428 let policy = CapabilityPolicy {
2429 shell_allow: vec!["gcc".to_string()],
2430 workdir_exec_allowed: true,
2431 ..CapabilityPolicy::default()
2432 };
2433
2434 let report = report_for(
2435 &policy,
2436 ContainmentClass::Scoped,
2437 EnforcementTier::KernelFull,
2438 None,
2439 None,
2440 None,
2441 None,
2442 Vec::new(),
2443 Vec::new(),
2444 Vec::new(),
2445 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2446 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2447 EnforcementTier::KernelFull,
2448 ContainmentClass::Scoped,
2449 )),
2450 IoMaxReport::default(),
2451 );
2452
2453 assert!(report.workdir_exec);
2454 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2455 assert!(!report.floor_met);
2456 assert_eq!(report.enforcement_tier, "landlock+seccomp");
2459 assert!(report
2460 .shortfall_reason
2461 .as_deref()
2462 .expect("refusal")
2463 .contains("workdir_exec"));
2464
2465 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2466 assert_eq!(value["workdir_exec"], true);
2467
2468 let rendered = report.render();
2469 assert!(rendered.contains("workdir exec: true"));
2470 assert!(rendered.contains("achieved: advisory"));
2471 }
2472
2473 #[test]
2477 fn scope_report_json_always_carries_io_max() {
2478 let unprobed: serde_json::Value = serde_json::to_value(report_for(
2479 &sample_policy(),
2480 ContainmentClass::Scoped,
2481 EnforcementTier::KernelFull,
2482 None,
2483 None,
2484 None,
2485 None,
2486 Vec::new(),
2487 Vec::new(),
2488 Vec::new(),
2489 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2490 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2491 EnforcementTier::KernelFull,
2492 ContainmentClass::Scoped,
2493 )),
2494 IoMaxReport::default(),
2495 ))
2496 .unwrap();
2497 assert_eq!(unprobed["io_max"]["status"], "not-probed");
2498 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
2499 assert!(unprobed["io_max"].get("reason").is_none());
2500
2501 let enforced: serde_json::Value = serde_json::to_value(report_for(
2502 &sample_policy(),
2503 ContainmentClass::Scoped,
2504 EnforcementTier::KernelFull,
2505 None,
2506 None,
2507 None,
2508 None,
2509 Vec::new(),
2510 Vec::new(),
2511 Vec::new(),
2512 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2513 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2514 EnforcementTier::KernelFull,
2515 ContainmentClass::Scoped,
2516 )),
2517 IoMaxReport {
2518 declared_bytes_per_sec: 52_428_800,
2519 status: IoMaxStatus::Enforced,
2520 reason: None,
2521 },
2522 ))
2523 .unwrap();
2524 assert_eq!(enforced["io_max"]["status"], "enforced");
2525 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
2526 }
2527
2528 #[test]
2532 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
2533 let rendered = report_for(
2534 &sample_policy(),
2535 ContainmentClass::Scoped,
2536 EnforcementTier::KernelFull,
2537 None,
2538 None,
2539 None,
2540 None,
2541 Vec::new(),
2542 Vec::new(),
2543 Vec::new(),
2544 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2545 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2546 EnforcementTier::KernelFull,
2547 ContainmentClass::Scoped,
2548 )),
2549 IoMaxReport {
2550 declared_bytes_per_sec: 52_428_800,
2551 status: IoMaxStatus::Unavailable,
2552 reason: Some("the io controller is not delegated".to_string()),
2553 },
2554 )
2555 .render();
2556
2557 assert!(rendered.contains("Resource plane"), "{rendered}");
2558 assert!(rendered.contains("io.max:"), "{rendered}");
2559 assert!(rendered.contains("unavailable"), "{rendered}");
2560 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
2561 assert!(
2562 rendered.contains("the io controller is not delegated"),
2563 "{rendered}"
2564 );
2565 }
2566
2567 #[test]
2570 fn scope_report_json_always_carries_workdir_exec() {
2571 let value: serde_json::Value = serde_json::to_value(report_for(
2572 &sample_policy(),
2573 ContainmentClass::Scoped,
2574 EnforcementTier::KernelFull,
2575 None,
2576 None,
2577 None,
2578 None,
2579 Vec::new(),
2580 Vec::new(),
2581 Vec::new(),
2582 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2583 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2584 EnforcementTier::KernelFull,
2585 ContainmentClass::Scoped,
2586 )),
2587 IoMaxReport::default(),
2588 ))
2589 .unwrap();
2590 assert_eq!(value["workdir_exec"], false);
2591 }
2592
2593 #[test]
2597 fn staged_runtime_grants_are_reported_on_every_tier() {
2598 let policy = CapabilityPolicy {
2599 shell_allow: vec!["python3".to_string()],
2600 shell_staged_runtime: vec![StagedRuntimeGrant {
2601 binary: "python3".to_string(),
2602 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
2603 pin: "conda-4.10.3/python-3.9.19".to_string(),
2604 }],
2605 ..CapabilityPolicy::default()
2606 };
2607
2608 for tier in ALL_TIERS {
2609 let report = report_for(
2610 &policy,
2611 ContainmentClass::Sealed,
2612 *tier,
2613 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2614 None,
2615 None,
2616 None,
2617 Vec::new(),
2618 Vec::new(),
2619 Vec::new(),
2620 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2621 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2622 *tier,
2623 ContainmentClass::Sealed,
2624 )),
2625 IoMaxReport::default(),
2626 );
2627 assert_eq!(
2628 report.staged_runtime_grants,
2629 vec![
2630 "python3: /opt/testbed/conda/envs/django__django \
2631 (pin: conda-4.10.3/python-3.9.19)"
2632 ],
2633 "tier {tier:?} must still report the declared staged runtime"
2634 );
2635 assert!(
2636 report.render().contains("staged runtime"),
2637 "the rendered report must carry a staged runtime section on tier {tier:?}"
2638 );
2639 }
2640 }
2641
2642 #[test]
2645 fn absent_staged_runtime_reports_nothing() {
2646 let report = report_for(
2647 &sample_policy(),
2648 ContainmentClass::Scoped,
2649 EnforcementTier::KernelFull,
2650 None,
2651 None,
2652 None,
2653 None,
2654 Vec::new(),
2655 Vec::new(),
2656 Vec::new(),
2657 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2658 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2659 EnforcementTier::KernelFull,
2660 ContainmentClass::Scoped,
2661 )),
2662 IoMaxReport::default(),
2663 );
2664 assert!(report.staged_runtime_grants.is_empty());
2665 assert!(report.render().contains("staged runtime: <none>"));
2666 }
2667
2668 #[test]
2672 fn grant_set_never_raises_the_achieved_class() {
2673 let report = report_for(
2674 &sample_policy(),
2675 ContainmentClass::Sealed,
2676 EnforcementTier::KernelFull,
2677 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
2678 None,
2679 None,
2680 None,
2681 Vec::new(),
2682 Vec::new(),
2683 Vec::new(),
2684 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2685 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2686 EnforcementTier::KernelFull,
2687 ContainmentClass::Sealed,
2688 )),
2689 IoMaxReport::default(),
2690 );
2691 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2692 assert!(!report.floor_met);
2693 }
2694
2695 #[test]
2696 fn scope_report_still_reports_when_the_floor_is_unmet() {
2697 let report = report_for(
2698 &sample_policy(),
2699 ContainmentClass::Sealed,
2700 EnforcementTier::EnvironmentOnly,
2701 Some(&blocked(SealedBlocker::NotLinux)),
2702 None,
2703 None,
2704 None,
2705 Vec::new(),
2706 Vec::new(),
2707 Vec::new(),
2708 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2709 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2710 EnforcementTier::EnvironmentOnly,
2711 ContainmentClass::Sealed,
2712 )),
2713 IoMaxReport::default(),
2714 );
2715
2716 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
2717 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2718 assert!(!report.floor_met);
2719 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
2720 assert_eq!(report.enforcement_tier, "none");
2721 }
2722
2723 #[test]
2724 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
2725 let report = report_for(
2726 &sample_policy(),
2727 ContainmentClass::Sealed,
2728 EnforcementTier::KernelSealed,
2729 None,
2730 None,
2731 None,
2732 None,
2733 Vec::new(),
2734 Vec::new(),
2735 Vec::new(),
2736 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2737 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2738 EnforcementTier::KernelSealed,
2739 ContainmentClass::Sealed,
2740 )),
2741 IoMaxReport::default(),
2742 );
2743 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2744 assert_eq!(report.host_containment, ContainmentClass::Sealed);
2745 assert!(report.floor_met);
2746 assert_eq!(
2747 report.enforcement_tier,
2748 "mountns+pivot_root+landlock+seccomp"
2749 );
2750 assert_eq!(
2751 report.filesystem_boundary.restriction,
2752 FilesystemRestriction::Absent
2753 );
2754 }
2755
2756 #[test]
2760 fn every_report_field_follows_the_decision() {
2761 for &host_tier in ALL_TIERS {
2762 for &declared in ALL_CLASSES {
2763 let decision = ContainmentDecision::decide(host_tier, declared, false, false);
2764 let report = scope_report_for_tier(
2765 &sample_policy(),
2766 &decision,
2767 None,
2768 None,
2769 Some(6),
2770 None,
2771 Vec::new(),
2772 Vec::new(),
2773 Vec::new(),
2774 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2775 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
2776 IoMaxReport::default(),
2777 );
2778 let what = format!("{host_tier:?} {declared}");
2779 assert_eq!(report.declared_containment, declared, "{what}");
2780 assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
2781 assert_eq!(
2782 report.enforcement_tier,
2783 decision.enforcement_tier(),
2784 "{what}"
2785 );
2786 assert_eq!(
2787 report.host_containment,
2788 decision.host_containment(),
2789 "{what}"
2790 );
2791 assert_eq!(
2792 report.landlock_abi,
2793 crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
2794 "{what}"
2795 );
2796 let restriction = filesystem_boundary(decision.applied_tier()).restriction;
2797 assert_eq!(
2798 report.filesystem_boundary.restriction, restriction,
2799 "{what}"
2800 );
2801 assert_eq!(
2802 restriction,
2803 match report.enforcement_tier {
2804 "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
2805 "landlock+seccomp" => FilesystemRestriction::Enforced,
2806 _ => FilesystemRestriction::Advisory,
2807 },
2808 "{what}"
2809 );
2810 assert_eq!(
2811 report.temp_dir,
2812 temp_dir_report(
2813 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
2814 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2815 ),
2816 "{what}"
2817 );
2818 }
2819 }
2820 }
2821
2822 #[test]
2825 fn host_containment_is_reported_beside_the_achieved_class() {
2826 let report = report_for(
2827 &sample_policy(),
2828 ContainmentClass::Scoped,
2829 EnforcementTier::KernelSealed,
2830 None,
2831 None,
2832 None,
2833 None,
2834 Vec::new(),
2835 Vec::new(),
2836 Vec::new(),
2837 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2838 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
2839 IoMaxReport::default(),
2840 );
2841 let value = serde_json::to_value(&report).unwrap();
2842 assert_eq!(value["achieved_containment"], "scoped");
2843 assert_eq!(value["host_containment"], "sealed");
2844 assert_eq!(value["enforcement_tier"], "landlock+seccomp");
2845 assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
2846
2847 let rendered = report.render();
2848 assert!(
2849 rendered.contains(" achieved: scoped\n host can back: sealed\n floor met: yes\n"),
2850 "{rendered}"
2851 );
2852 assert!(
2853 rendered.contains(" mechanism: landlock+seccomp\n"),
2854 "{rendered}"
2855 );
2856 }
2857
2858 #[test]
2859 fn scope_report_json_uses_wire_names() {
2860 let report = report_for(
2861 &CapabilityPolicy::default(),
2862 ContainmentClass::Advisory,
2863 EnforcementTier::KernelSeccompOnly,
2864 None,
2865 None,
2866 None,
2867 None,
2868 Vec::new(),
2869 Vec::new(),
2870 Vec::new(),
2871 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2872 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2873 EnforcementTier::KernelSeccompOnly,
2874 ContainmentClass::Advisory,
2875 )),
2876 IoMaxReport::default(),
2877 );
2878 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2879
2880 assert_eq!(value["declared_containment"], "advisory");
2881 assert_eq!(value["achieved_containment"], "advisory");
2882 assert_eq!(value["host_containment"], "advisory");
2883 assert_eq!(value["floor_met"], true);
2884 assert_eq!(value["enforcement_tier"], "seccomp-only");
2885 assert!(value.get("shortfall_reason").is_none());
2887 }
2888
2889 #[test]
2892 fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
2893 let report = |tier, kernel| {
2894 report_for(
2895 &sample_policy(),
2896 ContainmentClass::Scoped,
2897 tier,
2898 None,
2899 None,
2900 kernel,
2901 None,
2902 Vec::new(),
2903 Vec::new(),
2904 Vec::new(),
2905 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2906 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2907 tier,
2908 ContainmentClass::Scoped,
2909 )),
2910 IoMaxReport::default(),
2911 )
2912 };
2913
2914 for (tier, kernel, expected) in [
2915 (EnforcementTier::KernelFull, Some(8), Some(3)),
2916 (EnforcementTier::KernelSealed, Some(8), Some(3)),
2917 (EnforcementTier::KernelFull, Some(2), Some(2)),
2918 (EnforcementTier::KernelFull, Some(1), Some(1)),
2919 (EnforcementTier::KernelFull, None, None),
2920 (EnforcementTier::KernelSeccompOnly, Some(8), None),
2921 (EnforcementTier::EnvironmentOnly, None, None),
2922 ] {
2923 let scope = report(tier, kernel);
2924 assert_eq!(
2925 scope.landlock_abi, expected,
2926 "{tier:?} with kernel {kernel:?}"
2927 );
2928
2929 let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
2930 assert!(value.as_object().unwrap().contains_key("landlock_abi"));
2931 match expected {
2932 Some(abi) => assert_eq!(value["landlock_abi"], abi),
2933 None => assert!(value["landlock_abi"].is_null()),
2934 }
2935
2936 let rendered = scope.render();
2937 let line = match expected {
2938 Some(abi) => format!(" landlock ABI: {abi}\n"),
2939 None => " landlock ABI: n/a\n".to_string(),
2940 };
2941 assert!(rendered.contains(&line), "{rendered}");
2942 assert!(
2943 rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
2944 );
2945 }
2946 }
2947
2948 #[test]
2951 fn the_userns_grant_is_reported_without_changing_any_verdict() {
2952 let baseline = report_for(
2953 &sample_policy(),
2954 ContainmentClass::Advisory,
2955 EnforcementTier::KernelSealed,
2956 None,
2957 None,
2958 None,
2959 None,
2960 Vec::new(),
2961 Vec::new(),
2962 Vec::new(),
2963 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2964 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2965 EnforcementTier::KernelSealed,
2966 ContainmentClass::Advisory,
2967 )),
2968 IoMaxReport::default(),
2969 );
2970
2971 for grant in UsernsGrant::ALL {
2972 let report = report_for(
2973 &sample_policy(),
2974 ContainmentClass::Advisory,
2975 EnforcementTier::KernelSealed,
2976 None,
2977 Some(*grant),
2978 None,
2979 None,
2980 Vec::new(),
2981 Vec::new(),
2982 Vec::new(),
2983 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
2984 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
2985 EnforcementTier::KernelSealed,
2986 ContainmentClass::Advisory,
2987 )),
2988 IoMaxReport::default(),
2989 );
2990 assert_eq!(report.achieved_containment, baseline.achieved_containment);
2991 assert_eq!(report.floor_met, baseline.floor_met);
2992 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
2993 assert_eq!(report.userns_grant, Some(*grant));
2994
2995 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
2996 assert_eq!(value["userns_grant"], grant.wire_name());
2997 assert!(report
2998 .render()
2999 .contains(&format!("userns grant: {}", grant.wire_name())));
3000 }
3001
3002 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
3005 assert!(value["userns_grant"].is_null());
3006 assert!(baseline.render().contains("userns grant: n/a"));
3007 }
3008
3009 #[test]
3010 fn rendered_report_names_both_classes_and_the_refusal() {
3011 let rendered = report_for(
3012 &sample_policy(),
3013 ContainmentClass::Sealed,
3014 EnforcementTier::KernelFull,
3015 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3016 None,
3017 None,
3018 None,
3019 Vec::new(),
3020 Vec::new(),
3021 Vec::new(),
3022 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3023 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3024 EnforcementTier::KernelFull,
3025 ContainmentClass::Sealed,
3026 )),
3027 IoMaxReport::default(),
3028 )
3029 .render();
3030
3031 assert!(rendered.contains("declared: sealed"));
3032 assert!(rendered.contains("achieved: scoped"));
3033 assert!(rendered.contains("floor met: no"));
3034 assert!(rendered.contains("mur-sealed"));
3035 assert!(rendered.contains("would refuse to launch"));
3036 }
3037
3038 #[test]
3042 fn an_export_never_changes_what_the_report_says_about_containment() {
3043 let export = FileExport {
3044 root: "out/".to_string(),
3045 mode: ExportMode::ReadOnly,
3046 max_bytes: 10 * 1024 * 1024,
3047 };
3048 for tier in ALL_TIERS {
3049 for workdir_exec in [false, true] {
3050 let policy = CapabilityPolicy {
3051 workdir_exec_allowed: workdir_exec,
3052 ..sample_policy()
3053 };
3054 let without = report_for(
3055 &policy,
3056 ContainmentClass::Scoped,
3057 *tier,
3058 None,
3059 None,
3060 None,
3061 None,
3062 Vec::new(),
3063 Vec::new(),
3064 Vec::new(),
3065 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3066 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3067 *tier,
3068 ContainmentClass::Scoped,
3069 )),
3070 IoMaxReport::default(),
3071 );
3072 let with = report_for(
3073 &policy,
3074 ContainmentClass::Scoped,
3075 *tier,
3076 None,
3077 None,
3078 None,
3079 Some(&Exports {
3080 files: Some(export.clone()),
3081 peer_files: None,
3082 peer_tasks: None,
3083 }),
3084 Vec::new(),
3085 Vec::new(),
3086 Vec::new(),
3087 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3088 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3089 *tier,
3090 ContainmentClass::Scoped,
3091 )),
3092 IoMaxReport::default(),
3093 );
3094 assert_eq!(
3095 with.achieved_containment, without.achieved_containment,
3096 "tier {tier:?}, workdir_exec {workdir_exec}"
3097 );
3098 assert_eq!(with.enforcement_tier, without.enforcement_tier);
3099 assert_eq!(with.floor_met, without.floor_met);
3100 assert_eq!(with.shortfall_reason, without.shortfall_reason);
3101 assert_eq!(
3104 ScopeReport {
3105 exports_files: None,
3106 ..with.clone()
3107 },
3108 without
3109 );
3110 assert_eq!(
3111 with.exports_files,
3112 Some(ExportsFilesReport {
3113 root: "out/".to_string(),
3114 mode: ExportMode::ReadOnly,
3115 max_bytes: 10 * 1024 * 1024,
3116 })
3117 );
3118 }
3119 }
3120 }
3121
3122 #[test]
3126 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
3127 let peer_files = PeerFilesExport {
3128 root: "out/".to_string(),
3129 max_ttl_secs: Some(900),
3130 max_bytes: 10 * 1024 * 1024,
3131 };
3132 for tier in ALL_TIERS {
3133 for workdir_exec in [false, true] {
3134 let without_policy = CapabilityPolicy {
3135 workdir_exec_allowed: workdir_exec,
3136 ..sample_policy()
3137 };
3138 let with_policy = CapabilityPolicy {
3139 peer_fetch_allow: vec!["localhost:41234".to_string()],
3140 ..without_policy.clone()
3141 };
3142 let without = report_for(
3143 &without_policy,
3144 ContainmentClass::Scoped,
3145 *tier,
3146 None,
3147 None,
3148 None,
3149 None,
3150 Vec::new(),
3151 Vec::new(),
3152 Vec::new(),
3153 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3154 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3155 *tier,
3156 ContainmentClass::Scoped,
3157 )),
3158 IoMaxReport::default(),
3159 );
3160 let with = report_for(
3161 &with_policy,
3162 ContainmentClass::Scoped,
3163 *tier,
3164 None,
3165 None,
3166 None,
3167 Some(&Exports {
3168 files: None,
3169 peer_files: Some(peer_files.clone()),
3170 peer_tasks: None,
3171 }),
3172 Vec::new(),
3173 Vec::new(),
3174 Vec::new(),
3175 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3176 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3177 *tier,
3178 ContainmentClass::Scoped,
3179 )),
3180 IoMaxReport::default(),
3181 );
3182 assert_eq!(
3183 with.achieved_containment, without.achieved_containment,
3184 "tier {tier:?}, workdir_exec {workdir_exec}"
3185 );
3186 assert_eq!(with.enforcement_tier, without.enforcement_tier);
3187 assert_eq!(with.floor_met, without.floor_met);
3188 assert_eq!(with.shortfall_reason, without.shortfall_reason);
3189 assert_eq!(
3190 ScopeReport {
3191 peer_files: None,
3192 peer_fetch_allow: Vec::new(),
3193 ..with.clone()
3194 },
3195 without
3196 );
3197 assert_eq!(
3198 with.peer_files,
3199 Some(PeerFilesReport {
3200 root: "out/".to_string(),
3201 max_ttl_secs: 900,
3202 max_bytes: 10 * 1024 * 1024,
3203 })
3204 );
3205 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
3206 assert_eq!(with.network_allow, without.network_allow);
3210 }
3211 }
3212 }
3213
3214 #[test]
3218 fn the_peer_fields_are_always_serialized() {
3219 let undeclared: serde_json::Value = serde_json::to_value(report_for(
3220 &sample_policy(),
3221 ContainmentClass::Advisory,
3222 EnforcementTier::KernelSealed,
3223 None,
3224 None,
3225 None,
3226 None,
3227 Vec::new(),
3228 Vec::new(),
3229 Vec::new(),
3230 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3231 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3232 EnforcementTier::KernelSealed,
3233 ContainmentClass::Advisory,
3234 )),
3235 IoMaxReport::default(),
3236 ))
3237 .unwrap();
3238 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
3239 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
3240
3241 let declared: serde_json::Value = serde_json::to_value(report_for(
3242 &CapabilityPolicy {
3243 peer_fetch_allow: vec!["localhost:41234".to_string()],
3244 ..sample_policy()
3245 },
3246 ContainmentClass::Advisory,
3247 EnforcementTier::KernelSealed,
3248 None,
3249 None,
3250 None,
3251 Some(&Exports {
3252 files: None,
3253 peer_files: Some(PeerFilesExport {
3254 root: "out/".to_string(),
3255 max_ttl_secs: None,
3256 max_bytes: 10 * 1024 * 1024,
3257 }),
3258 peer_tasks: None,
3259 }),
3260 Vec::new(),
3261 Vec::new(),
3262 Vec::new(),
3263 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3264 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3265 EnforcementTier::KernelSealed,
3266 ContainmentClass::Advisory,
3267 )),
3268 IoMaxReport::default(),
3269 ))
3270 .unwrap();
3271 assert_eq!(
3272 declared["peer_files"],
3273 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
3274 );
3275 assert_eq!(
3276 declared["peer_fetch_allow"],
3277 serde_json::json!(["localhost:41234"])
3278 );
3279 }
3280
3281 #[test]
3282 fn render_names_peer_handoff_in_both_directions() {
3283 let undeclared = report_for(
3284 &sample_policy(),
3285 ContainmentClass::Advisory,
3286 EnforcementTier::KernelSealed,
3287 None,
3288 None,
3289 None,
3290 None,
3291 Vec::new(),
3292 Vec::new(),
3293 Vec::new(),
3294 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3295 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3296 EnforcementTier::KernelSealed,
3297 ContainmentClass::Advisory,
3298 )),
3299 IoMaxReport::default(),
3300 )
3301 .render();
3302 assert!(undeclared.contains("Peer handoff"));
3303 assert!(undeclared.contains("exports.peer_files: <none>"));
3304 assert!(undeclared.contains("peer_fetch allow: <none>"));
3305
3306 let declared = report_for(
3307 &CapabilityPolicy {
3308 peer_fetch_allow: vec!["localhost:41234".to_string()],
3309 ..sample_policy()
3310 },
3311 ContainmentClass::Advisory,
3312 EnforcementTier::KernelSealed,
3313 None,
3314 None,
3315 None,
3316 Some(&Exports {
3317 files: None,
3318 peer_files: Some(PeerFilesExport {
3319 root: "out/".to_string(),
3320 max_ttl_secs: Some(900),
3321 max_bytes: 4096,
3322 }),
3323 peer_tasks: None,
3324 }),
3325 Vec::new(),
3326 Vec::new(),
3327 Vec::new(),
3328 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3329 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3330 EnforcementTier::KernelSealed,
3331 ContainmentClass::Advisory,
3332 )),
3333 IoMaxReport::default(),
3334 )
3335 .render();
3336 assert!(declared.contains("exports.peer_files root: out/"));
3337 assert!(declared.contains("max handle ttl: 900s"));
3338 assert!(declared.contains("max bytes: 4096 (per file)"));
3339 assert!(declared.contains("- localhost:41234"));
3340 }
3341
3342 fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
3343 report_for(
3344 &sample_policy(),
3345 ContainmentClass::Scoped,
3346 tier,
3347 None,
3348 None,
3349 None,
3350 peer_tasks
3351 .map(|accept| Exports {
3352 files: None,
3353 peer_files: None,
3354 peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
3355 })
3356 .as_ref(),
3357 Vec::new(),
3358 Vec::new(),
3359 Vec::new(),
3360 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3361 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3362 tier,
3363 ContainmentClass::Scoped,
3364 )),
3365 IoMaxReport::default(),
3366 )
3367 }
3368
3369 #[test]
3372 fn peer_tasks_is_reported_and_moves_no_other_field() {
3373 for tier in ALL_TIERS {
3374 let absent = report_with_peer_tasks(*tier, None);
3375 let refused = report_with_peer_tasks(*tier, Some(false));
3376 let accepted = report_with_peer_tasks(*tier, Some(true));
3377 assert!(!absent.peer_tasks);
3378 assert_eq!(refused, absent, "tier {tier:?}");
3379 assert!(accepted.peer_tasks);
3380 assert_eq!(accepted.achieved_containment, absent.achieved_containment);
3381 assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
3382 assert_eq!(accepted.floor_met, absent.floor_met);
3383 assert_eq!(
3384 ScopeReport {
3385 peer_tasks: false,
3386 ..accepted.clone()
3387 },
3388 absent
3389 );
3390 }
3391 }
3392
3393 #[test]
3395 fn peer_tasks_is_always_serialized() {
3396 let tier = EnforcementTier::KernelFull;
3397 let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
3398 assert_eq!(absent["peer_tasks"], serde_json::json!(false));
3399 let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
3400 assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
3401 }
3402
3403 #[test]
3404 fn render_names_the_peer_task_posture_under_peer_handoff() {
3405 let tier = EnforcementTier::KernelFull;
3406 for (declared, line) in [
3407 (None, " exports.peer_tasks: refuse\n"),
3408 (Some(false), " exports.peer_tasks: refuse\n"),
3409 (Some(true), " exports.peer_tasks: accept\n"),
3410 ] {
3411 let rendered = report_with_peer_tasks(tier, declared).render();
3412 let section = rendered
3413 .split("\nPeer handoff\n")
3414 .nth(1)
3415 .and_then(|rest| rest.split("\n\n").next())
3416 .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
3417 assert!(
3418 section.contains(line.trim_start_matches(' ').trim_end()),
3419 "{declared:?}:\n{section}"
3420 );
3421 assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
3422 }
3423 }
3424
3425 #[test]
3429 fn a_state_store_is_reported_and_moves_no_other_field() {
3430 let policy = sample_policy();
3431 let stores = vec![StateStoreReport {
3432 artifact: "murmur/tool-corpus".to_string(),
3433 store: "shey".to_string(),
3434 host_path: "/home/dev/.murmur/state/shey".to_string(),
3435 }];
3436
3437 let without = report_for(
3438 &policy,
3439 ContainmentClass::Scoped,
3440 EnforcementTier::KernelFull,
3441 None,
3442 None,
3443 None,
3444 None,
3445 Vec::new(),
3446 Vec::new(),
3447 Vec::new(),
3448 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3449 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3450 EnforcementTier::KernelFull,
3451 ContainmentClass::Scoped,
3452 )),
3453 IoMaxReport::default(),
3454 );
3455 let with = report_for(
3456 &policy,
3457 ContainmentClass::Scoped,
3458 EnforcementTier::KernelFull,
3459 None,
3460 None,
3461 None,
3462 None,
3463 stores.clone(),
3464 Vec::new(),
3465 Vec::new(),
3466 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3467 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3468 EnforcementTier::KernelFull,
3469 ContainmentClass::Scoped,
3470 )),
3471 IoMaxReport::default(),
3472 );
3473
3474 assert_eq!(with.state_stores, stores);
3475 assert!(without.state_stores.is_empty());
3476 assert_eq!(with.achieved_containment, without.achieved_containment);
3477 assert_eq!(with.floor_met, without.floor_met);
3478 assert_eq!(with.enforcement_tier, without.enforcement_tier);
3479 assert_eq!(with.shortfall_reason, without.shortfall_reason);
3480 assert_eq!(
3482 ScopeReport {
3483 state_stores: Vec::new(),
3484 ..with.clone()
3485 },
3486 without
3487 );
3488
3489 assert!(with
3491 .render()
3492 .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
3493 assert!(without.render().contains("state stores: <none>"));
3494
3495 let json = serde_json::to_value(&without).unwrap();
3498 assert_eq!(json["state_stores"], serde_json::json!([]));
3499 assert_eq!(
3500 serde_json::to_value(&with).unwrap()["state_stores"],
3501 serde_json::json!([{
3502 "artifact": "murmur/tool-corpus",
3503 "store": "shey",
3504 "host_path": "/home/dev/.murmur/state/shey",
3505 }])
3506 );
3507 }
3508
3509 #[test]
3514 fn preopens_are_reported_and_move_no_other_field() {
3515 let policy = sample_policy();
3516 let preopens = vec![
3517 PreopenReport {
3518 artifact: "notes-tool".to_string(),
3519 role: "tool".to_string(),
3520 scope: None,
3521 surface: PreopenSurface::WholeWorkdir,
3522 runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
3523 },
3524 PreopenReport {
3525 artifact: "cache-tool".to_string(),
3526 role: "tool".to_string(),
3527 scope: Some("cache".to_string()),
3528 surface: PreopenSurface::ScopedSubtree,
3529 runtime_dir_reach: None,
3530 },
3531 PreopenReport {
3532 artifact: "telemetry-hook".to_string(),
3533 role: "hook".to_string(),
3534 scope: None,
3535 surface: PreopenSurface::Nothing,
3536 runtime_dir_reach: None,
3537 },
3538 ];
3539
3540 let without = report_for(
3541 &policy,
3542 ContainmentClass::Scoped,
3543 EnforcementTier::KernelFull,
3544 None,
3545 None,
3546 None,
3547 None,
3548 Vec::new(),
3549 Vec::new(),
3550 Vec::new(),
3551 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3552 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3553 EnforcementTier::KernelFull,
3554 ContainmentClass::Scoped,
3555 )),
3556 IoMaxReport::default(),
3557 );
3558 let with = report_for(
3559 &policy,
3560 ContainmentClass::Scoped,
3561 EnforcementTier::KernelFull,
3562 None,
3563 None,
3564 None,
3565 None,
3566 Vec::new(),
3567 Vec::new(),
3568 preopens.clone(),
3569 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3570 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3571 EnforcementTier::KernelFull,
3572 ContainmentClass::Scoped,
3573 )),
3574 IoMaxReport::default(),
3575 );
3576
3577 assert_eq!(with.preopens, preopens);
3578 assert!(without.preopens.is_empty());
3579 assert_eq!(
3581 ScopeReport {
3582 preopens: Vec::new(),
3583 ..with.clone()
3584 },
3585 without
3586 );
3587
3588 let rendered = with.render();
3590 assert!(rendered.contains(
3591 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
3592 declared; runtime directory read-only at murmur-runtime/"
3593 ));
3594 assert!(rendered.contains(
3595 "- cache-tool (tool): one subtree of the accessible workdir — \
3596 capabilities.filesystem.scope: cache"
3597 ));
3598 assert!(rendered.contains(
3599 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
3600 ));
3601 assert!(without.render().contains("preopens: <none>"));
3602
3603 assert_eq!(
3605 serde_json::to_value(&without).unwrap()["preopens"],
3606 serde_json::json!([])
3607 );
3608 assert_eq!(
3609 serde_json::to_value(&with).unwrap()["preopens"],
3610 serde_json::json!([
3611 {
3612 "artifact": "notes-tool",
3613 "role": "tool",
3614 "scope": null,
3615 "surface": "whole-workdir",
3616 "runtime_dir_reach": "murmur-runtime",
3617 },
3618 {
3619 "artifact": "cache-tool",
3620 "role": "tool",
3621 "scope": "cache",
3622 "surface": "scoped-subtree",
3623 "runtime_dir_reach": null,
3624 },
3625 {
3626 "artifact": "telemetry-hook",
3627 "role": "hook",
3628 "scope": null,
3629 "surface": "nothing",
3630 "runtime_dir_reach": null,
3631 },
3632 ])
3633 );
3634 }
3635
3636 #[test]
3640 fn exports_files_is_always_serialized() {
3641 let undeclared = serde_json::to_value(report_for(
3642 &sample_policy(),
3643 ContainmentClass::Advisory,
3644 EnforcementTier::KernelFull,
3645 None,
3646 None,
3647 None,
3648 None,
3649 Vec::new(),
3650 Vec::new(),
3651 Vec::new(),
3652 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3653 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3654 EnforcementTier::KernelFull,
3655 ContainmentClass::Advisory,
3656 )),
3657 IoMaxReport::default(),
3658 ))
3659 .unwrap();
3660 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
3661
3662 let declared = serde_json::to_value(report_for(
3663 &sample_policy(),
3664 ContainmentClass::Advisory,
3665 EnforcementTier::KernelFull,
3666 None,
3667 None,
3668 None,
3669 Some(&Exports {
3670 files: Some(FileExport {
3671 root: "out/".to_string(),
3672 mode: ExportMode::ReadOnly,
3673 max_bytes: 10_485_760,
3674 }),
3675 peer_files: None,
3676 peer_tasks: None,
3677 }),
3678 Vec::new(),
3679 Vec::new(),
3680 Vec::new(),
3681 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3682 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3683 EnforcementTier::KernelFull,
3684 ContainmentClass::Advisory,
3685 )),
3686 IoMaxReport::default(),
3687 ))
3688 .unwrap();
3689 assert_eq!(
3690 declared["exports_files"],
3691 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
3692 );
3693 }
3694
3695 #[test]
3696 fn render_names_the_resource_plane_in_both_directions() {
3697 let undeclared = report_for(
3698 &sample_policy(),
3699 ContainmentClass::Advisory,
3700 EnforcementTier::KernelFull,
3701 None,
3702 None,
3703 None,
3704 None,
3705 Vec::new(),
3706 Vec::new(),
3707 Vec::new(),
3708 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3709 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3710 EnforcementTier::KernelFull,
3711 ContainmentClass::Advisory,
3712 )),
3713 IoMaxReport::default(),
3714 )
3715 .render();
3716 assert!(
3717 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
3718 "{undeclared}"
3719 );
3720
3721 let declared = report_for(
3722 &sample_policy(),
3723 ContainmentClass::Advisory,
3724 EnforcementTier::KernelFull,
3725 None,
3726 None,
3727 None,
3728 Some(&Exports {
3729 files: Some(FileExport {
3730 root: "out/".to_string(),
3731 mode: ExportMode::ReadOnly,
3732 max_bytes: 10_485_760,
3733 }),
3734 peer_files: None,
3735 peer_tasks: None,
3736 }),
3737 Vec::new(),
3738 Vec::new(),
3739 Vec::new(),
3740 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3741 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3742 EnforcementTier::KernelFull,
3743 ContainmentClass::Advisory,
3744 )),
3745 IoMaxReport::default(),
3746 )
3747 .render();
3748 assert!(declared.contains("exports.files root: out/"), "{declared}");
3749 assert!(
3750 declared.contains("mode: read-only"),
3751 "{declared}"
3752 );
3753 assert!(
3754 declared.contains("max bytes: 10485760 (per file)"),
3755 "{declared}"
3756 );
3757 }
3758
3759 fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
3762 let policy = CapabilityPolicy {
3763 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
3764 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
3765 ..CapabilityPolicy::default()
3766 };
3767 report_for(
3768 &policy,
3769 ContainmentClass::Scoped,
3770 EnforcementTier::KernelFull,
3771 None,
3772 None,
3773 None,
3774 None,
3775 Vec::new(),
3776 Vec::new(),
3777 Vec::new(),
3778 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3779 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3780 EnforcementTier::KernelFull,
3781 ContainmentClass::Scoped,
3782 )),
3783 IoMaxReport::default(),
3784 )
3785 }
3786
3787 #[test]
3790 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
3791 let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
3792 assert_eq!(
3793 report.read_only_paths,
3794 vec!["tests".to_string(), "bench/fixtures".to_string()]
3795 );
3796 assert!(report.read_only_advisory_for.is_empty());
3797
3798 let rendered = report.render();
3799 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
3800 assert!(rendered.contains(
3801 "read_only enforcement: enforced for every tool call and every shell command the \
3802 dispatch check can read"
3803 ));
3804 assert!(!rendered.contains("advisory against"));
3805 }
3806
3807 #[test]
3810 fn an_allowlisted_interpreter_is_named_as_advisory() {
3811 let report = read_only_report(&["tests"], &["git", "python3"]);
3812 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
3813
3814 let rendered = report.render();
3815 assert!(rendered.contains("read_only enforcement: advisory against python3"));
3816 assert!(rendered
3817 .contains("enforced for every tool call and for every shell command it can read"));
3818 }
3819
3820 #[test]
3824 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
3825 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
3826 .iter()
3827 .map(|b| (*b).to_string())
3828 .collect();
3829 let declared = vec!["tests".to_string()];
3830 assert_eq!(
3831 read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
3832 .read_only_advisory_for,
3833 read_only_advisory_for(&declared, &shell_allow)
3834 );
3835 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
3837 }
3838
3839 #[test]
3843 fn both_read_only_keys_are_always_serialized_arrays() {
3844 let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
3845 assert_eq!(json["read_only_paths"], serde_json::json!([]));
3846 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
3847
3848 let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
3849 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
3850 assert_eq!(
3851 json["read_only_advisory_for"],
3852 serde_json::json!(["python3"])
3853 );
3854 }
3855
3856 #[test]
3859 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
3860 let rendered = read_only_report(&[], &["python3"]).render();
3861 assert!(rendered.contains(" read_only: <none>\n"));
3862 assert!(!rendered.contains("read_only enforcement:"));
3863 }
3864
3865 #[test]
3877 fn every_capability_policy_field_is_reported_or_exempted() {
3878 const EXEMPT: &[(&str, &str)] = &[
3880 (
3881 "shell_strip_env",
3882 "Names host variables removed from the shell child's environment on top of the \
3883 credential patterns the runtime always strips. It subtracts from what the \
3884 capsule sees and can open nothing, so reporting it beside the grants would pad \
3885 the list an operator audits for reach.",
3886 ),
3887 (
3888 "shell_baseline_env",
3889 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
3890 environment of a subprocess the capsule was already granted, and names no host \
3891 resource `shell allow` and `env allow` do not already account for.",
3892 ),
3893 (
3894 "limits",
3895 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
3896 deadline. A bound on how much the capsule may consume, never a reach it gains.",
3897 ),
3898 (
3899 "resources",
3900 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
3901 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
3902 ),
3903 (
3904 "declared_deadline_seconds",
3905 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
3906 can apply a lower default. Reporting it would report one ceiling twice.",
3907 ),
3908 (
3909 "state_declared",
3910 "Records that a capsule-wide `capabilities.state` block was declared where \
3911 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
3912 exist are reported per artifact in `state_stores`.",
3913 ),
3914 (
3915 "conversation_declared",
3916 "Records a capsule-wide `capabilities.conversation` block that reaches no \
3917 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
3918 and the grant itself is per artifact.",
3919 ),
3920 ];
3921
3922 const REPORTED: &[(&str, &str)] = &[
3924 ("network_allow", "network_allow"),
3925 ("peer_fetch_allow", "peer_fetch_allow"),
3926 ("unix_sockets_allowed", "unix_sockets"),
3927 ("filesystem_scope", "filesystem_scope"),
3928 ("workdir_exec_allowed", "workdir_exec"),
3929 ("read_only_paths", "read_only_paths"),
3930 ("shell_allow", "shell_allow"),
3931 ("spawn_allow", "spawn_allow"),
3932 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
3933 (
3934 "shell_derived_interpreter_runtime",
3935 "derived_interpreter_runtime",
3936 ),
3937 ("shell_trust_store", "trust_store"),
3938 ("shell_staged_runtime", "staged_runtime_grants"),
3939 ("env_allow", "env_allow"),
3940 ("plan_submit", "plan_submit"),
3941 ("install_skill", "install_skill"),
3942 ("install_tool", "install_tool"),
3943 ("install_from_sources", "install_from"),
3944 ("containment_floor", "declared_containment"),
3945 ];
3946
3947 let declared = capability_policy_field_names(include_str!("types.rs"));
3948 assert!(
3949 declared.len() > 10,
3950 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
3951 );
3952
3953 for field in &declared {
3954 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
3955 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
3956 assert!(
3957 reported || exempt,
3958 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
3959 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
3960 with a written reason for why an operator does not need to see it."
3961 );
3962 assert!(
3963 !(reported && exempt),
3964 "CapabilityPolicy::{field} is listed as both reported and exempt"
3965 );
3966 }
3967
3968 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
3969 assert!(
3970 declared.iter().any(|field| field == policy_field),
3971 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
3972 declares — remove the stale row"
3973 );
3974 }
3975
3976 let json = serde_json::to_value(report_for(
3979 &sample_policy(),
3980 ContainmentClass::Scoped,
3981 EnforcementTier::KernelFull,
3982 None,
3983 None,
3984 None,
3985 None,
3986 Vec::new(),
3987 Vec::new(),
3988 Vec::new(),
3989 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3990 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3991 EnforcementTier::KernelFull,
3992 ContainmentClass::Scoped,
3993 )),
3994 IoMaxReport::default(),
3995 ))
3996 .unwrap();
3997 let object = json.as_object().expect("a scope report is a JSON object");
3998 for (policy_field, report_key) in REPORTED {
3999 assert!(
4000 object.contains_key(*report_key),
4001 "the table maps CapabilityPolicy::{policy_field} to a report key \
4002 '{report_key}' that a serialized ScopeReport does not carry"
4003 );
4004 }
4005 }
4006
4007 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
4010 report_for(
4011 &sample_policy(),
4012 declared,
4013 tier,
4014 None,
4015 None,
4016 Some(6),
4019 None,
4020 Vec::new(),
4021 Vec::new(),
4022 Vec::new(),
4023 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4024 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
4025 IoMaxReport::default(),
4026 )
4027 }
4028
4029 #[test]
4032 fn the_mechanism_names_every_subprocess_route_it_covers() {
4033 for tier in [
4034 EnforcementTier::KernelSealed,
4035 EnforcementTier::KernelFull,
4036 EnforcementTier::KernelSeccompOnly,
4037 EnforcementTier::EnvironmentOnly,
4038 ] {
4039 let report = boundary_report(ContainmentClass::Advisory, tier);
4040 assert_eq!(
4041 report.mechanism_covers,
4042 vec!["shell", "plan-shell-steps", "native-tools"]
4043 );
4044
4045 let rendered = report.render();
4046 let mechanism = format!(" mechanism: {}\n", report.enforcement_tier);
4047 let covers = " covers: shell commands, plan shell steps, native tools\n";
4048 assert!(
4049 rendered.contains(&format!("{mechanism}{covers}")),
4050 "covers: must follow mechanism: directly:\n{rendered}"
4051 );
4052
4053 let json = serde_json::to_string(&report).unwrap();
4054 assert!(
4055 json.contains(r#""mechanism_covers":["shell","plan-shell-steps","native-tools"]"#),
4056 "{json}"
4057 );
4058 }
4059 }
4060
4061 #[test]
4063 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
4064 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
4065
4066 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4067 assert_eq!(
4068 report.filesystem_boundary.restriction,
4069 FilesystemRestriction::Advisory
4070 );
4071 assert_eq!(report.filesystem_boundary.not_protected.len(), 4);
4074 assert_eq!(
4075 Some(&report.filesystem_boundary.not_protected[2]),
4076 report.runtime_dir.not_protected_because.as_ref()
4077 );
4078 assert_eq!(
4079 Some(&report.filesystem_boundary.not_protected[3]),
4080 report.records_dir.not_protected_because.as_ref()
4081 );
4082 assert!(report.filesystem_boundary.not_protected[3].contains("the records directory"));
4083
4084 let filesystem = &report.filesystem_boundary.not_protected[0];
4085 assert!(filesystem.contains("advisory"), "{filesystem}");
4086 assert!(
4087 filesystem.contains("denies a read or a write outside the work folder"),
4088 "{filesystem}"
4089 );
4090
4091 let rewriting = &report.filesystem_boundary.not_protected[1];
4092 assert!(rewriting.contains("not a boundary"), "{rewriting}");
4093 assert!(rewriting.contains("HOME"), "{rewriting}");
4094
4095 let rendered = report.render();
4096 let heading = rendered
4097 .find("\nNot protected here\n")
4098 .expect("a session that composes no root prints the block");
4099 assert!(
4100 heading > rendered.find("Containment\n").unwrap(),
4101 "the block belongs under Containment:\n{rendered}"
4102 );
4103 assert!(
4104 heading < rendered.find("\nEffective grants\n").unwrap(),
4105 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
4106 );
4107 for statement in &report.filesystem_boundary.not_protected {
4108 assert!(rendered.contains(statement.as_str()), "{rendered}");
4109 }
4110 }
4111
4112 fn inside_placement() -> RuntimeDirPlacement {
4113 RuntimeDirPlacement {
4114 sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
4115 records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
4116 workdir: std::path::PathBuf::from("/home/dev"),
4117 sessions_inside_workdir: true,
4118 records_inside_workdir: true,
4119 }
4120 }
4121
4122 #[test]
4124 fn runtime_dir_report_makes_one_claim_per_tier() {
4125 let outside = RuntimeDirPlacement::outside_for_test();
4126 for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
4127 let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
4128 assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
4129 assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
4130 assert_eq!(sealed.not_protected_because, None);
4131 }
4132
4133 let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
4134 assert!(full.read_only_to_commands);
4135 assert_eq!(full.enforced_by, Some("landlock"));
4136 assert_eq!(full.not_protected_because, None);
4137
4138 for abi in [Some(1), Some(2)] {
4139 let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
4140 assert!(!old.read_only_to_commands);
4141 assert_eq!(old.enforced_by, None);
4142 let reason = old.not_protected_because.unwrap();
4143 assert!(
4144 reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
4145 && reason.contains("truncate(2)")
4146 && reason.contains("6.2"),
4147 "{reason}"
4148 );
4149 }
4150
4151 let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
4152 assert!(!seccomp.read_only_to_commands);
4153 assert!(seccomp
4154 .not_protected_because
4155 .unwrap()
4156 .contains("no Landlock"));
4157
4158 let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
4159 assert!(!none.read_only_to_commands);
4160 let reason = none.not_protected_because.unwrap();
4161 assert!(
4162 reason.contains("no kernel containment") && reason.contains("macOS"),
4163 "{reason}"
4164 );
4165
4166 assert_eq!(
4167 full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
4168 "the path carries the literal session-id segment"
4169 );
4170 }
4171
4172 #[test]
4174 fn runtime_dir_report_inside_the_workdir_is_never_protected() {
4175 for tier in ALL_TIERS {
4176 let report = runtime_dir_report(*tier, Some(6), &inside_placement());
4177 assert!(!report.read_only_to_commands, "{tier:?}");
4178 assert_eq!(report.enforced_by, None);
4179 }
4180 let reason =
4181 runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
4182 .not_protected_because
4183 .unwrap();
4184 assert!(
4185 reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
4186 "{reason}"
4187 );
4188 assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
4189 }
4190
4191 #[test]
4194 fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
4195 let inside = inside_placement();
4196 assert_eq!(
4197 achieved_containment_class(EnforcementTier::KernelSealed, false, true),
4198 ContainmentClass::Advisory
4199 );
4200 assert_eq!(
4201 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
4202 ContainmentClass::Sealed
4203 );
4204 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
4205 match check_containment_floor(
4206 declared,
4207 ContainmentClass::Advisory,
4208 None,
4209 false,
4210 inside.inside(),
4211 ) {
4212 Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
4213 assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
4214 assert!(reason.contains("the workdir /home/dev"), "{reason}");
4215 assert!(
4216 reason.contains("run with a --workdir that does not contain ~/.murmur"),
4217 "{reason}"
4218 );
4219 }
4220 other => panic!("{declared:?}: {other:?}"),
4221 }
4222 }
4223 assert!(check_containment_floor(
4224 ContainmentClass::Advisory,
4225 ContainmentClass::Advisory,
4226 None,
4227 false,
4228 inside.inside()
4229 )
4230 .is_ok());
4231
4232 let report = report_for(
4233 &sample_policy(),
4234 ContainmentClass::Advisory,
4235 EnforcementTier::KernelFull,
4236 None,
4237 None,
4238 Some(6),
4239 None,
4240 Vec::new(),
4241 Vec::new(),
4242 Vec::new(),
4243 &inside,
4244 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4245 EnforcementTier::KernelFull,
4246 ContainmentClass::Advisory,
4247 )),
4248 IoMaxReport::default(),
4249 );
4250 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4251 let reason = report.runtime_dir.not_protected_because.clone().unwrap();
4252 assert!(report.filesystem_boundary.not_protected.contains(&reason));
4253 let rendered = report.render();
4254 assert!(
4255 rendered.contains(&format!(" commands: can change it — {reason}\n")),
4256 "{rendered}"
4257 );
4258 }
4259
4260 #[test]
4262 fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
4263 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
4264 let rendered = report.render();
4265 let block =
4266 "Runtime directory\n path: /home/test/.murmur/sessions/<session-id>/runtime\n \
4267 commands: read only (landlock)\n";
4268 let at = rendered.find(block).expect(&rendered);
4269 assert!(at > rendered.find("Containment\n").unwrap());
4270 assert!(at < rendered.find("Effective grants").unwrap());
4271
4272 let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
4273 assert!(sealed
4274 .render()
4275 .contains(" commands: read only (mountns+landlock)\n"));
4276
4277 let json = serde_json::to_string(&report.runtime_dir).unwrap();
4278 assert_eq!(
4279 json,
4280 r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
4281 );
4282 }
4283
4284 #[test]
4287 fn a_landlock_session_reports_denial_rather_than_absence() {
4288 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
4289
4290 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
4291 assert_eq!(
4292 report.filesystem_boundary.restriction,
4293 FilesystemRestriction::Enforced
4294 );
4295 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
4296
4297 let filesystem = &report.filesystem_boundary.not_protected[0];
4298 assert!(
4299 !filesystem.contains("advisory"),
4300 "Landlock mediates the filesystem here: {filesystem}"
4301 );
4302 assert!(
4303 filesystem.contains("denied rather than absent"),
4304 "{filesystem}"
4305 );
4306 for probe in ["stat", "access", "readlink"] {
4307 assert!(filesystem.contains(probe), "{filesystem}");
4308 }
4309 }
4310
4311 #[test]
4315 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
4316 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
4317
4318 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
4319 assert_eq!(report.enforcement_tier, "landlock+seccomp");
4320 assert_eq!(
4321 report.host_containment,
4322 ContainmentClass::Sealed,
4323 "the host can back sealed, which only `host_containment` says"
4324 );
4325 assert_eq!(
4326 report.filesystem_boundary.restriction,
4327 FilesystemRestriction::Enforced,
4328 "no composed root is installed for a capsule that did not declare one"
4329 );
4330 assert_eq!(
4331 report.filesystem_boundary.not_protected,
4332 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
4333 .filesystem_boundary
4334 .not_protected,
4335 "the same mechanism gets the same statements on either host"
4336 );
4337 assert!(report.render().contains("Not protected here"));
4338 }
4339
4340 #[test]
4343 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
4344 let advisory =
4345 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
4346 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
4347
4348 assert_eq!(
4349 advisory.filesystem_boundary.not_protected[1],
4350 enforced.filesystem_boundary.not_protected[1]
4351 );
4352 assert!(
4353 advisory.filesystem_boundary.not_protected[1]
4354 .contains("home/ in its records directory"),
4355 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
4356 );
4357 }
4358
4359 #[test]
4362 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
4363 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
4364
4365 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
4366 assert_eq!(
4367 report.filesystem_boundary.restriction,
4368 FilesystemRestriction::Absent
4369 );
4370 assert!(report.filesystem_boundary.not_protected.is_empty());
4371 assert!(
4372 !report.render().contains("Not protected here"),
4373 "{}",
4374 report.render()
4375 );
4376
4377 let json = serde_json::to_value(&report).unwrap();
4378 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
4379 assert_eq!(
4380 json["filesystem_boundary"]["not_protected"],
4381 serde_json::json!([]),
4382 "the key is present and empty at absent, never missing"
4383 );
4384 }
4385
4386 #[test]
4390 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
4391 let policy = CapabilityPolicy {
4392 workdir_exec_allowed: true,
4393 ..CapabilityPolicy::default()
4394 };
4395
4396 let report = report_for(
4397 &policy,
4398 ContainmentClass::Advisory,
4399 EnforcementTier::KernelSealed,
4400 None,
4401 None,
4402 Some(6),
4403 None,
4404 Vec::new(),
4405 Vec::new(),
4406 Vec::new(),
4407 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4408 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4409 EnforcementTier::KernelSealed,
4410 ContainmentClass::Advisory,
4411 )),
4412 IoMaxReport::default(),
4413 );
4414
4415 assert_eq!(report.enforcement_tier, "landlock+seccomp");
4416 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
4417 assert_eq!(
4418 report.filesystem_boundary.restriction,
4419 FilesystemRestriction::Enforced
4420 );
4421 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
4422 assert!(report.render().contains("Not protected here"));
4423 }
4424
4425 #[test]
4428 fn the_restriction_follows_the_tier_the_session_would_install() {
4429 for tier in ALL_TIERS {
4430 for declared in [
4431 ContainmentClass::Advisory,
4432 ContainmentClass::Scoped,
4433 ContainmentClass::Sealed,
4434 ] {
4435 let report = boundary_report(declared, *tier);
4436 let expected = match crate::sandbox::applied_tier(*tier, declared) {
4437 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
4438 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
4439 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
4440 FilesystemRestriction::Advisory
4441 }
4442 };
4443 assert_eq!(
4444 report.filesystem_boundary.restriction, expected,
4445 "{tier:?} / {declared:?}"
4446 );
4447 assert_eq!(
4448 report.filesystem_boundary.not_protected.is_empty(),
4449 expected == FilesystemRestriction::Absent,
4450 "{tier:?} / {declared:?}"
4451 );
4452 let wire = match expected {
4453 FilesystemRestriction::Advisory => "advisory",
4454 FilesystemRestriction::Enforced => "enforced",
4455 FilesystemRestriction::Absent => "absent",
4456 };
4457 assert_eq!(
4458 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
4459 serde_json::Value::String(wire.to_string())
4460 );
4461 }
4462 }
4463 }
4464
4465 #[test]
4478 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
4479 if crate::network_namespace::skip_without_egress_namespace(
4480 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
4481 ) {
4482 return;
4483 }
4484 if crate::sandbox::find_on_path("bash").is_none() {
4485 eprintln!(
4486 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
4487 no bash on PATH"
4488 );
4489 return;
4490 }
4491 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
4492 let secret = outside.path().join("host-file");
4493 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
4494 let secret = secret.to_str().unwrap().to_string();
4495
4496 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
4497 let workdir = tempfile::tempdir().unwrap();
4498 let policy = CapabilityPolicy {
4499 shell_allow: vec!["bash".to_string()],
4500 ..CapabilityPolicy::default()
4501 };
4502 let probe = crate::sandbox::HostProbe::probe();
4503 let host_tier = probe.tier();
4504 let runtime = tempfile::tempdir().unwrap();
4505 let enforcement =
4506 crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
4507 .unwrap()
4508 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
4509 let report = report_for(
4510 &policy,
4511 declared,
4512 host_tier,
4513 None,
4514 None,
4515 None,
4516 None,
4517 Vec::new(),
4518 Vec::new(),
4519 Vec::new(),
4520 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4521 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4522 host_tier, declared,
4523 )),
4524 IoMaxReport::default(),
4525 );
4526
4527 let command = format!(
4528 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
4529 else echo read=refused; fi; \
4530 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
4531 );
4532 let result = crate::shell::execute_shell(
4533 "bash",
4534 &["-c", &command],
4535 &[],
4536 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
4537 &policy,
4538 &enforcement,
4539 )
4540 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
4541 let seen = result.stdout;
4542
4543 match report.filesystem_boundary.restriction {
4544 FilesystemRestriction::Advisory => assert!(
4545 seen.contains("read=PROBE-CONTENTS"),
4546 "reported advisory, so nothing denies the read: {seen}"
4547 ),
4548 FilesystemRestriction::Enforced => assert!(
4549 seen.contains("read=refused") && seen.contains("exists=yes"),
4550 "reported enforced, so the read is denied and the path still exists: {seen}"
4551 ),
4552 FilesystemRestriction::Absent => assert!(
4553 seen.contains("read=refused") && seen.contains("exists=no"),
4554 "reported absent, so the path is not in the composed root: {seen}"
4555 ),
4556 }
4557 }
4558 }
4559
4560 fn capability_policy_field_names(source: &str) -> Vec<String> {
4562 source
4563 .split_once("pub struct CapabilityPolicy {")
4564 .expect("types.rs declares CapabilityPolicy")
4565 .1
4566 .split_once("\n}")
4567 .expect("CapabilityPolicy's declaration is closed at column 0")
4568 .0
4569 .lines()
4570 .filter_map(|line| {
4571 line.strip_prefix(" pub ")
4572 .and_then(|rest| rest.split_once(':'))
4573 .map(|(name, _)| name.to_string())
4574 })
4575 .collect()
4576 }
4577
4578 #[test]
4583 fn the_temp_dir_report_follows_the_per_tier_table() {
4584 use crate::temp_dir::temp_mapping;
4585
4586 let placement = RuntimeDirPlacement::outside_for_test();
4587 let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
4588 let tmp = crate::temp_dir::canonical_tmp();
4589 let elsewhere = std::path::Path::new("/work");
4590 let rows = [
4592 (
4593 EnforcementTier::KernelSealed,
4594 ContainmentClass::Sealed,
4595 true,
4596 elsewhere,
4597 true,
4598 Some("composed-root"),
4599 ),
4600 (
4601 EnforcementTier::KernelSealed,
4602 ContainmentClass::Scoped,
4603 true,
4604 elsewhere,
4605 true,
4606 Some("mount-namespace"),
4607 ),
4608 (
4609 EnforcementTier::KernelSealed,
4610 ContainmentClass::Advisory,
4611 true,
4612 elsewhere,
4613 true,
4614 Some("mount-namespace"),
4615 ),
4616 (
4617 EnforcementTier::KernelFull,
4618 ContainmentClass::Scoped,
4619 true,
4620 elsewhere,
4621 true,
4622 Some("mount-namespace"),
4623 ),
4624 (
4625 EnforcementTier::KernelFull,
4626 ContainmentClass::Scoped,
4627 false,
4628 elsewhere,
4629 false,
4630 None,
4631 ),
4632 (
4633 EnforcementTier::KernelSeccompOnly,
4634 ContainmentClass::Advisory,
4635 true,
4636 elsewhere,
4637 true,
4638 Some("mount-namespace"),
4639 ),
4640 (
4641 EnforcementTier::KernelSeccompOnly,
4642 ContainmentClass::Advisory,
4643 false,
4644 elsewhere,
4645 false,
4646 None,
4647 ),
4648 (
4649 EnforcementTier::EnvironmentOnly,
4650 ContainmentClass::Advisory,
4651 false,
4652 elsewhere,
4653 false,
4654 None,
4655 ),
4656 (
4657 EnforcementTier::KernelFull,
4658 ContainmentClass::Scoped,
4659 true,
4660 tmp.as_path(),
4661 false,
4662 None,
4663 ),
4664 (
4665 EnforcementTier::KernelSeccompOnly,
4666 ContainmentClass::Advisory,
4667 true,
4668 tmp.as_path(),
4669 false,
4670 None,
4671 ),
4672 ];
4673 for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
4674 let applied = crate::sandbox::applied_tier(tier, declared);
4675 let mapping = temp_mapping(applied, usable, workdir);
4676 let report = temp_dir_report(mapping, &placement);
4677 let row = format!(
4678 "{tier:?} {declared:?} usable={usable} workdir={}",
4679 workdir.display()
4680 );
4681 assert_eq!(report.path, folder, "{row}");
4682 assert_eq!(report.tmp_mapped, mapped, "{row}");
4683 assert_eq!(report.mapped_by, mapped_by, "{row}");
4684 assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
4685 assert_eq!(
4686 report
4687 .not_mapped_because
4688 .as_deref()
4689 .is_some_and(|reason| !reason.is_empty()),
4690 !mapped,
4691 "{row}"
4692 );
4693
4694 let scope = report_for(
4695 &CapabilityPolicy::default(),
4696 declared,
4697 tier,
4698 None,
4699 None,
4700 None,
4701 None,
4702 Vec::new(),
4703 Vec::new(),
4704 Vec::new(),
4705 &placement,
4706 mapping,
4707 IoMaxReport::default(),
4708 );
4709 assert_eq!(scope.temp_dir, report, "{row}");
4710 }
4711
4712 let reason = |tier, usable, workdir| {
4713 temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
4714 .not_mapped_because
4715 .unwrap()
4716 };
4717 assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
4718 .starts_with("this host has no kernel containment for capsule commands"));
4719 assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
4720 .contains("neither a private mount namespace nor Landlock"));
4721 assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
4722 .contains("a literal /tmp path is refused"));
4723 assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
4724 .starts_with("the workdir is /tmp itself"));
4725 }
4726
4727 #[test]
4730 fn the_temp_folder_block_renders_after_the_records_directory() {
4731 use crate::temp_dir::{NotMappedReason, TempMapping};
4732
4733 let placement = RuntimeDirPlacement::outside_for_test();
4734 let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
4735 assert_eq!(
4736 render_temp_dir(&mapped),
4737 "Temporary folder\n path: /home/test/.murmur/records/work/<session-id>/tmp\n \
4738 TMPDIR: /tmp\n /tmp: mapped (mount-namespace)\n"
4739 );
4740 let unmapped = temp_dir_report(
4741 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
4742 &placement,
4743 );
4744 assert_eq!(
4745 render_temp_dir(&unmapped),
4746 format!(
4747 "Temporary folder\n path: {path}\n TMPDIR: {path}\n /tmp: not \
4748 mapped — {}\n",
4749 NotMappedReason::NoMountNamespace.describe(),
4750 path = "/home/test/.murmur/records/work/<session-id>/tmp"
4751 )
4752 );
4753 assert_eq!(
4754 serde_json::to_value(&mapped).unwrap(),
4755 serde_json::json!({
4756 "path": "/home/test/.murmur/records/work/<session-id>/tmp",
4757 "tmpdir": "/tmp",
4758 "tmp_mapped": true,
4759 "mapped_by": "mount-namespace",
4760 "not_mapped_because": null,
4761 })
4762 );
4763
4764 let scope = report_for(
4765 &CapabilityPolicy::default(),
4766 ContainmentClass::Scoped,
4767 EnforcementTier::KernelFull,
4768 None,
4769 None,
4770 None,
4771 None,
4772 Vec::new(),
4773 Vec::new(),
4774 Vec::new(),
4775 &placement,
4776 TempMapping::MountNamespace,
4777 IoMaxReport::default(),
4778 );
4779 let rendered = scope.render();
4780 let records = rendered.find("Records directory\n").expect("records block");
4781 let temp = rendered.find("Temporary folder\n").expect("temp block");
4782 assert!(records < temp, "{rendered}");
4783 assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
4784 assert!(serde_json::to_value(&scope)
4785 .unwrap()
4786 .get("temp_dir")
4787 .is_some());
4788 }
4789}