1use murmur_artifact::{ContainmentClass, ExportMode, Exports, FileExport, PeerFilesExport};
30use serde::Serialize;
31
32use crate::{
33 cgroup::{IoMaxReport, IoMaxStatus},
34 errors::RuntimeError,
35 sandbox::{detect_enforcement_tier, EnforcementTier},
36 sealed::{SealedBlocker, UsernsGrant},
37 types::CapabilityPolicy,
38};
39
40pub use crate::sandbox::{detect_sealed_blocker, detect_userns_grant};
41
42pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
48 match tier {
49 EnforcementTier::KernelSealed => ContainmentClass::Sealed,
54 EnforcementTier::KernelFull => ContainmentClass::Scoped,
57 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
61 ContainmentClass::Advisory
62 }
63 }
64}
65
66pub(crate) fn achieved_containment_class(
80 tier: EnforcementTier,
81 workdir_exec: bool,
82) -> ContainmentClass {
83 let host_ceiling = achieved_class_for_tier(tier);
84 if workdir_exec {
85 host_ceiling.min(ContainmentClass::Advisory)
86 } else {
87 host_ceiling
88 }
89}
90
91pub fn detect_achieved_containment() -> ContainmentClass {
99 achieved_class_for_tier(detect_enforcement_tier())
100}
101
102pub fn containment_shortfall_reason(
116 declared: ContainmentClass,
117 achieved: ContainmentClass,
118 sealed_blocker: Option<SealedBlocker>,
119 workdir_exec: bool,
120) -> Option<String> {
121 if achieved >= declared {
122 return None;
123 }
124
125 if workdir_exec && declared > ContainmentClass::Advisory {
126 return Some(WORKDIR_EXEC_SHORTFALL.to_string());
127 }
128
129 let reason = match declared {
130 ContainmentClass::Advisory => {
134 "advisory is the weakest containment class and is satisfied by every host".to_string()
135 }
136 ContainmentClass::Scoped => {
137 "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
138 ABI); this host provides no kernel filesystem mediation, so paths outside the \
139 workdir are constrained by convention only"
140 .to_string()
141 }
142 ContainmentClass::Sealed => match sealed_blocker {
145 Some(blocker) => blocker.reason(),
146 None => "sealed requires a private mount namespace pivoted onto a composed root \
147 (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
148 provide"
149 .to_string(),
150 },
151 };
152
153 Some(reason)
154}
155
156const WORKDIR_EXEC_SHORTFALL: &str =
161 "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
162 workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
163 capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
164 No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
165 then enforced by the kernel on the resolved path) or lower the declared containment floor to \
166 advisory";
167
168pub fn check_containment_floor(
174 declared: ContainmentClass,
175 achieved: ContainmentClass,
176 sealed_blocker: Option<SealedBlocker>,
177 workdir_exec: bool,
178) -> Result<(), RuntimeError> {
179 match containment_shortfall_reason(declared, achieved, sealed_blocker, workdir_exec) {
180 None => Ok(()),
181 Some(reason) => Err(RuntimeError::ContainmentFloorUnmet {
182 declared,
183 achieved,
184 reason,
185 }),
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
196pub struct ExportsFilesReport {
197 pub root: String,
199 pub mode: ExportMode,
200 pub max_bytes: u64,
202}
203
204impl From<&FileExport> for ExportsFilesReport {
205 fn from(export: &FileExport) -> Self {
206 Self {
207 root: export.root.clone(),
208 mode: export.mode,
209 max_bytes: export.max_bytes,
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct PeerFilesReport {
221 pub root: String,
223 pub max_ttl_secs: u64,
227 pub max_bytes: u64,
229}
230
231impl From<&PeerFilesExport> for PeerFilesReport {
232 fn from(export: &PeerFilesExport) -> Self {
233 Self {
234 root: export.root.clone(),
235 max_ttl_secs: export.effective_max_ttl_secs(),
236 max_bytes: export.max_bytes,
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
247pub struct StateStoreReport {
248 pub artifact: String,
250 pub store: String,
253 pub host_path: String,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "kebab-case")]
268pub enum PreopenSurface {
269 WholeWorkdir,
275 ScopedSubtree,
278 Nothing,
282}
283
284impl PreopenSurface {
285 fn summary(self) -> &'static str {
289 match self {
290 PreopenSurface::WholeWorkdir => "the whole accessible workdir",
291 PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
292 PreopenSurface::Nothing => "nothing preopened",
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
306pub struct PreopenReport {
307 pub artifact: String,
309 pub role: String,
312 pub scope: Option<String>,
315 pub surface: PreopenSurface,
318}
319
320impl PreopenReport {
321 pub fn render(&self) -> String {
324 let artifact = &self.artifact;
325 let role = &self.role;
326 let surface = self.surface.summary();
327 match &self.scope {
328 Some(scope) => {
329 format!("{artifact} ({role}): {surface} — capabilities.filesystem.scope: {scope}")
330 }
331 None => format!(
332 "{artifact} ({role}): {surface} — no capabilities.filesystem.scope declared"
333 ),
334 }
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum FilesystemRestriction {
348 Advisory,
350 Enforced,
352 Absent,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct FilesystemBoundaryReport {
367 pub restriction: FilesystemRestriction,
369 pub not_protected: Vec<String>,
373}
374
375const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
380 "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
381 outside the session workdir, so capabilities.filesystem.scope bounds what the runtime hands a \
382 subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
383 ABI installs a Landlock domain instead, where the kernel refuses the open.";
384
385const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
391 "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
392 stat, access and readlink on that path still succeed and still report the real file's \
393 metadata, because Landlock mediates the operations that touch a file and not path resolution. \
394 A probe that only asks whether a path exists therefore learns nothing about what was denied — \
395 read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
396 reaches the sealed tier composes a root the path is not in at all.";
397
398const PATH_REWRITING_NOT_PROTECTED: &str =
404 "Path rewriting is a convenience, not a boundary: HOME is set to <workdir>/.capsule-home on \
405 every tier, so `cat ~/.ssh/id_rsa` answers \"No such file or directory\" whether or not \
406 anything was denied — the tilde resolved into the workdir and the host file was never looked \
407 for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
408 to a real file outside the workdir instead.";
409
410pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
423 let (restriction, filesystem) = match applied {
427 EnforcementTier::KernelSealed => (FilesystemRestriction::Absent, None),
428 EnforcementTier::KernelFull => (
429 FilesystemRestriction::Enforced,
430 Some(ENFORCED_FILESYSTEM_NOT_PROTECTED),
431 ),
432 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => (
433 FilesystemRestriction::Advisory,
434 Some(ADVISORY_FILESYSTEM_NOT_PROTECTED),
435 ),
436 };
437
438 let not_protected = match filesystem {
439 Some(filesystem) => vec![
440 filesystem.to_string(),
441 PATH_REWRITING_NOT_PROTECTED.to_string(),
442 ],
443 None => Vec::new(),
444 };
445
446 FilesystemBoundaryReport {
447 restriction,
448 not_protected,
449 }
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
458pub struct ScopeReport {
459 pub declared_containment: ContainmentClass,
461 pub achieved_containment: ContainmentClass,
463 pub floor_met: bool,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub shortfall_reason: Option<String>,
468 pub enforcement_tier: &'static str,
470 pub userns_grant: Option<UsernsGrant>,
480 pub filesystem_scope: Option<String>,
482 pub workdir_exec: bool,
490 pub read_only_paths: Vec<String>,
500 pub read_only_advisory_for: Vec<String>,
510 pub preopens: Vec<PreopenReport>,
523 pub network_allow: Vec<String>,
525 pub unix_sockets: bool,
527 pub shell_allow: Vec<String>,
529 pub spawn_allow: Vec<String>,
531 pub env_allow: Vec<String>,
533 pub plan_submit: bool,
541 pub interpreter_runtime_grants: Vec<String>,
545 pub exports_files: Option<ExportsFilesReport>,
555 pub peer_files: Option<PeerFilesReport>,
563 pub peer_fetch_allow: Vec<String>,
570 pub state_stores: Vec<StateStoreReport>,
580 pub configured_artifacts: Vec<String>,
594 pub staged_runtime_grants: Vec<String>,
603 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
621 pub io_max: IoMaxReport,
637 pub filesystem_boundary: FilesystemBoundaryReport,
651}
652
653impl ScopeReport {
654 pub fn render(&self) -> String {
656 let mut out = String::new();
657 out.push_str("Containment\n");
658 out.push_str(&format!(" declared: {}\n", self.declared_containment));
659 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
660 out.push_str(&format!(
661 " floor met: {}\n",
662 if self.floor_met { "yes" } else { "no" }
663 ));
664 if let Some(reason) = &self.shortfall_reason {
665 out.push_str(&format!(" reason: {reason}\n"));
666 }
667 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
668 out.push_str(&format!(
669 " userns grant: {}\n",
670 match self.userns_grant {
671 Some(grant) => grant.wire_name(),
672 None => "n/a",
673 }
674 ));
675
676 out.push_str(&render_not_protected(&self.filesystem_boundary));
677
678 out.push_str("\nEffective grants\n");
679 out.push_str(&format!(
680 " filesystem scope: {}\n",
681 self.filesystem_scope.as_deref().unwrap_or("<none>")
682 ));
683 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
684 out.push_str(&render_read_only(
685 &self.read_only_paths,
686 &self.read_only_advisory_for,
687 ));
688 push_list(
689 &mut out,
690 "preopens",
691 &self
692 .preopens
693 .iter()
694 .map(PreopenReport::render)
695 .collect::<Vec<_>>(),
696 );
697 push_list(&mut out, "network allow", &self.network_allow);
698 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
699 push_list(&mut out, "shell allow", &self.shell_allow);
700 push_list(&mut out, "spawn allow", &self.spawn_allow);
701 push_list(&mut out, "env allow", &self.env_allow);
702 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
703 push_list(
704 &mut out,
705 "interpreter runtime",
706 &self.interpreter_runtime_grants,
707 );
708 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
709 push_list(
710 &mut out,
711 "state stores",
712 &self
713 .state_stores
714 .iter()
715 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
716 .collect::<Vec<_>>(),
717 );
718 push_list(&mut out, "artifact config", &self.configured_artifacts);
719
720 out.push_str("\nResource plane\n");
721 match &self.exports_files {
722 None => out.push_str(" exports.files: <none>\n"),
723 Some(export) => {
724 out.push_str(&format!(" exports.files root: {}\n", export.root));
725 out.push_str(&format!(" mode: {}\n", export.mode));
726 out.push_str(&format!(
727 " max bytes: {} (per file)\n",
728 export.max_bytes
729 ));
730 }
731 }
732
733 out.push_str(&format!(
734 " io.max: {} ({} bytes/s declared)\n",
735 match self.io_max.status {
736 IoMaxStatus::Enforced => "enforced",
737 IoMaxStatus::Unavailable => "unavailable",
738 IoMaxStatus::NotRequired => "not-required",
739 IoMaxStatus::NotProbed => "not-probed",
740 },
741 self.io_max.declared_bytes_per_sec
742 ));
743 if let Some(reason) = &self.io_max.reason {
744 out.push_str(&format!(" {reason}\n"));
745 }
746
747 out.push_str("\nPeer handoff\n");
748 match &self.peer_files {
749 None => out.push_str(" exports.peer_files: <none>\n"),
750 Some(peer) => {
751 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
752 out.push_str(&format!(
753 " max handle ttl: {}s\n",
754 peer.max_ttl_secs
755 ));
756 out.push_str(&format!(
757 " max bytes: {} (per file)\n",
758 peer.max_bytes
759 ));
760 }
761 }
762 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
763
764 out.push_str("\nRuntime writes\n");
765 out.push_str(&crate::workdir_writes::render_runtime_writes(
766 &self.runtime_writes,
767 ));
768
769 if !self.floor_met {
770 out.push_str(
771 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
772 launch here.\n",
773 );
774 }
775 out
776 }
777}
778
779#[must_use]
787pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
788 if read_only_paths.is_empty() {
789 return Vec::new();
790 }
791 shell_allow
792 .iter()
793 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
794 .cloned()
795 .collect()
796}
797
798#[must_use]
806pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
807 let mut out = String::new();
808 push_list(&mut out, "read_only", read_only_paths);
809 if read_only_paths.is_empty() {
810 return out;
811 }
812 if advisory_for.is_empty() {
813 out.push_str(
814 " read_only enforcement: enforced for every tool call and every shell command the \
815 dispatch check can read\n",
816 );
817 } else {
818 out.push_str(&format!(
819 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
820 something the dispatch check can read; enforced for every tool call and for every \
821 shell command it can read\n",
822 advisory_for.join(", ")
823 ));
824 }
825 out
826}
827
828fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
836 if boundary.not_protected.is_empty() {
837 return String::new();
838 }
839 let mut out = String::from("\nNot protected here\n");
840 for statement in &boundary.not_protected {
841 out.push_str(&format!(" - {statement}\n"));
842 }
843 out
844}
845
846fn push_list(out: &mut String, label: &str, values: &[String]) {
847 if values.is_empty() {
848 out.push_str(&format!(" {label}: <none>\n"));
849 return;
850 }
851 out.push_str(&format!(" {label}:\n"));
852 for value in values {
853 out.push_str(&format!(" - {value}\n"));
854 }
855}
856
857pub fn explain_scope(
868 policy: &CapabilityPolicy,
869 declared: ContainmentClass,
870 exports: Option<&Exports>,
871 state_stores: Vec<StateStoreReport>,
872 configured_artifacts: Vec<String>,
873 preopens: Vec<PreopenReport>,
874 io_max: IoMaxReport,
875) -> ScopeReport {
876 let probe = crate::sandbox::HostProbe::probe();
877 scope_report_for_tier(
878 policy,
879 declared,
880 probe.tier(),
881 probe.sealed_blocker(),
882 probe.userns_grant(),
883 exports,
884 state_stores,
885 configured_artifacts,
886 preopens,
887 io_max,
888 )
889}
890
891#[allow(clippy::too_many_arguments)]
894pub(crate) fn scope_report_for_tier(
895 policy: &CapabilityPolicy,
896 declared: ContainmentClass,
897 tier: EnforcementTier,
898 sealed_blocker: Option<SealedBlocker>,
899 userns_grant: Option<UsernsGrant>,
900 exports: Option<&Exports>,
901 state_stores: Vec<StateStoreReport>,
902 configured_artifacts: Vec<String>,
903 preopens: Vec<PreopenReport>,
904 io_max: IoMaxReport,
905) -> ScopeReport {
906 let achieved = achieved_containment_class(tier, policy.workdir_exec_allowed);
907 let applied = crate::sandbox::applied_tier(tier, declared);
912 let shortfall_reason = containment_shortfall_reason(
913 declared,
914 achieved,
915 sealed_blocker,
916 policy.workdir_exec_allowed,
917 );
918
919 ScopeReport {
920 declared_containment: declared,
921 achieved_containment: achieved,
922 floor_met: shortfall_reason.is_none(),
923 shortfall_reason,
924 enforcement_tier: enforcement_tier_name(tier),
925 userns_grant,
926 exports_files: exports
929 .and_then(|exports| exports.files.as_ref())
930 .map(ExportsFilesReport::from),
931 peer_files: exports
932 .and_then(|exports| exports.peer_files.as_ref())
933 .map(PeerFilesReport::from),
934 peer_fetch_allow: policy.peer_fetch_allow.clone(),
935 state_stores,
939 configured_artifacts,
943 filesystem_scope: policy.filesystem_scope.clone(),
944 workdir_exec: policy.workdir_exec_allowed,
945 read_only_paths: policy.read_only_paths.clone(),
946 read_only_advisory_for: read_only_advisory_for(
947 &policy.read_only_paths,
948 &policy.shell_allow,
949 ),
950 preopens,
954 network_allow: policy.network_allow.clone(),
955 unix_sockets: policy.unix_sockets_allowed,
956 shell_allow: policy.shell_allow.clone(),
957 spawn_allow: policy.spawn_allow.clone(),
958 env_allow: policy.env_allow.clone(),
959 plan_submit: policy.plan_submit,
960 interpreter_runtime_grants: policy
961 .shell_interpreter_runtime
962 .iter()
963 .flat_map(|grant| {
964 grant.dirs.iter().map(move |dir| {
965 let listable = if dir.list_dir { " (list_dir)" } else { "" };
966 format!("{}: {}{}", grant.binary, dir.path, listable)
967 })
968 })
969 .collect(),
970 staged_runtime_grants: policy
971 .shell_staged_runtime
972 .iter()
973 .map(|grant| {
974 format!(
975 "{}: {} (pin: {})",
976 grant.binary, grant.source_path, grant.pin
977 )
978 })
979 .collect(),
980 runtime_writes: crate::workdir_writes::runtime_writes(applied),
986 io_max,
990 filesystem_boundary: filesystem_boundary(applied),
994 }
995}
996
997fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
1000 match tier {
1001 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
1002 EnforcementTier::KernelFull => "landlock+seccomp",
1003 EnforcementTier::KernelSeccompOnly => "seccomp-only",
1004 EnforcementTier::EnvironmentOnly => "none",
1005 }
1006}
1007
1008#[cfg(test)]
1009#[allow(clippy::print_stdout, clippy::print_stderr)]
1010mod tests {
1011 use super::*;
1012 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1013
1014 const ALL_TIERS: &[EnforcementTier] = &[
1016 EnforcementTier::KernelSealed,
1017 EnforcementTier::KernelFull,
1018 EnforcementTier::KernelSeccompOnly,
1019 EnforcementTier::EnvironmentOnly,
1020 ];
1021
1022 #[test]
1023 fn kernel_full_achieves_scoped() {
1024 assert_eq!(
1025 achieved_class_for_tier(EnforcementTier::KernelFull),
1026 ContainmentClass::Scoped
1027 );
1028 }
1029
1030 #[test]
1032 fn only_the_sealed_tier_achieves_sealed() {
1033 assert_eq!(
1034 achieved_class_for_tier(EnforcementTier::KernelSealed),
1035 ContainmentClass::Sealed
1036 );
1037 for tier in ALL_TIERS
1038 .iter()
1039 .filter(|t| **t != EnforcementTier::KernelSealed)
1040 {
1041 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1042 }
1043 }
1044
1045 #[test]
1046 fn tiers_without_landlock_achieve_only_advisory() {
1047 assert_eq!(
1048 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1049 ContainmentClass::Advisory
1050 );
1051 assert_eq!(
1052 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1053 ContainmentClass::Advisory
1054 );
1055 }
1056
1057 #[test]
1063 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1064 assert_eq!(
1066 achieved_containment_class(EnforcementTier::KernelSealed, false),
1067 ContainmentClass::Sealed
1068 );
1069 assert_eq!(
1070 achieved_containment_class(EnforcementTier::KernelFull, false),
1071 ContainmentClass::Scoped
1072 );
1073 assert_eq!(
1074 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false),
1075 ContainmentClass::Advisory
1076 );
1077 assert_eq!(
1078 achieved_containment_class(EnforcementTier::EnvironmentOnly, false),
1079 ContainmentClass::Advisory
1080 );
1081
1082 for tier in ALL_TIERS {
1084 assert_eq!(
1085 achieved_containment_class(*tier, true),
1086 ContainmentClass::Advisory,
1087 "workdir_exec must cap tier {tier:?} at advisory"
1088 );
1089 }
1090 }
1091
1092 #[test]
1095 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1096 for tier in ALL_TIERS {
1097 assert_eq!(
1098 achieved_class_for_tier(*tier),
1099 achieved_containment_class(*tier, false),
1100 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1101 );
1102 }
1103 }
1104
1105 #[test]
1109 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1110 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true);
1111 assert_eq!(achieved, ContainmentClass::Advisory);
1112
1113 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true)
1114 .expect_err("workdir_exec + scoped must refuse");
1115 match error {
1116 RuntimeError::ContainmentFloorUnmet {
1117 declared,
1118 achieved,
1119 reason,
1120 } => {
1121 assert_eq!(declared, ContainmentClass::Scoped);
1122 assert_eq!(achieved, ContainmentClass::Advisory);
1123 assert!(
1124 reason.contains("workdir_exec"),
1125 "the reason must name the manifest key, got: {reason}"
1126 );
1127 assert!(
1128 !reason.contains("this host provides no kernel filesystem mediation"),
1129 "the reason must not blame the host for a manifest declaration: {reason}"
1130 );
1131 }
1132 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1133 }
1134 }
1135
1136 #[test]
1140 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1141 let reason = containment_shortfall_reason(
1142 ContainmentClass::Sealed,
1143 ContainmentClass::Advisory,
1144 Some(SealedBlocker::AppArmorProfileMissing),
1145 true,
1146 )
1147 .expect("refusal");
1148 assert!(reason.contains("workdir_exec"));
1149 assert!(!reason.contains("apparmor_parser"));
1150 }
1151
1152 #[test]
1155 fn workdir_exec_alone_refuses_nothing() {
1156 for tier in ALL_TIERS {
1157 let achieved = achieved_containment_class(*tier, true);
1158 assert!(
1159 check_containment_floor(ContainmentClass::Advisory, achieved, None, true).is_ok(),
1160 "workdir_exec at an advisory floor must launch on tier {tier:?}"
1161 );
1162 }
1163 }
1164
1165 #[test]
1166 fn advisory_floor_is_met_on_every_tier() {
1167 for tier in ALL_TIERS {
1168 let achieved = achieved_class_for_tier(*tier);
1169 assert!(
1170 check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok()
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1177 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1178 let achieved = achieved_class_for_tier(tier);
1179 assert!(
1180 check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok()
1181 );
1182 }
1183
1184 for tier in [
1185 EnforcementTier::KernelSeccompOnly,
1186 EnforcementTier::EnvironmentOnly,
1187 ] {
1188 let achieved = achieved_class_for_tier(tier);
1189 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, false)
1190 .expect_err("scoped must refuse without Landlock");
1191 match error {
1192 RuntimeError::ContainmentFloorUnmet {
1193 declared,
1194 achieved,
1195 reason,
1196 } => {
1197 assert_eq!(declared, ContainmentClass::Scoped);
1198 assert_eq!(achieved, ContainmentClass::Advisory);
1199 assert!(
1200 reason.contains("Landlock"),
1201 "reason must name the missing mechanism, got: {reason}"
1202 );
1203 }
1204 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1205 }
1206 }
1207 }
1208
1209 #[test]
1215 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
1216 let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
1217 assert_eq!(achieved, ContainmentClass::Sealed);
1218 assert!(check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok());
1219 assert!(check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok());
1220 assert_eq!(
1221 containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false),
1222 None
1223 );
1224 }
1225
1226 #[test]
1227 fn sealed_floor_is_met_only_on_the_sealed_tier() {
1228 assert!(check_containment_floor(
1229 ContainmentClass::Sealed,
1230 achieved_class_for_tier(EnforcementTier::KernelSealed),
1231 None,
1232 false
1233 )
1234 .is_ok());
1235
1236 for tier in ALL_TIERS
1237 .iter()
1238 .filter(|t| **t != EnforcementTier::KernelSealed)
1239 {
1240 let achieved = achieved_class_for_tier(*tier);
1241 let error = check_containment_floor(
1242 ContainmentClass::Sealed,
1243 achieved,
1244 Some(SealedBlocker::NamespaceCreationDenied),
1245 false,
1246 )
1247 .expect_err("sealed must refuse on a host that cannot back it");
1248 match error {
1249 RuntimeError::ContainmentFloorUnmet {
1250 declared, reason, ..
1251 } => {
1252 assert_eq!(declared, ContainmentClass::Sealed);
1253 assert!(
1254 reason.contains("--cap-add SYS_ADMIN"),
1255 "reason must name the blocking mechanism's remediation, got: {reason}"
1256 );
1257 }
1258 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1259 }
1260 }
1261 }
1262
1263 #[test]
1266 fn the_sealed_refusal_names_the_specific_blocker() {
1267 let achieved = ContainmentClass::Scoped;
1268
1269 let apparmor = containment_shortfall_reason(
1270 ContainmentClass::Sealed,
1271 achieved,
1272 Some(SealedBlocker::AppArmorProfileMissing),
1273 false,
1274 )
1275 .expect("refusal");
1276 assert!(apparmor.contains("mur-sealed"));
1277 assert!(apparmor.contains("apparmor_parser -r"));
1278 assert!(!apparmor.contains("--cap-add"));
1279
1280 let container = containment_shortfall_reason(
1281 ContainmentClass::Sealed,
1282 achieved,
1283 Some(SealedBlocker::NamespaceCreationDenied),
1284 false,
1285 )
1286 .expect("refusal");
1287 assert!(container.contains("--cap-add SYS_ADMIN"));
1288 assert!(container.contains("outside the container"));
1289 assert!(!container.contains("apparmor_parser"));
1290
1291 let pivot = containment_shortfall_reason(
1294 ContainmentClass::Sealed,
1295 ContainmentClass::Advisory,
1296 Some(SealedBlocker::PivotRootDenied),
1297 false,
1298 )
1299 .expect("refusal");
1300 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
1301 assert_ne!(
1302 pivot,
1303 containment_shortfall_reason(
1304 ContainmentClass::Sealed,
1305 ContainmentClass::Advisory,
1306 Some(SealedBlocker::MountDenied),
1307 false,
1308 )
1309 .expect("refusal")
1310 );
1311
1312 for blocker in [
1314 SealedBlocker::NotLinux,
1315 SealedBlocker::AppArmorProfileMissing,
1316 SealedBlocker::NamespaceCreationDenied,
1317 SealedBlocker::MountDenied,
1318 SealedBlocker::PivotRootDenied,
1319 SealedBlocker::KernelUnsupported,
1320 SealedBlocker::LandlockUnavailable,
1321 ] {
1322 let reason = containment_shortfall_reason(
1323 ContainmentClass::Sealed,
1324 achieved,
1325 Some(blocker),
1326 false,
1327 )
1328 .expect("refusal");
1329 assert!(
1330 !reason.contains("no host can provide it today"),
1331 "got: {reason}"
1332 );
1333 }
1334 }
1335
1336 #[test]
1339 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
1340 let reason = containment_shortfall_reason(
1341 ContainmentClass::Sealed,
1342 ContainmentClass::Advisory,
1343 None,
1344 false,
1345 )
1346 .expect("refusal");
1347 assert!(reason.contains("pivot_root"));
1348 assert!(reason.contains("CLONE_NEWNS"));
1349 }
1350
1351 #[test]
1352 fn shortfall_reason_is_absent_when_the_floor_is_met() {
1353 assert_eq!(
1354 containment_shortfall_reason(
1355 ContainmentClass::Advisory,
1356 ContainmentClass::Scoped,
1357 None,
1358 false
1359 ),
1360 None
1361 );
1362 assert_eq!(
1363 containment_shortfall_reason(
1364 ContainmentClass::Scoped,
1365 ContainmentClass::Scoped,
1366 None,
1367 false
1368 ),
1369 None
1370 );
1371 assert_eq!(
1372 containment_shortfall_reason(
1373 ContainmentClass::Sealed,
1374 ContainmentClass::Sealed,
1375 Some(SealedBlocker::NamespaceCreationDenied),
1376 false
1377 ),
1378 None,
1379 "a met floor must ignore a stale blocker rather than manufacture a refusal"
1380 );
1381 }
1382
1383 fn sample_policy() -> CapabilityPolicy {
1384 CapabilityPolicy {
1385 network_allow: vec!["https://api.example.com".to_string()],
1386 unix_sockets_allowed: false,
1387 filesystem_scope: Some("workdir".to_string()),
1388 shell_allow: vec!["python3".to_string()],
1389 spawn_allow: vec!["helper".to_string()],
1390 env_allow: vec!["TZ".to_string()],
1391 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
1392 binary: "python3".to_string(),
1393 dirs: vec![InterpreterRuntimeDir {
1394 path: "/usr/lib/python3.11".to_string(),
1395 list_dir: true,
1396 }],
1397 }],
1398 ..CapabilityPolicy::default()
1399 }
1400 }
1401
1402 #[test]
1403 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
1404 let report = scope_report_for_tier(
1405 &sample_policy(),
1406 ContainmentClass::Scoped,
1407 EnforcementTier::KernelFull,
1408 None,
1409 None,
1410 None,
1411 Vec::new(),
1412 Vec::new(),
1413 Vec::new(),
1414 IoMaxReport::default(),
1415 );
1416
1417 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
1418 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1419 assert!(report.floor_met);
1420 assert_eq!(report.shortfall_reason, None);
1421 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1422 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
1423 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
1424 assert!(!report.unix_sockets);
1425 assert_eq!(report.shell_allow, vec!["python3"]);
1426 assert_eq!(report.spawn_allow, vec!["helper"]);
1427 assert_eq!(report.env_allow, vec!["TZ"]);
1428 assert!(!report.workdir_exec);
1429 assert_eq!(
1430 report.interpreter_runtime_grants,
1431 vec!["python3: /usr/lib/python3.11 (list_dir)"]
1432 );
1433 }
1434
1435 #[test]
1439 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
1440 let policy = CapabilityPolicy {
1441 shell_allow: vec!["gcc".to_string()],
1442 workdir_exec_allowed: true,
1443 ..CapabilityPolicy::default()
1444 };
1445
1446 let report = scope_report_for_tier(
1447 &policy,
1448 ContainmentClass::Scoped,
1449 EnforcementTier::KernelFull,
1450 None,
1451 None,
1452 None,
1453 Vec::new(),
1454 Vec::new(),
1455 Vec::new(),
1456 IoMaxReport::default(),
1457 );
1458
1459 assert!(report.workdir_exec);
1460 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1461 assert!(!report.floor_met);
1462 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1465 assert!(report
1466 .shortfall_reason
1467 .as_deref()
1468 .expect("refusal")
1469 .contains("workdir_exec"));
1470
1471 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1472 assert_eq!(value["workdir_exec"], true);
1473
1474 let rendered = report.render();
1475 assert!(rendered.contains("workdir exec: true"));
1476 assert!(rendered.contains("achieved: advisory"));
1477 }
1478
1479 #[test]
1483 fn scope_report_json_always_carries_io_max() {
1484 let unprobed: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1485 &sample_policy(),
1486 ContainmentClass::Scoped,
1487 EnforcementTier::KernelFull,
1488 None,
1489 None,
1490 None,
1491 Vec::new(),
1492 Vec::new(),
1493 Vec::new(),
1494 IoMaxReport::default(),
1495 ))
1496 .unwrap();
1497 assert_eq!(unprobed["io_max"]["status"], "not-probed");
1498 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
1499 assert!(unprobed["io_max"].get("reason").is_none());
1500
1501 let enforced: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1502 &sample_policy(),
1503 ContainmentClass::Scoped,
1504 EnforcementTier::KernelFull,
1505 None,
1506 None,
1507 None,
1508 Vec::new(),
1509 Vec::new(),
1510 Vec::new(),
1511 IoMaxReport {
1512 declared_bytes_per_sec: 52_428_800,
1513 status: IoMaxStatus::Enforced,
1514 reason: None,
1515 },
1516 ))
1517 .unwrap();
1518 assert_eq!(enforced["io_max"]["status"], "enforced");
1519 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
1520 }
1521
1522 #[test]
1526 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
1527 let rendered = scope_report_for_tier(
1528 &sample_policy(),
1529 ContainmentClass::Scoped,
1530 EnforcementTier::KernelFull,
1531 None,
1532 None,
1533 None,
1534 Vec::new(),
1535 Vec::new(),
1536 Vec::new(),
1537 IoMaxReport {
1538 declared_bytes_per_sec: 52_428_800,
1539 status: IoMaxStatus::Unavailable,
1540 reason: Some("the io controller is not delegated".to_string()),
1541 },
1542 )
1543 .render();
1544
1545 assert!(rendered.contains("Resource plane"), "{rendered}");
1546 assert!(rendered.contains("io.max:"), "{rendered}");
1547 assert!(rendered.contains("unavailable"), "{rendered}");
1548 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
1549 assert!(
1550 rendered.contains("the io controller is not delegated"),
1551 "{rendered}"
1552 );
1553 }
1554
1555 #[test]
1558 fn scope_report_json_always_carries_workdir_exec() {
1559 let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1560 &sample_policy(),
1561 ContainmentClass::Scoped,
1562 EnforcementTier::KernelFull,
1563 None,
1564 None,
1565 None,
1566 Vec::new(),
1567 Vec::new(),
1568 Vec::new(),
1569 IoMaxReport::default(),
1570 ))
1571 .unwrap();
1572 assert_eq!(value["workdir_exec"], false);
1573 }
1574
1575 #[test]
1579 fn staged_runtime_grants_are_reported_on_every_tier() {
1580 let policy = CapabilityPolicy {
1581 shell_allow: vec!["python3".to_string()],
1582 shell_staged_runtime: vec![StagedRuntimeGrant {
1583 binary: "python3".to_string(),
1584 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
1585 pin: "conda-4.10.3/python-3.9.19".to_string(),
1586 }],
1587 ..CapabilityPolicy::default()
1588 };
1589
1590 for tier in ALL_TIERS {
1591 let report = scope_report_for_tier(
1592 &policy,
1593 ContainmentClass::Sealed,
1594 *tier,
1595 Some(SealedBlocker::NamespaceCreationDenied),
1596 None,
1597 None,
1598 Vec::new(),
1599 Vec::new(),
1600 Vec::new(),
1601 IoMaxReport::default(),
1602 );
1603 assert_eq!(
1604 report.staged_runtime_grants,
1605 vec![
1606 "python3: /opt/testbed/conda/envs/django__django \
1607 (pin: conda-4.10.3/python-3.9.19)"
1608 ],
1609 "tier {tier:?} must still report the declared staged runtime"
1610 );
1611 assert!(
1612 report.render().contains("staged runtime"),
1613 "the rendered report must carry a staged runtime section on tier {tier:?}"
1614 );
1615 }
1616 }
1617
1618 #[test]
1621 fn absent_staged_runtime_reports_nothing() {
1622 let report = scope_report_for_tier(
1623 &sample_policy(),
1624 ContainmentClass::Scoped,
1625 EnforcementTier::KernelFull,
1626 None,
1627 None,
1628 None,
1629 Vec::new(),
1630 Vec::new(),
1631 Vec::new(),
1632 IoMaxReport::default(),
1633 );
1634 assert!(report.staged_runtime_grants.is_empty());
1635 assert!(report.render().contains("staged runtime: <none>"));
1636 }
1637
1638 #[test]
1642 fn grant_set_never_raises_the_achieved_class() {
1643 let report = scope_report_for_tier(
1644 &sample_policy(),
1645 ContainmentClass::Sealed,
1646 EnforcementTier::KernelFull,
1647 Some(SealedBlocker::NamespaceCreationDenied),
1648 None,
1649 None,
1650 Vec::new(),
1651 Vec::new(),
1652 Vec::new(),
1653 IoMaxReport::default(),
1654 );
1655 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1656 assert!(!report.floor_met);
1657 }
1658
1659 #[test]
1660 fn scope_report_still_reports_when_the_floor_is_unmet() {
1661 let report = scope_report_for_tier(
1662 &sample_policy(),
1663 ContainmentClass::Sealed,
1664 EnforcementTier::EnvironmentOnly,
1665 Some(SealedBlocker::NotLinux),
1666 None,
1667 None,
1668 Vec::new(),
1669 Vec::new(),
1670 Vec::new(),
1671 IoMaxReport::default(),
1672 );
1673
1674 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
1675 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1676 assert!(!report.floor_met);
1677 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
1678 assert_eq!(report.enforcement_tier, "none");
1679 }
1680
1681 #[test]
1682 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
1683 let report = scope_report_for_tier(
1684 &sample_policy(),
1685 ContainmentClass::Sealed,
1686 EnforcementTier::KernelSealed,
1687 None,
1688 None,
1689 None,
1690 Vec::new(),
1691 Vec::new(),
1692 Vec::new(),
1693 IoMaxReport::default(),
1694 );
1695 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
1696 assert!(report.floor_met);
1697 assert_eq!(
1698 report.enforcement_tier,
1699 "mountns+pivot_root+landlock+seccomp"
1700 );
1701 }
1702
1703 #[test]
1704 fn scope_report_json_uses_wire_names() {
1705 let report = scope_report_for_tier(
1706 &CapabilityPolicy::default(),
1707 ContainmentClass::Advisory,
1708 EnforcementTier::KernelSeccompOnly,
1709 None,
1710 None,
1711 None,
1712 Vec::new(),
1713 Vec::new(),
1714 Vec::new(),
1715 IoMaxReport::default(),
1716 );
1717 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1718
1719 assert_eq!(value["declared_containment"], "advisory");
1720 assert_eq!(value["achieved_containment"], "advisory");
1721 assert_eq!(value["floor_met"], true);
1722 assert_eq!(value["enforcement_tier"], "seccomp-only");
1723 assert!(value.get("shortfall_reason").is_none());
1725 }
1726
1727 #[test]
1730 fn the_userns_grant_is_reported_without_changing_any_verdict() {
1731 let baseline = scope_report_for_tier(
1732 &sample_policy(),
1733 ContainmentClass::Advisory,
1734 EnforcementTier::KernelSealed,
1735 None,
1736 None,
1737 None,
1738 Vec::new(),
1739 Vec::new(),
1740 Vec::new(),
1741 IoMaxReport::default(),
1742 );
1743
1744 for grant in UsernsGrant::ALL {
1745 let report = scope_report_for_tier(
1746 &sample_policy(),
1747 ContainmentClass::Advisory,
1748 EnforcementTier::KernelSealed,
1749 None,
1750 Some(*grant),
1751 None,
1752 Vec::new(),
1753 Vec::new(),
1754 Vec::new(),
1755 IoMaxReport::default(),
1756 );
1757 assert_eq!(report.achieved_containment, baseline.achieved_containment);
1758 assert_eq!(report.floor_met, baseline.floor_met);
1759 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
1760 assert_eq!(report.userns_grant, Some(*grant));
1761
1762 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1763 assert_eq!(value["userns_grant"], grant.wire_name());
1764 assert!(report
1765 .render()
1766 .contains(&format!("userns grant: {}", grant.wire_name())));
1767 }
1768
1769 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
1772 assert!(value["userns_grant"].is_null());
1773 assert!(baseline.render().contains("userns grant: n/a"));
1774 }
1775
1776 #[test]
1777 fn rendered_report_names_both_classes_and_the_refusal() {
1778 let rendered = scope_report_for_tier(
1779 &sample_policy(),
1780 ContainmentClass::Sealed,
1781 EnforcementTier::KernelFull,
1782 Some(SealedBlocker::AppArmorProfileMissing),
1783 None,
1784 None,
1785 Vec::new(),
1786 Vec::new(),
1787 Vec::new(),
1788 IoMaxReport::default(),
1789 )
1790 .render();
1791
1792 assert!(rendered.contains("declared: sealed"));
1793 assert!(rendered.contains("achieved: scoped"));
1794 assert!(rendered.contains("floor met: no"));
1795 assert!(rendered.contains("mur-sealed"));
1796 assert!(rendered.contains("would refuse to launch"));
1797 }
1798
1799 #[test]
1803 fn an_export_never_changes_what_the_report_says_about_containment() {
1804 let export = FileExport {
1805 root: "out/".to_string(),
1806 mode: ExportMode::ReadOnly,
1807 max_bytes: 10 * 1024 * 1024,
1808 };
1809 for tier in ALL_TIERS {
1810 for workdir_exec in [false, true] {
1811 let policy = CapabilityPolicy {
1812 workdir_exec_allowed: workdir_exec,
1813 ..sample_policy()
1814 };
1815 let without = scope_report_for_tier(
1816 &policy,
1817 ContainmentClass::Scoped,
1818 *tier,
1819 None,
1820 None,
1821 None,
1822 Vec::new(),
1823 Vec::new(),
1824 Vec::new(),
1825 IoMaxReport::default(),
1826 );
1827 let with = scope_report_for_tier(
1828 &policy,
1829 ContainmentClass::Scoped,
1830 *tier,
1831 None,
1832 None,
1833 Some(&Exports {
1834 files: Some(export.clone()),
1835 peer_files: None,
1836 }),
1837 Vec::new(),
1838 Vec::new(),
1839 Vec::new(),
1840 IoMaxReport::default(),
1841 );
1842 assert_eq!(
1843 with.achieved_containment, without.achieved_containment,
1844 "tier {tier:?}, workdir_exec {workdir_exec}"
1845 );
1846 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1847 assert_eq!(with.floor_met, without.floor_met);
1848 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1849 assert_eq!(
1852 ScopeReport {
1853 exports_files: None,
1854 ..with.clone()
1855 },
1856 without
1857 );
1858 assert_eq!(
1859 with.exports_files,
1860 Some(ExportsFilesReport {
1861 root: "out/".to_string(),
1862 mode: ExportMode::ReadOnly,
1863 max_bytes: 10 * 1024 * 1024,
1864 })
1865 );
1866 }
1867 }
1868 }
1869
1870 #[test]
1874 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
1875 let peer_files = PeerFilesExport {
1876 root: "out/".to_string(),
1877 max_ttl_secs: Some(900),
1878 max_bytes: 10 * 1024 * 1024,
1879 };
1880 for tier in ALL_TIERS {
1881 for workdir_exec in [false, true] {
1882 let without_policy = CapabilityPolicy {
1883 workdir_exec_allowed: workdir_exec,
1884 ..sample_policy()
1885 };
1886 let with_policy = CapabilityPolicy {
1887 peer_fetch_allow: vec!["localhost:41234".to_string()],
1888 ..without_policy.clone()
1889 };
1890 let without = scope_report_for_tier(
1891 &without_policy,
1892 ContainmentClass::Scoped,
1893 *tier,
1894 None,
1895 None,
1896 None,
1897 Vec::new(),
1898 Vec::new(),
1899 Vec::new(),
1900 IoMaxReport::default(),
1901 );
1902 let with = scope_report_for_tier(
1903 &with_policy,
1904 ContainmentClass::Scoped,
1905 *tier,
1906 None,
1907 None,
1908 Some(&Exports {
1909 files: None,
1910 peer_files: Some(peer_files.clone()),
1911 }),
1912 Vec::new(),
1913 Vec::new(),
1914 Vec::new(),
1915 IoMaxReport::default(),
1916 );
1917 assert_eq!(
1918 with.achieved_containment, without.achieved_containment,
1919 "tier {tier:?}, workdir_exec {workdir_exec}"
1920 );
1921 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1922 assert_eq!(with.floor_met, without.floor_met);
1923 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1924 assert_eq!(
1925 ScopeReport {
1926 peer_files: None,
1927 peer_fetch_allow: Vec::new(),
1928 ..with.clone()
1929 },
1930 without
1931 );
1932 assert_eq!(
1933 with.peer_files,
1934 Some(PeerFilesReport {
1935 root: "out/".to_string(),
1936 max_ttl_secs: 900,
1937 max_bytes: 10 * 1024 * 1024,
1938 })
1939 );
1940 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
1941 assert_eq!(with.network_allow, without.network_allow);
1945 }
1946 }
1947 }
1948
1949 #[test]
1953 fn the_peer_fields_are_always_serialized() {
1954 let undeclared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1955 &sample_policy(),
1956 ContainmentClass::Advisory,
1957 EnforcementTier::KernelSealed,
1958 None,
1959 None,
1960 None,
1961 Vec::new(),
1962 Vec::new(),
1963 Vec::new(),
1964 IoMaxReport::default(),
1965 ))
1966 .unwrap();
1967 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
1968 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
1969
1970 let declared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1971 &CapabilityPolicy {
1972 peer_fetch_allow: vec!["localhost:41234".to_string()],
1973 ..sample_policy()
1974 },
1975 ContainmentClass::Advisory,
1976 EnforcementTier::KernelSealed,
1977 None,
1978 None,
1979 Some(&Exports {
1980 files: None,
1981 peer_files: Some(PeerFilesExport {
1982 root: "out/".to_string(),
1983 max_ttl_secs: None,
1984 max_bytes: 10 * 1024 * 1024,
1985 }),
1986 }),
1987 Vec::new(),
1988 Vec::new(),
1989 Vec::new(),
1990 IoMaxReport::default(),
1991 ))
1992 .unwrap();
1993 assert_eq!(
1994 declared["peer_files"],
1995 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
1996 );
1997 assert_eq!(
1998 declared["peer_fetch_allow"],
1999 serde_json::json!(["localhost:41234"])
2000 );
2001 }
2002
2003 #[test]
2004 fn render_names_peer_handoff_in_both_directions() {
2005 let undeclared = scope_report_for_tier(
2006 &sample_policy(),
2007 ContainmentClass::Advisory,
2008 EnforcementTier::KernelSealed,
2009 None,
2010 None,
2011 None,
2012 Vec::new(),
2013 Vec::new(),
2014 Vec::new(),
2015 IoMaxReport::default(),
2016 )
2017 .render();
2018 assert!(undeclared.contains("Peer handoff"));
2019 assert!(undeclared.contains("exports.peer_files: <none>"));
2020 assert!(undeclared.contains("peer_fetch allow: <none>"));
2021
2022 let declared = scope_report_for_tier(
2023 &CapabilityPolicy {
2024 peer_fetch_allow: vec!["localhost:41234".to_string()],
2025 ..sample_policy()
2026 },
2027 ContainmentClass::Advisory,
2028 EnforcementTier::KernelSealed,
2029 None,
2030 None,
2031 Some(&Exports {
2032 files: None,
2033 peer_files: Some(PeerFilesExport {
2034 root: "out/".to_string(),
2035 max_ttl_secs: Some(900),
2036 max_bytes: 4096,
2037 }),
2038 }),
2039 Vec::new(),
2040 Vec::new(),
2041 Vec::new(),
2042 IoMaxReport::default(),
2043 )
2044 .render();
2045 assert!(declared.contains("exports.peer_files root: out/"));
2046 assert!(declared.contains("max handle ttl: 900s"));
2047 assert!(declared.contains("max bytes: 4096 (per file)"));
2048 assert!(declared.contains("- localhost:41234"));
2049 }
2050
2051 #[test]
2055 fn a_state_store_is_reported_and_moves_no_other_field() {
2056 let policy = sample_policy();
2057 let stores = vec![StateStoreReport {
2058 artifact: "murmur-tool-corpus".to_string(),
2059 store: "shey".to_string(),
2060 host_path: "/home/dev/.murmur/state/shey".to_string(),
2061 }];
2062
2063 let without = scope_report_for_tier(
2064 &policy,
2065 ContainmentClass::Scoped,
2066 EnforcementTier::KernelFull,
2067 None,
2068 None,
2069 None,
2070 Vec::new(),
2071 Vec::new(),
2072 Vec::new(),
2073 IoMaxReport::default(),
2074 );
2075 let with = scope_report_for_tier(
2076 &policy,
2077 ContainmentClass::Scoped,
2078 EnforcementTier::KernelFull,
2079 None,
2080 None,
2081 None,
2082 stores.clone(),
2083 Vec::new(),
2084 Vec::new(),
2085 IoMaxReport::default(),
2086 );
2087
2088 assert_eq!(with.state_stores, stores);
2089 assert!(without.state_stores.is_empty());
2090 assert_eq!(with.achieved_containment, without.achieved_containment);
2091 assert_eq!(with.floor_met, without.floor_met);
2092 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2093 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2094 assert_eq!(
2096 ScopeReport {
2097 state_stores: Vec::new(),
2098 ..with.clone()
2099 },
2100 without
2101 );
2102
2103 assert!(with
2105 .render()
2106 .contains("- murmur-tool-corpus: shey -> /home/dev/.murmur/state/shey"));
2107 assert!(without.render().contains("state stores: <none>"));
2108
2109 let json = serde_json::to_value(&without).unwrap();
2112 assert_eq!(json["state_stores"], serde_json::json!([]));
2113 assert_eq!(
2114 serde_json::to_value(&with).unwrap()["state_stores"],
2115 serde_json::json!([{
2116 "artifact": "murmur-tool-corpus",
2117 "store": "shey",
2118 "host_path": "/home/dev/.murmur/state/shey",
2119 }])
2120 );
2121 }
2122
2123 #[test]
2128 fn preopens_are_reported_and_move_no_other_field() {
2129 let policy = sample_policy();
2130 let preopens = vec![
2131 PreopenReport {
2132 artifact: "notes-tool".to_string(),
2133 role: "tool".to_string(),
2134 scope: None,
2135 surface: PreopenSurface::WholeWorkdir,
2136 },
2137 PreopenReport {
2138 artifact: "cache-tool".to_string(),
2139 role: "tool".to_string(),
2140 scope: Some("cache".to_string()),
2141 surface: PreopenSurface::ScopedSubtree,
2142 },
2143 PreopenReport {
2144 artifact: "telemetry-hook".to_string(),
2145 role: "hook".to_string(),
2146 scope: None,
2147 surface: PreopenSurface::Nothing,
2148 },
2149 ];
2150
2151 let without = scope_report_for_tier(
2152 &policy,
2153 ContainmentClass::Scoped,
2154 EnforcementTier::KernelFull,
2155 None,
2156 None,
2157 None,
2158 Vec::new(),
2159 Vec::new(),
2160 Vec::new(),
2161 IoMaxReport::default(),
2162 );
2163 let with = scope_report_for_tier(
2164 &policy,
2165 ContainmentClass::Scoped,
2166 EnforcementTier::KernelFull,
2167 None,
2168 None,
2169 None,
2170 Vec::new(),
2171 Vec::new(),
2172 preopens.clone(),
2173 IoMaxReport::default(),
2174 );
2175
2176 assert_eq!(with.preopens, preopens);
2177 assert!(without.preopens.is_empty());
2178 assert_eq!(
2180 ScopeReport {
2181 preopens: Vec::new(),
2182 ..with.clone()
2183 },
2184 without
2185 );
2186
2187 let rendered = with.render();
2189 assert!(rendered.contains(
2190 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
2191 declared"
2192 ));
2193 assert!(rendered.contains(
2194 "- cache-tool (tool): one subtree of the accessible workdir — \
2195 capabilities.filesystem.scope: cache"
2196 ));
2197 assert!(rendered.contains(
2198 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
2199 ));
2200 assert!(without.render().contains("preopens: <none>"));
2201
2202 assert_eq!(
2204 serde_json::to_value(&without).unwrap()["preopens"],
2205 serde_json::json!([])
2206 );
2207 assert_eq!(
2208 serde_json::to_value(&with).unwrap()["preopens"],
2209 serde_json::json!([
2210 {
2211 "artifact": "notes-tool",
2212 "role": "tool",
2213 "scope": null,
2214 "surface": "whole-workdir",
2215 },
2216 {
2217 "artifact": "cache-tool",
2218 "role": "tool",
2219 "scope": "cache",
2220 "surface": "scoped-subtree",
2221 },
2222 {
2223 "artifact": "telemetry-hook",
2224 "role": "hook",
2225 "scope": null,
2226 "surface": "nothing",
2227 },
2228 ])
2229 );
2230 }
2231
2232 #[test]
2236 fn exports_files_is_always_serialized() {
2237 let undeclared = serde_json::to_value(scope_report_for_tier(
2238 &sample_policy(),
2239 ContainmentClass::Advisory,
2240 EnforcementTier::KernelFull,
2241 None,
2242 None,
2243 None,
2244 Vec::new(),
2245 Vec::new(),
2246 Vec::new(),
2247 IoMaxReport::default(),
2248 ))
2249 .unwrap();
2250 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
2251
2252 let declared = serde_json::to_value(scope_report_for_tier(
2253 &sample_policy(),
2254 ContainmentClass::Advisory,
2255 EnforcementTier::KernelFull,
2256 None,
2257 None,
2258 Some(&Exports {
2259 files: Some(FileExport {
2260 root: "out/".to_string(),
2261 mode: ExportMode::ReadOnly,
2262 max_bytes: 10_485_760,
2263 }),
2264 peer_files: None,
2265 }),
2266 Vec::new(),
2267 Vec::new(),
2268 Vec::new(),
2269 IoMaxReport::default(),
2270 ))
2271 .unwrap();
2272 assert_eq!(
2273 declared["exports_files"],
2274 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
2275 );
2276 }
2277
2278 #[test]
2279 fn render_names_the_resource_plane_in_both_directions() {
2280 let undeclared = scope_report_for_tier(
2281 &sample_policy(),
2282 ContainmentClass::Advisory,
2283 EnforcementTier::KernelFull,
2284 None,
2285 None,
2286 None,
2287 Vec::new(),
2288 Vec::new(),
2289 Vec::new(),
2290 IoMaxReport::default(),
2291 )
2292 .render();
2293 assert!(
2294 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
2295 "{undeclared}"
2296 );
2297
2298 let declared = scope_report_for_tier(
2299 &sample_policy(),
2300 ContainmentClass::Advisory,
2301 EnforcementTier::KernelFull,
2302 None,
2303 None,
2304 Some(&Exports {
2305 files: Some(FileExport {
2306 root: "out/".to_string(),
2307 mode: ExportMode::ReadOnly,
2308 max_bytes: 10_485_760,
2309 }),
2310 peer_files: None,
2311 }),
2312 Vec::new(),
2313 Vec::new(),
2314 Vec::new(),
2315 IoMaxReport::default(),
2316 )
2317 .render();
2318 assert!(declared.contains("exports.files root: out/"), "{declared}");
2319 assert!(
2320 declared.contains("mode: read-only"),
2321 "{declared}"
2322 );
2323 assert!(
2324 declared.contains("max bytes: 10485760 (per file)"),
2325 "{declared}"
2326 );
2327 }
2328
2329 fn report_for(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
2332 let policy = CapabilityPolicy {
2333 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
2334 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
2335 ..CapabilityPolicy::default()
2336 };
2337 scope_report_for_tier(
2338 &policy,
2339 ContainmentClass::Scoped,
2340 EnforcementTier::KernelFull,
2341 None,
2342 None,
2343 None,
2344 Vec::new(),
2345 Vec::new(),
2346 Vec::new(),
2347 IoMaxReport::default(),
2348 )
2349 }
2350
2351 #[test]
2354 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
2355 let report = report_for(&["tests", "bench/fixtures"], &["git"]);
2356 assert_eq!(
2357 report.read_only_paths,
2358 vec!["tests".to_string(), "bench/fixtures".to_string()]
2359 );
2360 assert!(report.read_only_advisory_for.is_empty());
2361
2362 let rendered = report.render();
2363 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
2364 assert!(rendered.contains(
2365 "read_only enforcement: enforced for every tool call and every shell command the \
2366 dispatch check can read"
2367 ));
2368 assert!(!rendered.contains("advisory against"));
2369 }
2370
2371 #[test]
2374 fn an_allowlisted_interpreter_is_named_as_advisory() {
2375 let report = report_for(&["tests"], &["git", "python3"]);
2376 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
2377
2378 let rendered = report.render();
2379 assert!(rendered.contains("read_only enforcement: advisory against python3"));
2380 assert!(rendered
2381 .contains("enforced for every tool call and for every shell command it can read"));
2382 }
2383
2384 #[test]
2388 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
2389 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
2390 .iter()
2391 .map(|b| (*b).to_string())
2392 .collect();
2393 let declared = vec!["tests".to_string()];
2394 assert_eq!(
2395 report_for(&["tests"], &["git", "python3", "node", "make", "bash"])
2396 .read_only_advisory_for,
2397 read_only_advisory_for(&declared, &shell_allow)
2398 );
2399 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
2401 }
2402
2403 #[test]
2407 fn both_read_only_keys_are_always_serialized_arrays() {
2408 let json = serde_json::to_value(report_for(&[], &["python3"])).unwrap();
2409 assert_eq!(json["read_only_paths"], serde_json::json!([]));
2410 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
2411
2412 let json = serde_json::to_value(report_for(&["tests"], &["python3"])).unwrap();
2413 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
2414 assert_eq!(
2415 json["read_only_advisory_for"],
2416 serde_json::json!(["python3"])
2417 );
2418 }
2419
2420 #[test]
2423 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
2424 let rendered = report_for(&[], &["python3"]).render();
2425 assert!(rendered.contains(" read_only: <none>\n"));
2426 assert!(!rendered.contains("read_only enforcement:"));
2427 }
2428
2429 #[test]
2441 fn every_capability_policy_field_is_reported_or_exempted() {
2442 const EXEMPT: &[(&str, &str)] = &[
2444 (
2445 "shell_strip_env",
2446 "Names host variables removed from the shell child's environment on top of the \
2447 credential patterns the runtime always strips. It subtracts from what the \
2448 capsule sees and can open nothing, so reporting it beside the grants would pad \
2449 the list an operator audits for reach.",
2450 ),
2451 (
2452 "shell_baseline_env",
2453 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
2454 environment of a subprocess the capsule was already granted, and names no host \
2455 resource `shell allow` and `env allow` do not already account for.",
2456 ),
2457 (
2458 "limits",
2459 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
2460 deadline. A bound on how much the capsule may consume, never a reach it gains.",
2461 ),
2462 (
2463 "resources",
2464 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
2465 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
2466 ),
2467 (
2468 "declared_deadline_seconds",
2469 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
2470 can apply a lower default. Reporting it would report one ceiling twice.",
2471 ),
2472 (
2473 "state_declared",
2474 "Records that a capsule-wide `capabilities.state` block was declared where \
2475 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
2476 exist are reported per artifact in `state_stores`.",
2477 ),
2478 (
2479 "conversation_declared",
2480 "Records a capsule-wide `capabilities.conversation` block that reaches no \
2481 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
2482 and the grant itself is per artifact.",
2483 ),
2484 ];
2485
2486 const REPORTED: &[(&str, &str)] = &[
2488 ("network_allow", "network_allow"),
2489 ("peer_fetch_allow", "peer_fetch_allow"),
2490 ("unix_sockets_allowed", "unix_sockets"),
2491 ("filesystem_scope", "filesystem_scope"),
2492 ("workdir_exec_allowed", "workdir_exec"),
2493 ("read_only_paths", "read_only_paths"),
2494 ("shell_allow", "shell_allow"),
2495 ("spawn_allow", "spawn_allow"),
2496 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
2497 ("shell_staged_runtime", "staged_runtime_grants"),
2498 ("env_allow", "env_allow"),
2499 ("plan_submit", "plan_submit"),
2500 ("containment_floor", "declared_containment"),
2501 ];
2502
2503 let declared = capability_policy_field_names(include_str!("types.rs"));
2504 assert!(
2505 declared.len() > 10,
2506 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
2507 );
2508
2509 for field in &declared {
2510 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
2511 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
2512 assert!(
2513 reported || exempt,
2514 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
2515 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
2516 with a written reason for why an operator does not need to see it."
2517 );
2518 assert!(
2519 !(reported && exempt),
2520 "CapabilityPolicy::{field} is listed as both reported and exempt"
2521 );
2522 }
2523
2524 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
2525 assert!(
2526 declared.iter().any(|field| field == policy_field),
2527 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
2528 declares — remove the stale row"
2529 );
2530 }
2531
2532 let json = serde_json::to_value(scope_report_for_tier(
2535 &sample_policy(),
2536 ContainmentClass::Scoped,
2537 EnforcementTier::KernelFull,
2538 None,
2539 None,
2540 None,
2541 Vec::new(),
2542 Vec::new(),
2543 Vec::new(),
2544 IoMaxReport::default(),
2545 ))
2546 .unwrap();
2547 let object = json.as_object().expect("a scope report is a JSON object");
2548 for (policy_field, report_key) in REPORTED {
2549 assert!(
2550 object.contains_key(*report_key),
2551 "the table maps CapabilityPolicy::{policy_field} to a report key \
2552 '{report_key}' that a serialized ScopeReport does not carry"
2553 );
2554 }
2555 }
2556
2557 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
2560 scope_report_for_tier(
2561 &sample_policy(),
2562 declared,
2563 tier,
2564 None,
2565 None,
2566 None,
2567 Vec::new(),
2568 Vec::new(),
2569 Vec::new(),
2570 IoMaxReport::default(),
2571 )
2572 }
2573
2574 #[test]
2576 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
2577 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2578
2579 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2580 assert_eq!(
2581 report.filesystem_boundary.restriction,
2582 FilesystemRestriction::Advisory
2583 );
2584 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2585
2586 let filesystem = &report.filesystem_boundary.not_protected[0];
2587 assert!(filesystem.contains("advisory"), "{filesystem}");
2588 assert!(
2589 filesystem.contains("denies a read or a write outside the session workdir"),
2590 "{filesystem}"
2591 );
2592
2593 let rewriting = &report.filesystem_boundary.not_protected[1];
2594 assert!(rewriting.contains("not a boundary"), "{rewriting}");
2595 assert!(rewriting.contains("HOME"), "{rewriting}");
2596
2597 let rendered = report.render();
2598 let heading = rendered
2599 .find("\nNot protected here\n")
2600 .expect("a session that composes no root prints the block");
2601 assert!(
2602 heading > rendered.find("Containment\n").unwrap(),
2603 "the block belongs under Containment:\n{rendered}"
2604 );
2605 assert!(
2606 heading < rendered.find("\nEffective grants\n").unwrap(),
2607 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
2608 );
2609 for statement in &report.filesystem_boundary.not_protected {
2610 assert!(rendered.contains(statement.as_str()), "{rendered}");
2611 }
2612 }
2613
2614 #[test]
2617 fn a_landlock_session_reports_denial_rather_than_absence() {
2618 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2619
2620 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2621 assert_eq!(
2622 report.filesystem_boundary.restriction,
2623 FilesystemRestriction::Enforced
2624 );
2625 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2626
2627 let filesystem = &report.filesystem_boundary.not_protected[0];
2628 assert!(
2629 !filesystem.contains("advisory"),
2630 "Landlock mediates the filesystem here: {filesystem}"
2631 );
2632 assert!(
2633 filesystem.contains("denied rather than absent"),
2634 "{filesystem}"
2635 );
2636 for probe in ["stat", "access", "readlink"] {
2637 assert!(filesystem.contains(probe), "{filesystem}");
2638 }
2639 }
2640
2641 #[test]
2646 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
2647 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
2648
2649 assert_eq!(
2650 report.achieved_containment,
2651 ContainmentClass::Sealed,
2652 "the host can back sealed, which is what `achieved` answers"
2653 );
2654 assert_eq!(
2655 report.filesystem_boundary.restriction,
2656 FilesystemRestriction::Enforced,
2657 "no composed root is installed for a capsule that did not declare one"
2658 );
2659 assert_eq!(
2660 report.filesystem_boundary.not_protected,
2661 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
2662 .filesystem_boundary
2663 .not_protected,
2664 "the same mechanism gets the same statements on either host"
2665 );
2666 assert!(report.render().contains("Not protected here"));
2667 }
2668
2669 #[test]
2672 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
2673 let advisory =
2674 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2675 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2676
2677 assert_eq!(
2678 advisory.filesystem_boundary.not_protected[1],
2679 enforced.filesystem_boundary.not_protected[1]
2680 );
2681 assert!(
2682 advisory.filesystem_boundary.not_protected[1]
2683 .contains(crate::shell::SYNTHETIC_HOME_DIR_NAME),
2684 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
2685 );
2686 }
2687
2688 #[test]
2691 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
2692 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
2693
2694 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2695 assert_eq!(
2696 report.filesystem_boundary.restriction,
2697 FilesystemRestriction::Absent
2698 );
2699 assert!(report.filesystem_boundary.not_protected.is_empty());
2700 assert!(
2701 !report.render().contains("Not protected here"),
2702 "{}",
2703 report.render()
2704 );
2705
2706 let json = serde_json::to_value(&report).unwrap();
2707 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
2708 assert_eq!(
2709 json["filesystem_boundary"]["not_protected"],
2710 serde_json::json!([]),
2711 "the key is present and empty at absent, never missing"
2712 );
2713 }
2714
2715 #[test]
2719 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
2720 let policy = CapabilityPolicy {
2721 workdir_exec_allowed: true,
2722 ..CapabilityPolicy::default()
2723 };
2724
2725 let report = scope_report_for_tier(
2726 &policy,
2727 ContainmentClass::Advisory,
2728 EnforcementTier::KernelSealed,
2729 None,
2730 None,
2731 None,
2732 Vec::new(),
2733 Vec::new(),
2734 Vec::new(),
2735 IoMaxReport::default(),
2736 );
2737
2738 assert_eq!(
2739 report.enforcement_tier,
2740 "mountns+pivot_root+landlock+seccomp"
2741 );
2742 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2743 assert_eq!(
2744 report.filesystem_boundary.restriction,
2745 FilesystemRestriction::Enforced
2746 );
2747 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2748 assert!(report.render().contains("Not protected here"));
2749 }
2750
2751 #[test]
2754 fn the_restriction_follows_the_tier_the_session_would_install() {
2755 for tier in ALL_TIERS {
2756 for declared in [
2757 ContainmentClass::Advisory,
2758 ContainmentClass::Scoped,
2759 ContainmentClass::Sealed,
2760 ] {
2761 let report = boundary_report(declared, *tier);
2762 let expected = match crate::sandbox::applied_tier(*tier, declared) {
2763 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
2764 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
2765 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
2766 FilesystemRestriction::Advisory
2767 }
2768 };
2769 assert_eq!(
2770 report.filesystem_boundary.restriction, expected,
2771 "{tier:?} / {declared:?}"
2772 );
2773 assert_eq!(
2774 report.filesystem_boundary.not_protected.is_empty(),
2775 expected == FilesystemRestriction::Absent,
2776 "{tier:?} / {declared:?}"
2777 );
2778 let wire = match expected {
2779 FilesystemRestriction::Advisory => "advisory",
2780 FilesystemRestriction::Enforced => "enforced",
2781 FilesystemRestriction::Absent => "absent",
2782 };
2783 assert_eq!(
2784 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
2785 serde_json::Value::String(wire.to_string())
2786 );
2787 }
2788 }
2789 }
2790
2791 #[test]
2804 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
2805 if crate::network_namespace::skip_without_egress_namespace(
2806 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
2807 ) {
2808 return;
2809 }
2810 if crate::sandbox::find_on_path("bash").is_none() {
2811 eprintln!(
2812 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
2813 no bash on PATH"
2814 );
2815 return;
2816 }
2817 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
2818 let secret = outside.path().join("host-file");
2819 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
2820 let secret = secret.to_str().unwrap().to_string();
2821
2822 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
2823 let workdir = tempfile::tempdir().unwrap();
2824 let policy = CapabilityPolicy {
2825 shell_allow: vec!["bash".to_string()],
2826 ..CapabilityPolicy::default()
2827 };
2828 let probe = crate::sandbox::HostProbe::probe();
2829 let enforcement =
2830 crate::sandbox::ShellEnforcement::resolve(&policy, declared, probe).unwrap();
2831 let report = scope_report_for_tier(
2832 &policy,
2833 declared,
2834 probe.tier(),
2835 None,
2836 None,
2837 None,
2838 Vec::new(),
2839 Vec::new(),
2840 Vec::new(),
2841 IoMaxReport::default(),
2842 );
2843
2844 let command = format!(
2845 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
2846 else echo read=refused; fi; \
2847 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
2848 );
2849 let result = crate::shell::execute_shell(
2850 "bash",
2851 &["-c", &command],
2852 &[],
2853 workdir.path(),
2854 workdir.path(),
2855 &policy,
2856 &enforcement,
2857 )
2858 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
2859 let seen = result.stdout;
2860
2861 match report.filesystem_boundary.restriction {
2862 FilesystemRestriction::Advisory => assert!(
2863 seen.contains("read=PROBE-CONTENTS"),
2864 "reported advisory, so nothing denies the read: {seen}"
2865 ),
2866 FilesystemRestriction::Enforced => assert!(
2867 seen.contains("read=refused") && seen.contains("exists=yes"),
2868 "reported enforced, so the read is denied and the path still exists: {seen}"
2869 ),
2870 FilesystemRestriction::Absent => assert!(
2871 seen.contains("read=refused") && seen.contains("exists=no"),
2872 "reported absent, so the path is not in the composed root: {seen}"
2873 ),
2874 }
2875 }
2876 }
2877
2878 fn capability_policy_field_names(source: &str) -> Vec<String> {
2880 source
2881 .split_once("pub struct CapabilityPolicy {")
2882 .expect("types.rs declares CapabilityPolicy")
2883 .1
2884 .split_once("\n}")
2885 .expect("CapabilityPolicy's declaration is closed at column 0")
2886 .0
2887 .lines()
2888 .filter_map(|line| {
2889 line.strip_prefix(" pub ")
2890 .and_then(|rest| rest.split_once(':'))
2891 .map(|(name, _)| name.to_string())
2892 })
2893 .collect()
2894 }
2895}