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)]
1009mod tests {
1010 use super::*;
1011 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1012
1013 const ALL_TIERS: &[EnforcementTier] = &[
1015 EnforcementTier::KernelSealed,
1016 EnforcementTier::KernelFull,
1017 EnforcementTier::KernelSeccompOnly,
1018 EnforcementTier::EnvironmentOnly,
1019 ];
1020
1021 #[test]
1022 fn kernel_full_achieves_scoped() {
1023 assert_eq!(
1024 achieved_class_for_tier(EnforcementTier::KernelFull),
1025 ContainmentClass::Scoped
1026 );
1027 }
1028
1029 #[test]
1031 fn only_the_sealed_tier_achieves_sealed() {
1032 assert_eq!(
1033 achieved_class_for_tier(EnforcementTier::KernelSealed),
1034 ContainmentClass::Sealed
1035 );
1036 for tier in ALL_TIERS
1037 .iter()
1038 .filter(|t| **t != EnforcementTier::KernelSealed)
1039 {
1040 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1041 }
1042 }
1043
1044 #[test]
1045 fn tiers_without_landlock_achieve_only_advisory() {
1046 assert_eq!(
1047 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1048 ContainmentClass::Advisory
1049 );
1050 assert_eq!(
1051 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1052 ContainmentClass::Advisory
1053 );
1054 }
1055
1056 #[test]
1062 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1063 assert_eq!(
1065 achieved_containment_class(EnforcementTier::KernelSealed, false),
1066 ContainmentClass::Sealed
1067 );
1068 assert_eq!(
1069 achieved_containment_class(EnforcementTier::KernelFull, false),
1070 ContainmentClass::Scoped
1071 );
1072 assert_eq!(
1073 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false),
1074 ContainmentClass::Advisory
1075 );
1076 assert_eq!(
1077 achieved_containment_class(EnforcementTier::EnvironmentOnly, false),
1078 ContainmentClass::Advisory
1079 );
1080
1081 for tier in ALL_TIERS {
1083 assert_eq!(
1084 achieved_containment_class(*tier, true),
1085 ContainmentClass::Advisory,
1086 "workdir_exec must cap tier {tier:?} at advisory"
1087 );
1088 }
1089 }
1090
1091 #[test]
1094 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1095 for tier in ALL_TIERS {
1096 assert_eq!(
1097 achieved_class_for_tier(*tier),
1098 achieved_containment_class(*tier, false),
1099 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1100 );
1101 }
1102 }
1103
1104 #[test]
1108 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1109 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true);
1110 assert_eq!(achieved, ContainmentClass::Advisory);
1111
1112 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true)
1113 .expect_err("workdir_exec + scoped must refuse");
1114 match error {
1115 RuntimeError::ContainmentFloorUnmet {
1116 declared,
1117 achieved,
1118 reason,
1119 } => {
1120 assert_eq!(declared, ContainmentClass::Scoped);
1121 assert_eq!(achieved, ContainmentClass::Advisory);
1122 assert!(
1123 reason.contains("workdir_exec"),
1124 "the reason must name the manifest key, got: {reason}"
1125 );
1126 assert!(
1127 !reason.contains("this host provides no kernel filesystem mediation"),
1128 "the reason must not blame the host for a manifest declaration: {reason}"
1129 );
1130 }
1131 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1132 }
1133 }
1134
1135 #[test]
1139 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1140 let reason = containment_shortfall_reason(
1141 ContainmentClass::Sealed,
1142 ContainmentClass::Advisory,
1143 Some(SealedBlocker::AppArmorProfileMissing),
1144 true,
1145 )
1146 .expect("refusal");
1147 assert!(reason.contains("workdir_exec"));
1148 assert!(!reason.contains("apparmor_parser"));
1149 }
1150
1151 #[test]
1154 fn workdir_exec_alone_refuses_nothing() {
1155 for tier in ALL_TIERS {
1156 let achieved = achieved_containment_class(*tier, true);
1157 assert!(
1158 check_containment_floor(ContainmentClass::Advisory, achieved, None, true).is_ok(),
1159 "workdir_exec at an advisory floor must launch on tier {tier:?}"
1160 );
1161 }
1162 }
1163
1164 #[test]
1165 fn advisory_floor_is_met_on_every_tier() {
1166 for tier in ALL_TIERS {
1167 let achieved = achieved_class_for_tier(*tier);
1168 assert!(
1169 check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok()
1170 );
1171 }
1172 }
1173
1174 #[test]
1175 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1176 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1177 let achieved = achieved_class_for_tier(tier);
1178 assert!(
1179 check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok()
1180 );
1181 }
1182
1183 for tier in [
1184 EnforcementTier::KernelSeccompOnly,
1185 EnforcementTier::EnvironmentOnly,
1186 ] {
1187 let achieved = achieved_class_for_tier(tier);
1188 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, false)
1189 .expect_err("scoped must refuse without Landlock");
1190 match error {
1191 RuntimeError::ContainmentFloorUnmet {
1192 declared,
1193 achieved,
1194 reason,
1195 } => {
1196 assert_eq!(declared, ContainmentClass::Scoped);
1197 assert_eq!(achieved, ContainmentClass::Advisory);
1198 assert!(
1199 reason.contains("Landlock"),
1200 "reason must name the missing mechanism, got: {reason}"
1201 );
1202 }
1203 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1204 }
1205 }
1206 }
1207
1208 #[test]
1214 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
1215 let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
1216 assert_eq!(achieved, ContainmentClass::Sealed);
1217 assert!(check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok());
1218 assert!(check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok());
1219 assert_eq!(
1220 containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false),
1221 None
1222 );
1223 }
1224
1225 #[test]
1226 fn sealed_floor_is_met_only_on_the_sealed_tier() {
1227 assert!(check_containment_floor(
1228 ContainmentClass::Sealed,
1229 achieved_class_for_tier(EnforcementTier::KernelSealed),
1230 None,
1231 false
1232 )
1233 .is_ok());
1234
1235 for tier in ALL_TIERS
1236 .iter()
1237 .filter(|t| **t != EnforcementTier::KernelSealed)
1238 {
1239 let achieved = achieved_class_for_tier(*tier);
1240 let error = check_containment_floor(
1241 ContainmentClass::Sealed,
1242 achieved,
1243 Some(SealedBlocker::NamespaceCreationDenied),
1244 false,
1245 )
1246 .expect_err("sealed must refuse on a host that cannot back it");
1247 match error {
1248 RuntimeError::ContainmentFloorUnmet {
1249 declared, reason, ..
1250 } => {
1251 assert_eq!(declared, ContainmentClass::Sealed);
1252 assert!(
1253 reason.contains("--cap-add SYS_ADMIN"),
1254 "reason must name the blocking mechanism's remediation, got: {reason}"
1255 );
1256 }
1257 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1258 }
1259 }
1260 }
1261
1262 #[test]
1265 fn the_sealed_refusal_names_the_specific_blocker() {
1266 let achieved = ContainmentClass::Scoped;
1267
1268 let apparmor = containment_shortfall_reason(
1269 ContainmentClass::Sealed,
1270 achieved,
1271 Some(SealedBlocker::AppArmorProfileMissing),
1272 false,
1273 )
1274 .expect("refusal");
1275 assert!(apparmor.contains("mur-sealed"));
1276 assert!(apparmor.contains("apparmor_parser -r"));
1277 assert!(!apparmor.contains("--cap-add"));
1278
1279 let container = containment_shortfall_reason(
1280 ContainmentClass::Sealed,
1281 achieved,
1282 Some(SealedBlocker::NamespaceCreationDenied),
1283 false,
1284 )
1285 .expect("refusal");
1286 assert!(container.contains("--cap-add SYS_ADMIN"));
1287 assert!(container.contains("outside the container"));
1288 assert!(!container.contains("apparmor_parser"));
1289
1290 let pivot = containment_shortfall_reason(
1293 ContainmentClass::Sealed,
1294 ContainmentClass::Advisory,
1295 Some(SealedBlocker::PivotRootDenied),
1296 false,
1297 )
1298 .expect("refusal");
1299 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
1300 assert_ne!(
1301 pivot,
1302 containment_shortfall_reason(
1303 ContainmentClass::Sealed,
1304 ContainmentClass::Advisory,
1305 Some(SealedBlocker::MountDenied),
1306 false,
1307 )
1308 .expect("refusal")
1309 );
1310
1311 for blocker in [
1313 SealedBlocker::NotLinux,
1314 SealedBlocker::AppArmorProfileMissing,
1315 SealedBlocker::NamespaceCreationDenied,
1316 SealedBlocker::MountDenied,
1317 SealedBlocker::PivotRootDenied,
1318 SealedBlocker::KernelUnsupported,
1319 SealedBlocker::LandlockUnavailable,
1320 ] {
1321 let reason = containment_shortfall_reason(
1322 ContainmentClass::Sealed,
1323 achieved,
1324 Some(blocker),
1325 false,
1326 )
1327 .expect("refusal");
1328 assert!(
1329 !reason.contains("no host can provide it today"),
1330 "got: {reason}"
1331 );
1332 }
1333 }
1334
1335 #[test]
1338 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
1339 let reason = containment_shortfall_reason(
1340 ContainmentClass::Sealed,
1341 ContainmentClass::Advisory,
1342 None,
1343 false,
1344 )
1345 .expect("refusal");
1346 assert!(reason.contains("pivot_root"));
1347 assert!(reason.contains("CLONE_NEWNS"));
1348 }
1349
1350 #[test]
1351 fn shortfall_reason_is_absent_when_the_floor_is_met() {
1352 assert_eq!(
1353 containment_shortfall_reason(
1354 ContainmentClass::Advisory,
1355 ContainmentClass::Scoped,
1356 None,
1357 false
1358 ),
1359 None
1360 );
1361 assert_eq!(
1362 containment_shortfall_reason(
1363 ContainmentClass::Scoped,
1364 ContainmentClass::Scoped,
1365 None,
1366 false
1367 ),
1368 None
1369 );
1370 assert_eq!(
1371 containment_shortfall_reason(
1372 ContainmentClass::Sealed,
1373 ContainmentClass::Sealed,
1374 Some(SealedBlocker::NamespaceCreationDenied),
1375 false
1376 ),
1377 None,
1378 "a met floor must ignore a stale blocker rather than manufacture a refusal"
1379 );
1380 }
1381
1382 fn sample_policy() -> CapabilityPolicy {
1383 CapabilityPolicy {
1384 network_allow: vec!["https://api.example.com".to_string()],
1385 unix_sockets_allowed: false,
1386 filesystem_scope: Some("workdir".to_string()),
1387 shell_allow: vec!["python3".to_string()],
1388 spawn_allow: vec!["helper".to_string()],
1389 env_allow: vec!["TZ".to_string()],
1390 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
1391 binary: "python3".to_string(),
1392 dirs: vec![InterpreterRuntimeDir {
1393 path: "/usr/lib/python3.11".to_string(),
1394 list_dir: true,
1395 }],
1396 }],
1397 ..CapabilityPolicy::default()
1398 }
1399 }
1400
1401 #[test]
1402 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
1403 let report = scope_report_for_tier(
1404 &sample_policy(),
1405 ContainmentClass::Scoped,
1406 EnforcementTier::KernelFull,
1407 None,
1408 None,
1409 None,
1410 Vec::new(),
1411 Vec::new(),
1412 Vec::new(),
1413 IoMaxReport::default(),
1414 );
1415
1416 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
1417 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1418 assert!(report.floor_met);
1419 assert_eq!(report.shortfall_reason, None);
1420 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1421 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
1422 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
1423 assert!(!report.unix_sockets);
1424 assert_eq!(report.shell_allow, vec!["python3"]);
1425 assert_eq!(report.spawn_allow, vec!["helper"]);
1426 assert_eq!(report.env_allow, vec!["TZ"]);
1427 assert!(!report.workdir_exec);
1428 assert_eq!(
1429 report.interpreter_runtime_grants,
1430 vec!["python3: /usr/lib/python3.11 (list_dir)"]
1431 );
1432 }
1433
1434 #[test]
1438 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
1439 let policy = CapabilityPolicy {
1440 shell_allow: vec!["gcc".to_string()],
1441 workdir_exec_allowed: true,
1442 ..CapabilityPolicy::default()
1443 };
1444
1445 let report = scope_report_for_tier(
1446 &policy,
1447 ContainmentClass::Scoped,
1448 EnforcementTier::KernelFull,
1449 None,
1450 None,
1451 None,
1452 Vec::new(),
1453 Vec::new(),
1454 Vec::new(),
1455 IoMaxReport::default(),
1456 );
1457
1458 assert!(report.workdir_exec);
1459 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1460 assert!(!report.floor_met);
1461 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1464 assert!(report
1465 .shortfall_reason
1466 .as_deref()
1467 .expect("refusal")
1468 .contains("workdir_exec"));
1469
1470 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1471 assert_eq!(value["workdir_exec"], true);
1472
1473 let rendered = report.render();
1474 assert!(rendered.contains("workdir exec: true"));
1475 assert!(rendered.contains("achieved: advisory"));
1476 }
1477
1478 #[test]
1482 fn scope_report_json_always_carries_io_max() {
1483 let unprobed: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1484 &sample_policy(),
1485 ContainmentClass::Scoped,
1486 EnforcementTier::KernelFull,
1487 None,
1488 None,
1489 None,
1490 Vec::new(),
1491 Vec::new(),
1492 Vec::new(),
1493 IoMaxReport::default(),
1494 ))
1495 .unwrap();
1496 assert_eq!(unprobed["io_max"]["status"], "not-probed");
1497 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
1498 assert!(unprobed["io_max"].get("reason").is_none());
1499
1500 let enforced: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1501 &sample_policy(),
1502 ContainmentClass::Scoped,
1503 EnforcementTier::KernelFull,
1504 None,
1505 None,
1506 None,
1507 Vec::new(),
1508 Vec::new(),
1509 Vec::new(),
1510 IoMaxReport {
1511 declared_bytes_per_sec: 52_428_800,
1512 status: IoMaxStatus::Enforced,
1513 reason: None,
1514 },
1515 ))
1516 .unwrap();
1517 assert_eq!(enforced["io_max"]["status"], "enforced");
1518 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
1519 }
1520
1521 #[test]
1525 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
1526 let rendered = scope_report_for_tier(
1527 &sample_policy(),
1528 ContainmentClass::Scoped,
1529 EnforcementTier::KernelFull,
1530 None,
1531 None,
1532 None,
1533 Vec::new(),
1534 Vec::new(),
1535 Vec::new(),
1536 IoMaxReport {
1537 declared_bytes_per_sec: 52_428_800,
1538 status: IoMaxStatus::Unavailable,
1539 reason: Some("the io controller is not delegated".to_string()),
1540 },
1541 )
1542 .render();
1543
1544 assert!(rendered.contains("Resource plane"), "{rendered}");
1545 assert!(rendered.contains("io.max:"), "{rendered}");
1546 assert!(rendered.contains("unavailable"), "{rendered}");
1547 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
1548 assert!(
1549 rendered.contains("the io controller is not delegated"),
1550 "{rendered}"
1551 );
1552 }
1553
1554 #[test]
1557 fn scope_report_json_always_carries_workdir_exec() {
1558 let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1559 &sample_policy(),
1560 ContainmentClass::Scoped,
1561 EnforcementTier::KernelFull,
1562 None,
1563 None,
1564 None,
1565 Vec::new(),
1566 Vec::new(),
1567 Vec::new(),
1568 IoMaxReport::default(),
1569 ))
1570 .unwrap();
1571 assert_eq!(value["workdir_exec"], false);
1572 }
1573
1574 #[test]
1578 fn staged_runtime_grants_are_reported_on_every_tier() {
1579 let policy = CapabilityPolicy {
1580 shell_allow: vec!["python3".to_string()],
1581 shell_staged_runtime: vec![StagedRuntimeGrant {
1582 binary: "python3".to_string(),
1583 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
1584 pin: "conda-4.10.3/python-3.9.19".to_string(),
1585 }],
1586 ..CapabilityPolicy::default()
1587 };
1588
1589 for tier in ALL_TIERS {
1590 let report = scope_report_for_tier(
1591 &policy,
1592 ContainmentClass::Sealed,
1593 *tier,
1594 Some(SealedBlocker::NamespaceCreationDenied),
1595 None,
1596 None,
1597 Vec::new(),
1598 Vec::new(),
1599 Vec::new(),
1600 IoMaxReport::default(),
1601 );
1602 assert_eq!(
1603 report.staged_runtime_grants,
1604 vec![
1605 "python3: /opt/testbed/conda/envs/django__django \
1606 (pin: conda-4.10.3/python-3.9.19)"
1607 ],
1608 "tier {tier:?} must still report the declared staged runtime"
1609 );
1610 assert!(
1611 report.render().contains("staged runtime"),
1612 "the rendered report must carry a staged runtime section on tier {tier:?}"
1613 );
1614 }
1615 }
1616
1617 #[test]
1620 fn absent_staged_runtime_reports_nothing() {
1621 let report = scope_report_for_tier(
1622 &sample_policy(),
1623 ContainmentClass::Scoped,
1624 EnforcementTier::KernelFull,
1625 None,
1626 None,
1627 None,
1628 Vec::new(),
1629 Vec::new(),
1630 Vec::new(),
1631 IoMaxReport::default(),
1632 );
1633 assert!(report.staged_runtime_grants.is_empty());
1634 assert!(report.render().contains("staged runtime: <none>"));
1635 }
1636
1637 #[test]
1641 fn grant_set_never_raises_the_achieved_class() {
1642 let report = scope_report_for_tier(
1643 &sample_policy(),
1644 ContainmentClass::Sealed,
1645 EnforcementTier::KernelFull,
1646 Some(SealedBlocker::NamespaceCreationDenied),
1647 None,
1648 None,
1649 Vec::new(),
1650 Vec::new(),
1651 Vec::new(),
1652 IoMaxReport::default(),
1653 );
1654 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1655 assert!(!report.floor_met);
1656 }
1657
1658 #[test]
1659 fn scope_report_still_reports_when_the_floor_is_unmet() {
1660 let report = scope_report_for_tier(
1661 &sample_policy(),
1662 ContainmentClass::Sealed,
1663 EnforcementTier::EnvironmentOnly,
1664 Some(SealedBlocker::NotLinux),
1665 None,
1666 None,
1667 Vec::new(),
1668 Vec::new(),
1669 Vec::new(),
1670 IoMaxReport::default(),
1671 );
1672
1673 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
1674 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1675 assert!(!report.floor_met);
1676 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
1677 assert_eq!(report.enforcement_tier, "none");
1678 }
1679
1680 #[test]
1681 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
1682 let report = scope_report_for_tier(
1683 &sample_policy(),
1684 ContainmentClass::Sealed,
1685 EnforcementTier::KernelSealed,
1686 None,
1687 None,
1688 None,
1689 Vec::new(),
1690 Vec::new(),
1691 Vec::new(),
1692 IoMaxReport::default(),
1693 );
1694 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
1695 assert!(report.floor_met);
1696 assert_eq!(
1697 report.enforcement_tier,
1698 "mountns+pivot_root+landlock+seccomp"
1699 );
1700 }
1701
1702 #[test]
1703 fn scope_report_json_uses_wire_names() {
1704 let report = scope_report_for_tier(
1705 &CapabilityPolicy::default(),
1706 ContainmentClass::Advisory,
1707 EnforcementTier::KernelSeccompOnly,
1708 None,
1709 None,
1710 None,
1711 Vec::new(),
1712 Vec::new(),
1713 Vec::new(),
1714 IoMaxReport::default(),
1715 );
1716 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1717
1718 assert_eq!(value["declared_containment"], "advisory");
1719 assert_eq!(value["achieved_containment"], "advisory");
1720 assert_eq!(value["floor_met"], true);
1721 assert_eq!(value["enforcement_tier"], "seccomp-only");
1722 assert!(value.get("shortfall_reason").is_none());
1724 }
1725
1726 #[test]
1729 fn the_userns_grant_is_reported_without_changing_any_verdict() {
1730 let baseline = scope_report_for_tier(
1731 &sample_policy(),
1732 ContainmentClass::Advisory,
1733 EnforcementTier::KernelSealed,
1734 None,
1735 None,
1736 None,
1737 Vec::new(),
1738 Vec::new(),
1739 Vec::new(),
1740 IoMaxReport::default(),
1741 );
1742
1743 for grant in UsernsGrant::ALL {
1744 let report = scope_report_for_tier(
1745 &sample_policy(),
1746 ContainmentClass::Advisory,
1747 EnforcementTier::KernelSealed,
1748 None,
1749 Some(*grant),
1750 None,
1751 Vec::new(),
1752 Vec::new(),
1753 Vec::new(),
1754 IoMaxReport::default(),
1755 );
1756 assert_eq!(report.achieved_containment, baseline.achieved_containment);
1757 assert_eq!(report.floor_met, baseline.floor_met);
1758 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
1759 assert_eq!(report.userns_grant, Some(*grant));
1760
1761 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1762 assert_eq!(value["userns_grant"], grant.wire_name());
1763 assert!(report
1764 .render()
1765 .contains(&format!("userns grant: {}", grant.wire_name())));
1766 }
1767
1768 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
1771 assert!(value["userns_grant"].is_null());
1772 assert!(baseline.render().contains("userns grant: n/a"));
1773 }
1774
1775 #[test]
1776 fn rendered_report_names_both_classes_and_the_refusal() {
1777 let rendered = scope_report_for_tier(
1778 &sample_policy(),
1779 ContainmentClass::Sealed,
1780 EnforcementTier::KernelFull,
1781 Some(SealedBlocker::AppArmorProfileMissing),
1782 None,
1783 None,
1784 Vec::new(),
1785 Vec::new(),
1786 Vec::new(),
1787 IoMaxReport::default(),
1788 )
1789 .render();
1790
1791 assert!(rendered.contains("declared: sealed"));
1792 assert!(rendered.contains("achieved: scoped"));
1793 assert!(rendered.contains("floor met: no"));
1794 assert!(rendered.contains("mur-sealed"));
1795 assert!(rendered.contains("would refuse to launch"));
1796 }
1797
1798 #[test]
1802 fn an_export_never_changes_what_the_report_says_about_containment() {
1803 let export = FileExport {
1804 root: "out/".to_string(),
1805 mode: ExportMode::ReadOnly,
1806 max_bytes: 10 * 1024 * 1024,
1807 };
1808 for tier in ALL_TIERS {
1809 for workdir_exec in [false, true] {
1810 let policy = CapabilityPolicy {
1811 workdir_exec_allowed: workdir_exec,
1812 ..sample_policy()
1813 };
1814 let without = scope_report_for_tier(
1815 &policy,
1816 ContainmentClass::Scoped,
1817 *tier,
1818 None,
1819 None,
1820 None,
1821 Vec::new(),
1822 Vec::new(),
1823 Vec::new(),
1824 IoMaxReport::default(),
1825 );
1826 let with = scope_report_for_tier(
1827 &policy,
1828 ContainmentClass::Scoped,
1829 *tier,
1830 None,
1831 None,
1832 Some(&Exports {
1833 files: Some(export.clone()),
1834 peer_files: None,
1835 }),
1836 Vec::new(),
1837 Vec::new(),
1838 Vec::new(),
1839 IoMaxReport::default(),
1840 );
1841 assert_eq!(
1842 with.achieved_containment, without.achieved_containment,
1843 "tier {tier:?}, workdir_exec {workdir_exec}"
1844 );
1845 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1846 assert_eq!(with.floor_met, without.floor_met);
1847 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1848 assert_eq!(
1851 ScopeReport {
1852 exports_files: None,
1853 ..with.clone()
1854 },
1855 without
1856 );
1857 assert_eq!(
1858 with.exports_files,
1859 Some(ExportsFilesReport {
1860 root: "out/".to_string(),
1861 mode: ExportMode::ReadOnly,
1862 max_bytes: 10 * 1024 * 1024,
1863 })
1864 );
1865 }
1866 }
1867 }
1868
1869 #[test]
1873 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
1874 let peer_files = PeerFilesExport {
1875 root: "out/".to_string(),
1876 max_ttl_secs: Some(900),
1877 max_bytes: 10 * 1024 * 1024,
1878 };
1879 for tier in ALL_TIERS {
1880 for workdir_exec in [false, true] {
1881 let without_policy = CapabilityPolicy {
1882 workdir_exec_allowed: workdir_exec,
1883 ..sample_policy()
1884 };
1885 let with_policy = CapabilityPolicy {
1886 peer_fetch_allow: vec!["localhost:41234".to_string()],
1887 ..without_policy.clone()
1888 };
1889 let without = scope_report_for_tier(
1890 &without_policy,
1891 ContainmentClass::Scoped,
1892 *tier,
1893 None,
1894 None,
1895 None,
1896 Vec::new(),
1897 Vec::new(),
1898 Vec::new(),
1899 IoMaxReport::default(),
1900 );
1901 let with = scope_report_for_tier(
1902 &with_policy,
1903 ContainmentClass::Scoped,
1904 *tier,
1905 None,
1906 None,
1907 Some(&Exports {
1908 files: None,
1909 peer_files: Some(peer_files.clone()),
1910 }),
1911 Vec::new(),
1912 Vec::new(),
1913 Vec::new(),
1914 IoMaxReport::default(),
1915 );
1916 assert_eq!(
1917 with.achieved_containment, without.achieved_containment,
1918 "tier {tier:?}, workdir_exec {workdir_exec}"
1919 );
1920 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1921 assert_eq!(with.floor_met, without.floor_met);
1922 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1923 assert_eq!(
1924 ScopeReport {
1925 peer_files: None,
1926 peer_fetch_allow: Vec::new(),
1927 ..with.clone()
1928 },
1929 without
1930 );
1931 assert_eq!(
1932 with.peer_files,
1933 Some(PeerFilesReport {
1934 root: "out/".to_string(),
1935 max_ttl_secs: 900,
1936 max_bytes: 10 * 1024 * 1024,
1937 })
1938 );
1939 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
1940 assert_eq!(with.network_allow, without.network_allow);
1944 }
1945 }
1946 }
1947
1948 #[test]
1952 fn the_peer_fields_are_always_serialized() {
1953 let undeclared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1954 &sample_policy(),
1955 ContainmentClass::Advisory,
1956 EnforcementTier::KernelSealed,
1957 None,
1958 None,
1959 None,
1960 Vec::new(),
1961 Vec::new(),
1962 Vec::new(),
1963 IoMaxReport::default(),
1964 ))
1965 .unwrap();
1966 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
1967 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
1968
1969 let declared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1970 &CapabilityPolicy {
1971 peer_fetch_allow: vec!["localhost:41234".to_string()],
1972 ..sample_policy()
1973 },
1974 ContainmentClass::Advisory,
1975 EnforcementTier::KernelSealed,
1976 None,
1977 None,
1978 Some(&Exports {
1979 files: None,
1980 peer_files: Some(PeerFilesExport {
1981 root: "out/".to_string(),
1982 max_ttl_secs: None,
1983 max_bytes: 10 * 1024 * 1024,
1984 }),
1985 }),
1986 Vec::new(),
1987 Vec::new(),
1988 Vec::new(),
1989 IoMaxReport::default(),
1990 ))
1991 .unwrap();
1992 assert_eq!(
1993 declared["peer_files"],
1994 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
1995 );
1996 assert_eq!(
1997 declared["peer_fetch_allow"],
1998 serde_json::json!(["localhost:41234"])
1999 );
2000 }
2001
2002 #[test]
2003 fn render_names_peer_handoff_in_both_directions() {
2004 let undeclared = scope_report_for_tier(
2005 &sample_policy(),
2006 ContainmentClass::Advisory,
2007 EnforcementTier::KernelSealed,
2008 None,
2009 None,
2010 None,
2011 Vec::new(),
2012 Vec::new(),
2013 Vec::new(),
2014 IoMaxReport::default(),
2015 )
2016 .render();
2017 assert!(undeclared.contains("Peer handoff"));
2018 assert!(undeclared.contains("exports.peer_files: <none>"));
2019 assert!(undeclared.contains("peer_fetch allow: <none>"));
2020
2021 let declared = scope_report_for_tier(
2022 &CapabilityPolicy {
2023 peer_fetch_allow: vec!["localhost:41234".to_string()],
2024 ..sample_policy()
2025 },
2026 ContainmentClass::Advisory,
2027 EnforcementTier::KernelSealed,
2028 None,
2029 None,
2030 Some(&Exports {
2031 files: None,
2032 peer_files: Some(PeerFilesExport {
2033 root: "out/".to_string(),
2034 max_ttl_secs: Some(900),
2035 max_bytes: 4096,
2036 }),
2037 }),
2038 Vec::new(),
2039 Vec::new(),
2040 Vec::new(),
2041 IoMaxReport::default(),
2042 )
2043 .render();
2044 assert!(declared.contains("exports.peer_files root: out/"));
2045 assert!(declared.contains("max handle ttl: 900s"));
2046 assert!(declared.contains("max bytes: 4096 (per file)"));
2047 assert!(declared.contains("- localhost:41234"));
2048 }
2049
2050 #[test]
2054 fn a_state_store_is_reported_and_moves_no_other_field() {
2055 let policy = sample_policy();
2056 let stores = vec![StateStoreReport {
2057 artifact: "murmur-tool-corpus".to_string(),
2058 store: "shey".to_string(),
2059 host_path: "/home/dev/.murmur/state/shey".to_string(),
2060 }];
2061
2062 let without = scope_report_for_tier(
2063 &policy,
2064 ContainmentClass::Scoped,
2065 EnforcementTier::KernelFull,
2066 None,
2067 None,
2068 None,
2069 Vec::new(),
2070 Vec::new(),
2071 Vec::new(),
2072 IoMaxReport::default(),
2073 );
2074 let with = scope_report_for_tier(
2075 &policy,
2076 ContainmentClass::Scoped,
2077 EnforcementTier::KernelFull,
2078 None,
2079 None,
2080 None,
2081 stores.clone(),
2082 Vec::new(),
2083 Vec::new(),
2084 IoMaxReport::default(),
2085 );
2086
2087 assert_eq!(with.state_stores, stores);
2088 assert!(without.state_stores.is_empty());
2089 assert_eq!(with.achieved_containment, without.achieved_containment);
2090 assert_eq!(with.floor_met, without.floor_met);
2091 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2092 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2093 assert_eq!(
2095 ScopeReport {
2096 state_stores: Vec::new(),
2097 ..with.clone()
2098 },
2099 without
2100 );
2101
2102 assert!(with
2104 .render()
2105 .contains("- murmur-tool-corpus: shey -> /home/dev/.murmur/state/shey"));
2106 assert!(without.render().contains("state stores: <none>"));
2107
2108 let json = serde_json::to_value(&without).unwrap();
2111 assert_eq!(json["state_stores"], serde_json::json!([]));
2112 assert_eq!(
2113 serde_json::to_value(&with).unwrap()["state_stores"],
2114 serde_json::json!([{
2115 "artifact": "murmur-tool-corpus",
2116 "store": "shey",
2117 "host_path": "/home/dev/.murmur/state/shey",
2118 }])
2119 );
2120 }
2121
2122 #[test]
2127 fn preopens_are_reported_and_move_no_other_field() {
2128 let policy = sample_policy();
2129 let preopens = vec![
2130 PreopenReport {
2131 artifact: "notes-tool".to_string(),
2132 role: "tool".to_string(),
2133 scope: None,
2134 surface: PreopenSurface::WholeWorkdir,
2135 },
2136 PreopenReport {
2137 artifact: "cache-tool".to_string(),
2138 role: "tool".to_string(),
2139 scope: Some("cache".to_string()),
2140 surface: PreopenSurface::ScopedSubtree,
2141 },
2142 PreopenReport {
2143 artifact: "telemetry-hook".to_string(),
2144 role: "hook".to_string(),
2145 scope: None,
2146 surface: PreopenSurface::Nothing,
2147 },
2148 ];
2149
2150 let without = scope_report_for_tier(
2151 &policy,
2152 ContainmentClass::Scoped,
2153 EnforcementTier::KernelFull,
2154 None,
2155 None,
2156 None,
2157 Vec::new(),
2158 Vec::new(),
2159 Vec::new(),
2160 IoMaxReport::default(),
2161 );
2162 let with = scope_report_for_tier(
2163 &policy,
2164 ContainmentClass::Scoped,
2165 EnforcementTier::KernelFull,
2166 None,
2167 None,
2168 None,
2169 Vec::new(),
2170 Vec::new(),
2171 preopens.clone(),
2172 IoMaxReport::default(),
2173 );
2174
2175 assert_eq!(with.preopens, preopens);
2176 assert!(without.preopens.is_empty());
2177 assert_eq!(
2179 ScopeReport {
2180 preopens: Vec::new(),
2181 ..with.clone()
2182 },
2183 without
2184 );
2185
2186 let rendered = with.render();
2188 assert!(rendered.contains(
2189 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
2190 declared"
2191 ));
2192 assert!(rendered.contains(
2193 "- cache-tool (tool): one subtree of the accessible workdir — \
2194 capabilities.filesystem.scope: cache"
2195 ));
2196 assert!(rendered.contains(
2197 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
2198 ));
2199 assert!(without.render().contains("preopens: <none>"));
2200
2201 assert_eq!(
2203 serde_json::to_value(&without).unwrap()["preopens"],
2204 serde_json::json!([])
2205 );
2206 assert_eq!(
2207 serde_json::to_value(&with).unwrap()["preopens"],
2208 serde_json::json!([
2209 {
2210 "artifact": "notes-tool",
2211 "role": "tool",
2212 "scope": null,
2213 "surface": "whole-workdir",
2214 },
2215 {
2216 "artifact": "cache-tool",
2217 "role": "tool",
2218 "scope": "cache",
2219 "surface": "scoped-subtree",
2220 },
2221 {
2222 "artifact": "telemetry-hook",
2223 "role": "hook",
2224 "scope": null,
2225 "surface": "nothing",
2226 },
2227 ])
2228 );
2229 }
2230
2231 #[test]
2235 fn exports_files_is_always_serialized() {
2236 let undeclared = serde_json::to_value(scope_report_for_tier(
2237 &sample_policy(),
2238 ContainmentClass::Advisory,
2239 EnforcementTier::KernelFull,
2240 None,
2241 None,
2242 None,
2243 Vec::new(),
2244 Vec::new(),
2245 Vec::new(),
2246 IoMaxReport::default(),
2247 ))
2248 .unwrap();
2249 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
2250
2251 let declared = serde_json::to_value(scope_report_for_tier(
2252 &sample_policy(),
2253 ContainmentClass::Advisory,
2254 EnforcementTier::KernelFull,
2255 None,
2256 None,
2257 Some(&Exports {
2258 files: Some(FileExport {
2259 root: "out/".to_string(),
2260 mode: ExportMode::ReadOnly,
2261 max_bytes: 10_485_760,
2262 }),
2263 peer_files: None,
2264 }),
2265 Vec::new(),
2266 Vec::new(),
2267 Vec::new(),
2268 IoMaxReport::default(),
2269 ))
2270 .unwrap();
2271 assert_eq!(
2272 declared["exports_files"],
2273 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
2274 );
2275 }
2276
2277 #[test]
2278 fn render_names_the_resource_plane_in_both_directions() {
2279 let undeclared = scope_report_for_tier(
2280 &sample_policy(),
2281 ContainmentClass::Advisory,
2282 EnforcementTier::KernelFull,
2283 None,
2284 None,
2285 None,
2286 Vec::new(),
2287 Vec::new(),
2288 Vec::new(),
2289 IoMaxReport::default(),
2290 )
2291 .render();
2292 assert!(
2293 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
2294 "{undeclared}"
2295 );
2296
2297 let declared = scope_report_for_tier(
2298 &sample_policy(),
2299 ContainmentClass::Advisory,
2300 EnforcementTier::KernelFull,
2301 None,
2302 None,
2303 Some(&Exports {
2304 files: Some(FileExport {
2305 root: "out/".to_string(),
2306 mode: ExportMode::ReadOnly,
2307 max_bytes: 10_485_760,
2308 }),
2309 peer_files: None,
2310 }),
2311 Vec::new(),
2312 Vec::new(),
2313 Vec::new(),
2314 IoMaxReport::default(),
2315 )
2316 .render();
2317 assert!(declared.contains("exports.files root: out/"), "{declared}");
2318 assert!(
2319 declared.contains("mode: read-only"),
2320 "{declared}"
2321 );
2322 assert!(
2323 declared.contains("max bytes: 10485760 (per file)"),
2324 "{declared}"
2325 );
2326 }
2327
2328 fn report_for(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
2331 let policy = CapabilityPolicy {
2332 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
2333 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
2334 ..CapabilityPolicy::default()
2335 };
2336 scope_report_for_tier(
2337 &policy,
2338 ContainmentClass::Scoped,
2339 EnforcementTier::KernelFull,
2340 None,
2341 None,
2342 None,
2343 Vec::new(),
2344 Vec::new(),
2345 Vec::new(),
2346 IoMaxReport::default(),
2347 )
2348 }
2349
2350 #[test]
2353 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
2354 let report = report_for(&["tests", "bench/fixtures"], &["git"]);
2355 assert_eq!(
2356 report.read_only_paths,
2357 vec!["tests".to_string(), "bench/fixtures".to_string()]
2358 );
2359 assert!(report.read_only_advisory_for.is_empty());
2360
2361 let rendered = report.render();
2362 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
2363 assert!(rendered.contains(
2364 "read_only enforcement: enforced for every tool call and every shell command the \
2365 dispatch check can read"
2366 ));
2367 assert!(!rendered.contains("advisory against"));
2368 }
2369
2370 #[test]
2373 fn an_allowlisted_interpreter_is_named_as_advisory() {
2374 let report = report_for(&["tests"], &["git", "python3"]);
2375 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
2376
2377 let rendered = report.render();
2378 assert!(rendered.contains("read_only enforcement: advisory against python3"));
2379 assert!(rendered
2380 .contains("enforced for every tool call and for every shell command it can read"));
2381 }
2382
2383 #[test]
2387 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
2388 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
2389 .iter()
2390 .map(|b| (*b).to_string())
2391 .collect();
2392 let declared = vec!["tests".to_string()];
2393 assert_eq!(
2394 report_for(&["tests"], &["git", "python3", "node", "make", "bash"])
2395 .read_only_advisory_for,
2396 read_only_advisory_for(&declared, &shell_allow)
2397 );
2398 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
2400 }
2401
2402 #[test]
2406 fn both_read_only_keys_are_always_serialized_arrays() {
2407 let json = serde_json::to_value(report_for(&[], &["python3"])).unwrap();
2408 assert_eq!(json["read_only_paths"], serde_json::json!([]));
2409 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
2410
2411 let json = serde_json::to_value(report_for(&["tests"], &["python3"])).unwrap();
2412 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
2413 assert_eq!(
2414 json["read_only_advisory_for"],
2415 serde_json::json!(["python3"])
2416 );
2417 }
2418
2419 #[test]
2422 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
2423 let rendered = report_for(&[], &["python3"]).render();
2424 assert!(rendered.contains(" read_only: <none>\n"));
2425 assert!(!rendered.contains("read_only enforcement:"));
2426 }
2427
2428 #[test]
2440 fn every_capability_policy_field_is_reported_or_exempted() {
2441 const EXEMPT: &[(&str, &str)] = &[
2443 (
2444 "shell_strip_env",
2445 "Names host variables removed from the shell child's environment on top of the \
2446 credential patterns the runtime always strips. It subtracts from what the \
2447 capsule sees and can open nothing, so reporting it beside the grants would pad \
2448 the list an operator audits for reach.",
2449 ),
2450 (
2451 "shell_baseline_env",
2452 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
2453 environment of a subprocess the capsule was already granted, and names no host \
2454 resource `shell allow` and `env allow` do not already account for.",
2455 ),
2456 (
2457 "limits",
2458 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
2459 deadline. A bound on how much the capsule may consume, never a reach it gains.",
2460 ),
2461 (
2462 "resources",
2463 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
2464 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
2465 ),
2466 (
2467 "declared_deadline_seconds",
2468 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
2469 can apply a lower default. Reporting it would report one ceiling twice.",
2470 ),
2471 (
2472 "state_declared",
2473 "Records that a capsule-wide `capabilities.state` block was declared where \
2474 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
2475 exist are reported per artifact in `state_stores`.",
2476 ),
2477 (
2478 "conversation_declared",
2479 "Records a capsule-wide `capabilities.conversation` block that reaches no \
2480 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
2481 and the grant itself is per artifact.",
2482 ),
2483 ];
2484
2485 const REPORTED: &[(&str, &str)] = &[
2487 ("network_allow", "network_allow"),
2488 ("peer_fetch_allow", "peer_fetch_allow"),
2489 ("unix_sockets_allowed", "unix_sockets"),
2490 ("filesystem_scope", "filesystem_scope"),
2491 ("workdir_exec_allowed", "workdir_exec"),
2492 ("read_only_paths", "read_only_paths"),
2493 ("shell_allow", "shell_allow"),
2494 ("spawn_allow", "spawn_allow"),
2495 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
2496 ("shell_staged_runtime", "staged_runtime_grants"),
2497 ("env_allow", "env_allow"),
2498 ("plan_submit", "plan_submit"),
2499 ("containment_floor", "declared_containment"),
2500 ];
2501
2502 let declared = capability_policy_field_names(include_str!("types.rs"));
2503 assert!(
2504 declared.len() > 10,
2505 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
2506 );
2507
2508 for field in &declared {
2509 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
2510 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
2511 assert!(
2512 reported || exempt,
2513 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
2514 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
2515 with a written reason for why an operator does not need to see it."
2516 );
2517 assert!(
2518 !(reported && exempt),
2519 "CapabilityPolicy::{field} is listed as both reported and exempt"
2520 );
2521 }
2522
2523 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
2524 assert!(
2525 declared.iter().any(|field| field == policy_field),
2526 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
2527 declares — remove the stale row"
2528 );
2529 }
2530
2531 let json = serde_json::to_value(scope_report_for_tier(
2534 &sample_policy(),
2535 ContainmentClass::Scoped,
2536 EnforcementTier::KernelFull,
2537 None,
2538 None,
2539 None,
2540 Vec::new(),
2541 Vec::new(),
2542 Vec::new(),
2543 IoMaxReport::default(),
2544 ))
2545 .unwrap();
2546 let object = json.as_object().expect("a scope report is a JSON object");
2547 for (policy_field, report_key) in REPORTED {
2548 assert!(
2549 object.contains_key(*report_key),
2550 "the table maps CapabilityPolicy::{policy_field} to a report key \
2551 '{report_key}' that a serialized ScopeReport does not carry"
2552 );
2553 }
2554 }
2555
2556 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
2559 scope_report_for_tier(
2560 &sample_policy(),
2561 declared,
2562 tier,
2563 None,
2564 None,
2565 None,
2566 Vec::new(),
2567 Vec::new(),
2568 Vec::new(),
2569 IoMaxReport::default(),
2570 )
2571 }
2572
2573 #[test]
2575 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
2576 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2577
2578 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2579 assert_eq!(
2580 report.filesystem_boundary.restriction,
2581 FilesystemRestriction::Advisory
2582 );
2583 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2584
2585 let filesystem = &report.filesystem_boundary.not_protected[0];
2586 assert!(filesystem.contains("advisory"), "{filesystem}");
2587 assert!(
2588 filesystem.contains("denies a read or a write outside the session workdir"),
2589 "{filesystem}"
2590 );
2591
2592 let rewriting = &report.filesystem_boundary.not_protected[1];
2593 assert!(rewriting.contains("not a boundary"), "{rewriting}");
2594 assert!(rewriting.contains("HOME"), "{rewriting}");
2595
2596 let rendered = report.render();
2597 let heading = rendered
2598 .find("\nNot protected here\n")
2599 .expect("a session that composes no root prints the block");
2600 assert!(
2601 heading > rendered.find("Containment\n").unwrap(),
2602 "the block belongs under Containment:\n{rendered}"
2603 );
2604 assert!(
2605 heading < rendered.find("\nEffective grants\n").unwrap(),
2606 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
2607 );
2608 for statement in &report.filesystem_boundary.not_protected {
2609 assert!(rendered.contains(statement.as_str()), "{rendered}");
2610 }
2611 }
2612
2613 #[test]
2616 fn a_landlock_session_reports_denial_rather_than_absence() {
2617 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2618
2619 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2620 assert_eq!(
2621 report.filesystem_boundary.restriction,
2622 FilesystemRestriction::Enforced
2623 );
2624 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2625
2626 let filesystem = &report.filesystem_boundary.not_protected[0];
2627 assert!(
2628 !filesystem.contains("advisory"),
2629 "Landlock mediates the filesystem here: {filesystem}"
2630 );
2631 assert!(
2632 filesystem.contains("denied rather than absent"),
2633 "{filesystem}"
2634 );
2635 for probe in ["stat", "access", "readlink"] {
2636 assert!(filesystem.contains(probe), "{filesystem}");
2637 }
2638 }
2639
2640 #[test]
2645 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
2646 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
2647
2648 assert_eq!(
2649 report.achieved_containment,
2650 ContainmentClass::Sealed,
2651 "the host can back sealed, which is what `achieved` answers"
2652 );
2653 assert_eq!(
2654 report.filesystem_boundary.restriction,
2655 FilesystemRestriction::Enforced,
2656 "no composed root is installed for a capsule that did not declare one"
2657 );
2658 assert_eq!(
2659 report.filesystem_boundary.not_protected,
2660 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
2661 .filesystem_boundary
2662 .not_protected,
2663 "the same mechanism gets the same statements on either host"
2664 );
2665 assert!(report.render().contains("Not protected here"));
2666 }
2667
2668 #[test]
2671 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
2672 let advisory =
2673 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2674 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2675
2676 assert_eq!(
2677 advisory.filesystem_boundary.not_protected[1],
2678 enforced.filesystem_boundary.not_protected[1]
2679 );
2680 assert!(
2681 advisory.filesystem_boundary.not_protected[1]
2682 .contains(crate::shell::SYNTHETIC_HOME_DIR_NAME),
2683 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
2684 );
2685 }
2686
2687 #[test]
2690 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
2691 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
2692
2693 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2694 assert_eq!(
2695 report.filesystem_boundary.restriction,
2696 FilesystemRestriction::Absent
2697 );
2698 assert!(report.filesystem_boundary.not_protected.is_empty());
2699 assert!(
2700 !report.render().contains("Not protected here"),
2701 "{}",
2702 report.render()
2703 );
2704
2705 let json = serde_json::to_value(&report).unwrap();
2706 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
2707 assert_eq!(
2708 json["filesystem_boundary"]["not_protected"],
2709 serde_json::json!([]),
2710 "the key is present and empty at absent, never missing"
2711 );
2712 }
2713
2714 #[test]
2718 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
2719 let policy = CapabilityPolicy {
2720 workdir_exec_allowed: true,
2721 ..CapabilityPolicy::default()
2722 };
2723
2724 let report = scope_report_for_tier(
2725 &policy,
2726 ContainmentClass::Advisory,
2727 EnforcementTier::KernelSealed,
2728 None,
2729 None,
2730 None,
2731 Vec::new(),
2732 Vec::new(),
2733 Vec::new(),
2734 IoMaxReport::default(),
2735 );
2736
2737 assert_eq!(
2738 report.enforcement_tier,
2739 "mountns+pivot_root+landlock+seccomp"
2740 );
2741 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2742 assert_eq!(
2743 report.filesystem_boundary.restriction,
2744 FilesystemRestriction::Enforced
2745 );
2746 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2747 assert!(report.render().contains("Not protected here"));
2748 }
2749
2750 #[test]
2753 fn the_restriction_follows_the_tier_the_session_would_install() {
2754 for tier in ALL_TIERS {
2755 for declared in [
2756 ContainmentClass::Advisory,
2757 ContainmentClass::Scoped,
2758 ContainmentClass::Sealed,
2759 ] {
2760 let report = boundary_report(declared, *tier);
2761 let expected = match crate::sandbox::applied_tier(*tier, declared) {
2762 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
2763 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
2764 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
2765 FilesystemRestriction::Advisory
2766 }
2767 };
2768 assert_eq!(
2769 report.filesystem_boundary.restriction, expected,
2770 "{tier:?} / {declared:?}"
2771 );
2772 assert_eq!(
2773 report.filesystem_boundary.not_protected.is_empty(),
2774 expected == FilesystemRestriction::Absent,
2775 "{tier:?} / {declared:?}"
2776 );
2777 let wire = match expected {
2778 FilesystemRestriction::Advisory => "advisory",
2779 FilesystemRestriction::Enforced => "enforced",
2780 FilesystemRestriction::Absent => "absent",
2781 };
2782 assert_eq!(
2783 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
2784 serde_json::Value::String(wire.to_string())
2785 );
2786 }
2787 }
2788 }
2789
2790 #[test]
2803 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
2804 if crate::network_namespace::skip_without_egress_namespace(
2805 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
2806 ) {
2807 return;
2808 }
2809 if crate::sandbox::find_on_path("bash").is_none() {
2810 eprintln!(
2811 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
2812 no bash on PATH"
2813 );
2814 return;
2815 }
2816 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
2817 let secret = outside.path().join("host-file");
2818 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
2819 let secret = secret.to_str().unwrap().to_string();
2820
2821 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
2822 let workdir = tempfile::tempdir().unwrap();
2823 let policy = CapabilityPolicy {
2824 shell_allow: vec!["bash".to_string()],
2825 ..CapabilityPolicy::default()
2826 };
2827 let probe = crate::sandbox::HostProbe::probe();
2828 let enforcement =
2829 crate::sandbox::ShellEnforcement::resolve(&policy, declared, probe).unwrap();
2830 let report = scope_report_for_tier(
2831 &policy,
2832 declared,
2833 probe.tier(),
2834 None,
2835 None,
2836 None,
2837 Vec::new(),
2838 Vec::new(),
2839 Vec::new(),
2840 IoMaxReport::default(),
2841 );
2842
2843 let command = format!(
2844 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
2845 else echo read=refused; fi; \
2846 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
2847 );
2848 let result = crate::shell::execute_shell(
2849 "bash",
2850 &["-c", &command],
2851 &[],
2852 workdir.path(),
2853 workdir.path(),
2854 &policy,
2855 &enforcement,
2856 )
2857 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
2858 let seen = result.stdout;
2859
2860 match report.filesystem_boundary.restriction {
2861 FilesystemRestriction::Advisory => assert!(
2862 seen.contains("read=PROBE-CONTENTS"),
2863 "reported advisory, so nothing denies the read: {seen}"
2864 ),
2865 FilesystemRestriction::Enforced => assert!(
2866 seen.contains("read=refused") && seen.contains("exists=yes"),
2867 "reported enforced, so the read is denied and the path still exists: {seen}"
2868 ),
2869 FilesystemRestriction::Absent => assert!(
2870 seen.contains("read=refused") && seen.contains("exists=no"),
2871 "reported absent, so the path is not in the composed root: {seen}"
2872 ),
2873 }
2874 }
2875 }
2876
2877 fn capability_policy_field_names(source: &str) -> Vec<String> {
2879 source
2880 .split_once("pub struct CapabilityPolicy {")
2881 .expect("types.rs declares CapabilityPolicy")
2882 .1
2883 .split_once("\n}")
2884 .expect("CapabilityPolicy's declaration is closed at column 0")
2885 .0
2886 .lines()
2887 .filter_map(|line| {
2888 line.strip_prefix(" pub ")
2889 .and_then(|rest| rest.split_once(':'))
2890 .map(|(name, _)| name.to_string())
2891 })
2892 .collect()
2893 }
2894}