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 install_skill: Vec<String>,
548 pub install_tool: Vec<String>,
552 pub interpreter_runtime_grants: Vec<String>,
556 pub exports_files: Option<ExportsFilesReport>,
566 pub peer_files: Option<PeerFilesReport>,
574 pub peer_fetch_allow: Vec<String>,
581 pub state_stores: Vec<StateStoreReport>,
591 pub configured_artifacts: Vec<String>,
605 pub staged_runtime_grants: Vec<String>,
614 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
632 pub io_max: IoMaxReport,
648 pub filesystem_boundary: FilesystemBoundaryReport,
662}
663
664impl ScopeReport {
665 pub fn render(&self) -> String {
667 let mut out = String::new();
668 out.push_str("Containment\n");
669 out.push_str(&format!(" declared: {}\n", self.declared_containment));
670 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
671 out.push_str(&format!(
672 " floor met: {}\n",
673 if self.floor_met { "yes" } else { "no" }
674 ));
675 if let Some(reason) = &self.shortfall_reason {
676 out.push_str(&format!(" reason: {reason}\n"));
677 }
678 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
679 out.push_str(&format!(
680 " userns grant: {}\n",
681 match self.userns_grant {
682 Some(grant) => grant.wire_name(),
683 None => "n/a",
684 }
685 ));
686
687 out.push_str(&render_not_protected(&self.filesystem_boundary));
688
689 out.push_str("\nEffective grants\n");
690 out.push_str(&format!(
691 " filesystem scope: {}\n",
692 self.filesystem_scope.as_deref().unwrap_or("<none>")
693 ));
694 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
695 out.push_str(&render_read_only(
696 &self.read_only_paths,
697 &self.read_only_advisory_for,
698 ));
699 push_list(
700 &mut out,
701 "preopens",
702 &self
703 .preopens
704 .iter()
705 .map(PreopenReport::render)
706 .collect::<Vec<_>>(),
707 );
708 push_list(&mut out, "network allow", &self.network_allow);
709 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
710 push_list(&mut out, "shell allow", &self.shell_allow);
711 push_list(&mut out, "spawn allow", &self.spawn_allow);
712 push_list(&mut out, "env allow", &self.env_allow);
713 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
714 out.push_str(&render_install(&self.install_skill, &self.install_tool));
715 push_list(
716 &mut out,
717 "interpreter runtime",
718 &self.interpreter_runtime_grants,
719 );
720 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
721 push_list(
722 &mut out,
723 "state stores",
724 &self
725 .state_stores
726 .iter()
727 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
728 .collect::<Vec<_>>(),
729 );
730 push_list(&mut out, "artifact config", &self.configured_artifacts);
731
732 out.push_str("\nResource plane\n");
733 match &self.exports_files {
734 None => out.push_str(" exports.files: <none>\n"),
735 Some(export) => {
736 out.push_str(&format!(" exports.files root: {}\n", export.root));
737 out.push_str(&format!(" mode: {}\n", export.mode));
738 out.push_str(&format!(
739 " max bytes: {} (per file)\n",
740 export.max_bytes
741 ));
742 }
743 }
744
745 out.push_str(&format!(
746 " io.max: {} ({} bytes/s declared)\n",
747 match self.io_max.status {
748 IoMaxStatus::Enforced => "enforced",
749 IoMaxStatus::Unavailable => "unavailable",
750 IoMaxStatus::NotRequired => "not-required",
751 IoMaxStatus::NotProbed => "not-probed",
752 },
753 self.io_max.declared_bytes_per_sec
754 ));
755 if let Some(reason) = &self.io_max.reason {
756 out.push_str(&format!(" {reason}\n"));
757 }
758
759 out.push_str("\nPeer handoff\n");
760 match &self.peer_files {
761 None => out.push_str(" exports.peer_files: <none>\n"),
762 Some(peer) => {
763 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
764 out.push_str(&format!(
765 " max handle ttl: {}s\n",
766 peer.max_ttl_secs
767 ));
768 out.push_str(&format!(
769 " max bytes: {} (per file)\n",
770 peer.max_bytes
771 ));
772 }
773 }
774 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
775
776 out.push_str("\nRuntime writes\n");
777 out.push_str(&crate::workdir_writes::render_runtime_writes(
778 &self.runtime_writes,
779 ));
780
781 if !self.floor_met {
782 out.push_str(
783 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
784 launch here.\n",
785 );
786 }
787 out
788 }
789}
790
791#[must_use]
799pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
800 if read_only_paths.is_empty() {
801 return Vec::new();
802 }
803 shell_allow
804 .iter()
805 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
806 .cloned()
807 .collect()
808}
809
810#[must_use]
818pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
819 let mut out = String::new();
820 push_list(&mut out, "read_only", read_only_paths);
821 if read_only_paths.is_empty() {
822 return out;
823 }
824 if advisory_for.is_empty() {
825 out.push_str(
826 " read_only enforcement: enforced for every tool call and every shell command the \
827 dispatch check can read\n",
828 );
829 } else {
830 out.push_str(&format!(
831 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
832 something the dispatch check can read; enforced for every tool call and for every \
833 shell command it can read\n",
834 advisory_for.join(", ")
835 ));
836 }
837 out
838}
839
840#[must_use]
846pub fn render_install(install_skill: &[String], install_tool: &[String]) -> String {
847 let mut out = String::new();
848 push_list(&mut out, "install skill", install_skill);
849 push_list(&mut out, "install tool", install_tool);
850 out
851}
852
853fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
861 if boundary.not_protected.is_empty() {
862 return String::new();
863 }
864 let mut out = String::from("\nNot protected here\n");
865 for statement in &boundary.not_protected {
866 out.push_str(&format!(" - {statement}\n"));
867 }
868 out
869}
870
871fn push_list(out: &mut String, label: &str, values: &[String]) {
872 if values.is_empty() {
873 out.push_str(&format!(" {label}: <none>\n"));
874 return;
875 }
876 out.push_str(&format!(" {label}:\n"));
877 for value in values {
878 out.push_str(&format!(" - {value}\n"));
879 }
880}
881
882pub fn explain_scope(
893 policy: &CapabilityPolicy,
894 declared: ContainmentClass,
895 exports: Option<&Exports>,
896 state_stores: Vec<StateStoreReport>,
897 configured_artifacts: Vec<String>,
898 preopens: Vec<PreopenReport>,
899 io_max: IoMaxReport,
900) -> ScopeReport {
901 let probe = crate::sandbox::HostProbe::probe();
902 scope_report_for_tier(
903 policy,
904 declared,
905 probe.tier(),
906 probe.sealed_blocker(),
907 probe.userns_grant(),
908 exports,
909 state_stores,
910 configured_artifacts,
911 preopens,
912 io_max,
913 )
914}
915
916#[allow(clippy::too_many_arguments)]
919pub(crate) fn scope_report_for_tier(
920 policy: &CapabilityPolicy,
921 declared: ContainmentClass,
922 tier: EnforcementTier,
923 sealed_blocker: Option<SealedBlocker>,
924 userns_grant: Option<UsernsGrant>,
925 exports: Option<&Exports>,
926 state_stores: Vec<StateStoreReport>,
927 configured_artifacts: Vec<String>,
928 preopens: Vec<PreopenReport>,
929 io_max: IoMaxReport,
930) -> ScopeReport {
931 let achieved = achieved_containment_class(tier, policy.workdir_exec_allowed);
932 let applied = crate::sandbox::applied_tier(tier, declared);
937 let shortfall_reason = containment_shortfall_reason(
938 declared,
939 achieved,
940 sealed_blocker,
941 policy.workdir_exec_allowed,
942 );
943
944 ScopeReport {
945 declared_containment: declared,
946 achieved_containment: achieved,
947 floor_met: shortfall_reason.is_none(),
948 shortfall_reason,
949 enforcement_tier: enforcement_tier_name(tier),
950 userns_grant,
951 exports_files: exports
954 .and_then(|exports| exports.files.as_ref())
955 .map(ExportsFilesReport::from),
956 peer_files: exports
957 .and_then(|exports| exports.peer_files.as_ref())
958 .map(PeerFilesReport::from),
959 peer_fetch_allow: policy.peer_fetch_allow.clone(),
960 state_stores,
964 configured_artifacts,
968 filesystem_scope: policy.filesystem_scope.clone(),
969 workdir_exec: policy.workdir_exec_allowed,
970 read_only_paths: policy.read_only_paths.clone(),
971 read_only_advisory_for: read_only_advisory_for(
972 &policy.read_only_paths,
973 &policy.shell_allow,
974 ),
975 preopens,
979 network_allow: policy.network_allow.clone(),
980 unix_sockets: policy.unix_sockets_allowed,
981 shell_allow: policy.shell_allow.clone(),
982 spawn_allow: policy.spawn_allow.clone(),
983 env_allow: policy.env_allow.clone(),
984 plan_submit: policy.plan_submit,
985 install_skill: policy.install_skill.clone(),
986 install_tool: policy.install_tool.clone(),
987 interpreter_runtime_grants: policy
988 .shell_interpreter_runtime
989 .iter()
990 .flat_map(|grant| {
991 grant.dirs.iter().map(move |dir| {
992 let listable = if dir.list_dir { " (list_dir)" } else { "" };
993 format!("{}: {}{}", grant.binary, dir.path, listable)
994 })
995 })
996 .collect(),
997 staged_runtime_grants: policy
998 .shell_staged_runtime
999 .iter()
1000 .map(|grant| {
1001 format!(
1002 "{}: {} (pin: {})",
1003 grant.binary, grant.source_path, grant.pin
1004 )
1005 })
1006 .collect(),
1007 runtime_writes: crate::workdir_writes::runtime_writes(applied),
1013 io_max,
1017 filesystem_boundary: filesystem_boundary(applied),
1021 }
1022}
1023
1024fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
1027 match tier {
1028 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
1029 EnforcementTier::KernelFull => "landlock+seccomp",
1030 EnforcementTier::KernelSeccompOnly => "seccomp-only",
1031 EnforcementTier::EnvironmentOnly => "none",
1032 }
1033}
1034
1035#[cfg(test)]
1036#[allow(clippy::print_stdout, clippy::print_stderr)]
1037mod tests {
1038 use super::*;
1039 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
1040
1041 const ALL_TIERS: &[EnforcementTier] = &[
1043 EnforcementTier::KernelSealed,
1044 EnforcementTier::KernelFull,
1045 EnforcementTier::KernelSeccompOnly,
1046 EnforcementTier::EnvironmentOnly,
1047 ];
1048
1049 #[test]
1050 fn kernel_full_achieves_scoped() {
1051 assert_eq!(
1052 achieved_class_for_tier(EnforcementTier::KernelFull),
1053 ContainmentClass::Scoped
1054 );
1055 }
1056
1057 #[test]
1059 fn only_the_sealed_tier_achieves_sealed() {
1060 assert_eq!(
1061 achieved_class_for_tier(EnforcementTier::KernelSealed),
1062 ContainmentClass::Sealed
1063 );
1064 for tier in ALL_TIERS
1065 .iter()
1066 .filter(|t| **t != EnforcementTier::KernelSealed)
1067 {
1068 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
1069 }
1070 }
1071
1072 #[test]
1073 fn tiers_without_landlock_achieve_only_advisory() {
1074 assert_eq!(
1075 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
1076 ContainmentClass::Advisory
1077 );
1078 assert_eq!(
1079 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
1080 ContainmentClass::Advisory
1081 );
1082 }
1083
1084 #[test]
1090 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
1091 assert_eq!(
1093 achieved_containment_class(EnforcementTier::KernelSealed, false),
1094 ContainmentClass::Sealed
1095 );
1096 assert_eq!(
1097 achieved_containment_class(EnforcementTier::KernelFull, false),
1098 ContainmentClass::Scoped
1099 );
1100 assert_eq!(
1101 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false),
1102 ContainmentClass::Advisory
1103 );
1104 assert_eq!(
1105 achieved_containment_class(EnforcementTier::EnvironmentOnly, false),
1106 ContainmentClass::Advisory
1107 );
1108
1109 for tier in ALL_TIERS {
1111 assert_eq!(
1112 achieved_containment_class(*tier, true),
1113 ContainmentClass::Advisory,
1114 "workdir_exec must cap tier {tier:?} at advisory"
1115 );
1116 }
1117 }
1118
1119 #[test]
1122 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
1123 for tier in ALL_TIERS {
1124 assert_eq!(
1125 achieved_class_for_tier(*tier),
1126 achieved_containment_class(*tier, false),
1127 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
1128 );
1129 }
1130 }
1131
1132 #[test]
1136 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
1137 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true);
1138 assert_eq!(achieved, ContainmentClass::Advisory);
1139
1140 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true)
1141 .expect_err("workdir_exec + scoped must refuse");
1142 match error {
1143 RuntimeError::ContainmentFloorUnmet {
1144 declared,
1145 achieved,
1146 reason,
1147 } => {
1148 assert_eq!(declared, ContainmentClass::Scoped);
1149 assert_eq!(achieved, ContainmentClass::Advisory);
1150 assert!(
1151 reason.contains("workdir_exec"),
1152 "the reason must name the manifest key, got: {reason}"
1153 );
1154 assert!(
1155 !reason.contains("this host provides no kernel filesystem mediation"),
1156 "the reason must not blame the host for a manifest declaration: {reason}"
1157 );
1158 }
1159 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1160 }
1161 }
1162
1163 #[test]
1167 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
1168 let reason = containment_shortfall_reason(
1169 ContainmentClass::Sealed,
1170 ContainmentClass::Advisory,
1171 Some(SealedBlocker::AppArmorProfileMissing),
1172 true,
1173 )
1174 .expect("refusal");
1175 assert!(reason.contains("workdir_exec"));
1176 assert!(!reason.contains("apparmor_parser"));
1177 }
1178
1179 #[test]
1182 fn workdir_exec_alone_refuses_nothing() {
1183 for tier in ALL_TIERS {
1184 let achieved = achieved_containment_class(*tier, true);
1185 assert!(
1186 check_containment_floor(ContainmentClass::Advisory, achieved, None, true).is_ok(),
1187 "workdir_exec at an advisory floor must launch on tier {tier:?}"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn advisory_floor_is_met_on_every_tier() {
1194 for tier in ALL_TIERS {
1195 let achieved = achieved_class_for_tier(*tier);
1196 assert!(
1197 check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok()
1198 );
1199 }
1200 }
1201
1202 #[test]
1203 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
1204 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
1205 let achieved = achieved_class_for_tier(tier);
1206 assert!(
1207 check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok()
1208 );
1209 }
1210
1211 for tier in [
1212 EnforcementTier::KernelSeccompOnly,
1213 EnforcementTier::EnvironmentOnly,
1214 ] {
1215 let achieved = achieved_class_for_tier(tier);
1216 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, false)
1217 .expect_err("scoped must refuse without Landlock");
1218 match error {
1219 RuntimeError::ContainmentFloorUnmet {
1220 declared,
1221 achieved,
1222 reason,
1223 } => {
1224 assert_eq!(declared, ContainmentClass::Scoped);
1225 assert_eq!(achieved, ContainmentClass::Advisory);
1226 assert!(
1227 reason.contains("Landlock"),
1228 "reason must name the missing mechanism, got: {reason}"
1229 );
1230 }
1231 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1232 }
1233 }
1234 }
1235
1236 #[test]
1242 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
1243 let achieved = achieved_class_for_tier(EnforcementTier::KernelSealed);
1244 assert_eq!(achieved, ContainmentClass::Sealed);
1245 assert!(check_containment_floor(ContainmentClass::Advisory, achieved, None, false).is_ok());
1246 assert!(check_containment_floor(ContainmentClass::Scoped, achieved, None, false).is_ok());
1247 assert_eq!(
1248 containment_shortfall_reason(ContainmentClass::Scoped, achieved, None, false),
1249 None
1250 );
1251 }
1252
1253 #[test]
1254 fn sealed_floor_is_met_only_on_the_sealed_tier() {
1255 assert!(check_containment_floor(
1256 ContainmentClass::Sealed,
1257 achieved_class_for_tier(EnforcementTier::KernelSealed),
1258 None,
1259 false
1260 )
1261 .is_ok());
1262
1263 for tier in ALL_TIERS
1264 .iter()
1265 .filter(|t| **t != EnforcementTier::KernelSealed)
1266 {
1267 let achieved = achieved_class_for_tier(*tier);
1268 let error = check_containment_floor(
1269 ContainmentClass::Sealed,
1270 achieved,
1271 Some(SealedBlocker::NamespaceCreationDenied),
1272 false,
1273 )
1274 .expect_err("sealed must refuse on a host that cannot back it");
1275 match error {
1276 RuntimeError::ContainmentFloorUnmet {
1277 declared, reason, ..
1278 } => {
1279 assert_eq!(declared, ContainmentClass::Sealed);
1280 assert!(
1281 reason.contains("--cap-add SYS_ADMIN"),
1282 "reason must name the blocking mechanism's remediation, got: {reason}"
1283 );
1284 }
1285 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
1286 }
1287 }
1288 }
1289
1290 #[test]
1293 fn the_sealed_refusal_names_the_specific_blocker() {
1294 let achieved = ContainmentClass::Scoped;
1295
1296 let apparmor = containment_shortfall_reason(
1297 ContainmentClass::Sealed,
1298 achieved,
1299 Some(SealedBlocker::AppArmorProfileMissing),
1300 false,
1301 )
1302 .expect("refusal");
1303 assert!(apparmor.contains("mur-sealed"));
1304 assert!(apparmor.contains("apparmor_parser -r"));
1305 assert!(!apparmor.contains("--cap-add"));
1306
1307 let container = containment_shortfall_reason(
1308 ContainmentClass::Sealed,
1309 achieved,
1310 Some(SealedBlocker::NamespaceCreationDenied),
1311 false,
1312 )
1313 .expect("refusal");
1314 assert!(container.contains("--cap-add SYS_ADMIN"));
1315 assert!(container.contains("outside the container"));
1316 assert!(!container.contains("apparmor_parser"));
1317
1318 let pivot = containment_shortfall_reason(
1321 ContainmentClass::Sealed,
1322 ContainmentClass::Advisory,
1323 Some(SealedBlocker::PivotRootDenied),
1324 false,
1325 )
1326 .expect("refusal");
1327 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
1328 assert_ne!(
1329 pivot,
1330 containment_shortfall_reason(
1331 ContainmentClass::Sealed,
1332 ContainmentClass::Advisory,
1333 Some(SealedBlocker::MountDenied),
1334 false,
1335 )
1336 .expect("refusal")
1337 );
1338
1339 for blocker in [
1341 SealedBlocker::NotLinux,
1342 SealedBlocker::AppArmorProfileMissing,
1343 SealedBlocker::NamespaceCreationDenied,
1344 SealedBlocker::MountDenied,
1345 SealedBlocker::PivotRootDenied,
1346 SealedBlocker::KernelUnsupported,
1347 SealedBlocker::LandlockUnavailable,
1348 ] {
1349 let reason = containment_shortfall_reason(
1350 ContainmentClass::Sealed,
1351 achieved,
1352 Some(blocker),
1353 false,
1354 )
1355 .expect("refusal");
1356 assert!(
1357 !reason.contains("no host can provide it today"),
1358 "got: {reason}"
1359 );
1360 }
1361 }
1362
1363 #[test]
1366 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
1367 let reason = containment_shortfall_reason(
1368 ContainmentClass::Sealed,
1369 ContainmentClass::Advisory,
1370 None,
1371 false,
1372 )
1373 .expect("refusal");
1374 assert!(reason.contains("pivot_root"));
1375 assert!(reason.contains("CLONE_NEWNS"));
1376 }
1377
1378 #[test]
1379 fn shortfall_reason_is_absent_when_the_floor_is_met() {
1380 assert_eq!(
1381 containment_shortfall_reason(
1382 ContainmentClass::Advisory,
1383 ContainmentClass::Scoped,
1384 None,
1385 false
1386 ),
1387 None
1388 );
1389 assert_eq!(
1390 containment_shortfall_reason(
1391 ContainmentClass::Scoped,
1392 ContainmentClass::Scoped,
1393 None,
1394 false
1395 ),
1396 None
1397 );
1398 assert_eq!(
1399 containment_shortfall_reason(
1400 ContainmentClass::Sealed,
1401 ContainmentClass::Sealed,
1402 Some(SealedBlocker::NamespaceCreationDenied),
1403 false
1404 ),
1405 None,
1406 "a met floor must ignore a stale blocker rather than manufacture a refusal"
1407 );
1408 }
1409
1410 fn sample_policy() -> CapabilityPolicy {
1411 CapabilityPolicy {
1412 network_allow: vec!["https://api.example.com".to_string()],
1413 unix_sockets_allowed: false,
1414 filesystem_scope: Some("workdir".to_string()),
1415 shell_allow: vec!["python3".to_string()],
1416 spawn_allow: vec!["helper".to_string()],
1417 env_allow: vec!["TZ".to_string()],
1418 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
1419 binary: "python3".to_string(),
1420 dirs: vec![InterpreterRuntimeDir {
1421 path: "/usr/lib/python3.11".to_string(),
1422 list_dir: true,
1423 }],
1424 }],
1425 ..CapabilityPolicy::default()
1426 }
1427 }
1428
1429 #[test]
1430 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
1431 let report = scope_report_for_tier(
1432 &sample_policy(),
1433 ContainmentClass::Scoped,
1434 EnforcementTier::KernelFull,
1435 None,
1436 None,
1437 None,
1438 Vec::new(),
1439 Vec::new(),
1440 Vec::new(),
1441 IoMaxReport::default(),
1442 );
1443
1444 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
1445 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1446 assert!(report.floor_met);
1447 assert_eq!(report.shortfall_reason, None);
1448 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1449 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
1450 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
1451 assert!(!report.unix_sockets);
1452 assert_eq!(report.shell_allow, vec!["python3"]);
1453 assert_eq!(report.spawn_allow, vec!["helper"]);
1454 assert_eq!(report.env_allow, vec!["TZ"]);
1455 assert!(!report.workdir_exec);
1456 assert_eq!(
1457 report.interpreter_runtime_grants,
1458 vec!["python3: /usr/lib/python3.11 (list_dir)"]
1459 );
1460 }
1461
1462 #[test]
1466 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
1467 let policy = CapabilityPolicy {
1468 shell_allow: vec!["gcc".to_string()],
1469 workdir_exec_allowed: true,
1470 ..CapabilityPolicy::default()
1471 };
1472
1473 let report = scope_report_for_tier(
1474 &policy,
1475 ContainmentClass::Scoped,
1476 EnforcementTier::KernelFull,
1477 None,
1478 None,
1479 None,
1480 Vec::new(),
1481 Vec::new(),
1482 Vec::new(),
1483 IoMaxReport::default(),
1484 );
1485
1486 assert!(report.workdir_exec);
1487 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1488 assert!(!report.floor_met);
1489 assert_eq!(report.enforcement_tier, "landlock+seccomp");
1492 assert!(report
1493 .shortfall_reason
1494 .as_deref()
1495 .expect("refusal")
1496 .contains("workdir_exec"));
1497
1498 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1499 assert_eq!(value["workdir_exec"], true);
1500
1501 let rendered = report.render();
1502 assert!(rendered.contains("workdir exec: true"));
1503 assert!(rendered.contains("achieved: advisory"));
1504 }
1505
1506 #[test]
1510 fn scope_report_json_always_carries_io_max() {
1511 let unprobed: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1512 &sample_policy(),
1513 ContainmentClass::Scoped,
1514 EnforcementTier::KernelFull,
1515 None,
1516 None,
1517 None,
1518 Vec::new(),
1519 Vec::new(),
1520 Vec::new(),
1521 IoMaxReport::default(),
1522 ))
1523 .unwrap();
1524 assert_eq!(unprobed["io_max"]["status"], "not-probed");
1525 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
1526 assert!(unprobed["io_max"].get("reason").is_none());
1527
1528 let enforced: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1529 &sample_policy(),
1530 ContainmentClass::Scoped,
1531 EnforcementTier::KernelFull,
1532 None,
1533 None,
1534 None,
1535 Vec::new(),
1536 Vec::new(),
1537 Vec::new(),
1538 IoMaxReport {
1539 declared_bytes_per_sec: 52_428_800,
1540 status: IoMaxStatus::Enforced,
1541 reason: None,
1542 },
1543 ))
1544 .unwrap();
1545 assert_eq!(enforced["io_max"]["status"], "enforced");
1546 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
1547 }
1548
1549 #[test]
1553 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
1554 let rendered = scope_report_for_tier(
1555 &sample_policy(),
1556 ContainmentClass::Scoped,
1557 EnforcementTier::KernelFull,
1558 None,
1559 None,
1560 None,
1561 Vec::new(),
1562 Vec::new(),
1563 Vec::new(),
1564 IoMaxReport {
1565 declared_bytes_per_sec: 52_428_800,
1566 status: IoMaxStatus::Unavailable,
1567 reason: Some("the io controller is not delegated".to_string()),
1568 },
1569 )
1570 .render();
1571
1572 assert!(rendered.contains("Resource plane"), "{rendered}");
1573 assert!(rendered.contains("io.max:"), "{rendered}");
1574 assert!(rendered.contains("unavailable"), "{rendered}");
1575 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
1576 assert!(
1577 rendered.contains("the io controller is not delegated"),
1578 "{rendered}"
1579 );
1580 }
1581
1582 #[test]
1585 fn scope_report_json_always_carries_workdir_exec() {
1586 let value: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1587 &sample_policy(),
1588 ContainmentClass::Scoped,
1589 EnforcementTier::KernelFull,
1590 None,
1591 None,
1592 None,
1593 Vec::new(),
1594 Vec::new(),
1595 Vec::new(),
1596 IoMaxReport::default(),
1597 ))
1598 .unwrap();
1599 assert_eq!(value["workdir_exec"], false);
1600 }
1601
1602 #[test]
1606 fn staged_runtime_grants_are_reported_on_every_tier() {
1607 let policy = CapabilityPolicy {
1608 shell_allow: vec!["python3".to_string()],
1609 shell_staged_runtime: vec![StagedRuntimeGrant {
1610 binary: "python3".to_string(),
1611 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
1612 pin: "conda-4.10.3/python-3.9.19".to_string(),
1613 }],
1614 ..CapabilityPolicy::default()
1615 };
1616
1617 for tier in ALL_TIERS {
1618 let report = scope_report_for_tier(
1619 &policy,
1620 ContainmentClass::Sealed,
1621 *tier,
1622 Some(SealedBlocker::NamespaceCreationDenied),
1623 None,
1624 None,
1625 Vec::new(),
1626 Vec::new(),
1627 Vec::new(),
1628 IoMaxReport::default(),
1629 );
1630 assert_eq!(
1631 report.staged_runtime_grants,
1632 vec![
1633 "python3: /opt/testbed/conda/envs/django__django \
1634 (pin: conda-4.10.3/python-3.9.19)"
1635 ],
1636 "tier {tier:?} must still report the declared staged runtime"
1637 );
1638 assert!(
1639 report.render().contains("staged runtime"),
1640 "the rendered report must carry a staged runtime section on tier {tier:?}"
1641 );
1642 }
1643 }
1644
1645 #[test]
1648 fn absent_staged_runtime_reports_nothing() {
1649 let report = scope_report_for_tier(
1650 &sample_policy(),
1651 ContainmentClass::Scoped,
1652 EnforcementTier::KernelFull,
1653 None,
1654 None,
1655 None,
1656 Vec::new(),
1657 Vec::new(),
1658 Vec::new(),
1659 IoMaxReport::default(),
1660 );
1661 assert!(report.staged_runtime_grants.is_empty());
1662 assert!(report.render().contains("staged runtime: <none>"));
1663 }
1664
1665 #[test]
1669 fn grant_set_never_raises_the_achieved_class() {
1670 let report = scope_report_for_tier(
1671 &sample_policy(),
1672 ContainmentClass::Sealed,
1673 EnforcementTier::KernelFull,
1674 Some(SealedBlocker::NamespaceCreationDenied),
1675 None,
1676 None,
1677 Vec::new(),
1678 Vec::new(),
1679 Vec::new(),
1680 IoMaxReport::default(),
1681 );
1682 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
1683 assert!(!report.floor_met);
1684 }
1685
1686 #[test]
1687 fn scope_report_still_reports_when_the_floor_is_unmet() {
1688 let report = scope_report_for_tier(
1689 &sample_policy(),
1690 ContainmentClass::Sealed,
1691 EnforcementTier::EnvironmentOnly,
1692 Some(SealedBlocker::NotLinux),
1693 None,
1694 None,
1695 Vec::new(),
1696 Vec::new(),
1697 Vec::new(),
1698 IoMaxReport::default(),
1699 );
1700
1701 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
1702 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
1703 assert!(!report.floor_met);
1704 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
1705 assert_eq!(report.enforcement_tier, "none");
1706 }
1707
1708 #[test]
1709 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
1710 let report = scope_report_for_tier(
1711 &sample_policy(),
1712 ContainmentClass::Sealed,
1713 EnforcementTier::KernelSealed,
1714 None,
1715 None,
1716 None,
1717 Vec::new(),
1718 Vec::new(),
1719 Vec::new(),
1720 IoMaxReport::default(),
1721 );
1722 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
1723 assert!(report.floor_met);
1724 assert_eq!(
1725 report.enforcement_tier,
1726 "mountns+pivot_root+landlock+seccomp"
1727 );
1728 }
1729
1730 #[test]
1731 fn scope_report_json_uses_wire_names() {
1732 let report = scope_report_for_tier(
1733 &CapabilityPolicy::default(),
1734 ContainmentClass::Advisory,
1735 EnforcementTier::KernelSeccompOnly,
1736 None,
1737 None,
1738 None,
1739 Vec::new(),
1740 Vec::new(),
1741 Vec::new(),
1742 IoMaxReport::default(),
1743 );
1744 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1745
1746 assert_eq!(value["declared_containment"], "advisory");
1747 assert_eq!(value["achieved_containment"], "advisory");
1748 assert_eq!(value["floor_met"], true);
1749 assert_eq!(value["enforcement_tier"], "seccomp-only");
1750 assert!(value.get("shortfall_reason").is_none());
1752 }
1753
1754 #[test]
1757 fn the_userns_grant_is_reported_without_changing_any_verdict() {
1758 let baseline = scope_report_for_tier(
1759 &sample_policy(),
1760 ContainmentClass::Advisory,
1761 EnforcementTier::KernelSealed,
1762 None,
1763 None,
1764 None,
1765 Vec::new(),
1766 Vec::new(),
1767 Vec::new(),
1768 IoMaxReport::default(),
1769 );
1770
1771 for grant in UsernsGrant::ALL {
1772 let report = scope_report_for_tier(
1773 &sample_policy(),
1774 ContainmentClass::Advisory,
1775 EnforcementTier::KernelSealed,
1776 None,
1777 Some(*grant),
1778 None,
1779 Vec::new(),
1780 Vec::new(),
1781 Vec::new(),
1782 IoMaxReport::default(),
1783 );
1784 assert_eq!(report.achieved_containment, baseline.achieved_containment);
1785 assert_eq!(report.floor_met, baseline.floor_met);
1786 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
1787 assert_eq!(report.userns_grant, Some(*grant));
1788
1789 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
1790 assert_eq!(value["userns_grant"], grant.wire_name());
1791 assert!(report
1792 .render()
1793 .contains(&format!("userns grant: {}", grant.wire_name())));
1794 }
1795
1796 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
1799 assert!(value["userns_grant"].is_null());
1800 assert!(baseline.render().contains("userns grant: n/a"));
1801 }
1802
1803 #[test]
1804 fn rendered_report_names_both_classes_and_the_refusal() {
1805 let rendered = scope_report_for_tier(
1806 &sample_policy(),
1807 ContainmentClass::Sealed,
1808 EnforcementTier::KernelFull,
1809 Some(SealedBlocker::AppArmorProfileMissing),
1810 None,
1811 None,
1812 Vec::new(),
1813 Vec::new(),
1814 Vec::new(),
1815 IoMaxReport::default(),
1816 )
1817 .render();
1818
1819 assert!(rendered.contains("declared: sealed"));
1820 assert!(rendered.contains("achieved: scoped"));
1821 assert!(rendered.contains("floor met: no"));
1822 assert!(rendered.contains("mur-sealed"));
1823 assert!(rendered.contains("would refuse to launch"));
1824 }
1825
1826 #[test]
1830 fn an_export_never_changes_what_the_report_says_about_containment() {
1831 let export = FileExport {
1832 root: "out/".to_string(),
1833 mode: ExportMode::ReadOnly,
1834 max_bytes: 10 * 1024 * 1024,
1835 };
1836 for tier in ALL_TIERS {
1837 for workdir_exec in [false, true] {
1838 let policy = CapabilityPolicy {
1839 workdir_exec_allowed: workdir_exec,
1840 ..sample_policy()
1841 };
1842 let without = scope_report_for_tier(
1843 &policy,
1844 ContainmentClass::Scoped,
1845 *tier,
1846 None,
1847 None,
1848 None,
1849 Vec::new(),
1850 Vec::new(),
1851 Vec::new(),
1852 IoMaxReport::default(),
1853 );
1854 let with = scope_report_for_tier(
1855 &policy,
1856 ContainmentClass::Scoped,
1857 *tier,
1858 None,
1859 None,
1860 Some(&Exports {
1861 files: Some(export.clone()),
1862 peer_files: None,
1863 }),
1864 Vec::new(),
1865 Vec::new(),
1866 Vec::new(),
1867 IoMaxReport::default(),
1868 );
1869 assert_eq!(
1870 with.achieved_containment, without.achieved_containment,
1871 "tier {tier:?}, workdir_exec {workdir_exec}"
1872 );
1873 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1874 assert_eq!(with.floor_met, without.floor_met);
1875 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1876 assert_eq!(
1879 ScopeReport {
1880 exports_files: None,
1881 ..with.clone()
1882 },
1883 without
1884 );
1885 assert_eq!(
1886 with.exports_files,
1887 Some(ExportsFilesReport {
1888 root: "out/".to_string(),
1889 mode: ExportMode::ReadOnly,
1890 max_bytes: 10 * 1024 * 1024,
1891 })
1892 );
1893 }
1894 }
1895 }
1896
1897 #[test]
1901 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
1902 let peer_files = PeerFilesExport {
1903 root: "out/".to_string(),
1904 max_ttl_secs: Some(900),
1905 max_bytes: 10 * 1024 * 1024,
1906 };
1907 for tier in ALL_TIERS {
1908 for workdir_exec in [false, true] {
1909 let without_policy = CapabilityPolicy {
1910 workdir_exec_allowed: workdir_exec,
1911 ..sample_policy()
1912 };
1913 let with_policy = CapabilityPolicy {
1914 peer_fetch_allow: vec!["localhost:41234".to_string()],
1915 ..without_policy.clone()
1916 };
1917 let without = scope_report_for_tier(
1918 &without_policy,
1919 ContainmentClass::Scoped,
1920 *tier,
1921 None,
1922 None,
1923 None,
1924 Vec::new(),
1925 Vec::new(),
1926 Vec::new(),
1927 IoMaxReport::default(),
1928 );
1929 let with = scope_report_for_tier(
1930 &with_policy,
1931 ContainmentClass::Scoped,
1932 *tier,
1933 None,
1934 None,
1935 Some(&Exports {
1936 files: None,
1937 peer_files: Some(peer_files.clone()),
1938 }),
1939 Vec::new(),
1940 Vec::new(),
1941 Vec::new(),
1942 IoMaxReport::default(),
1943 );
1944 assert_eq!(
1945 with.achieved_containment, without.achieved_containment,
1946 "tier {tier:?}, workdir_exec {workdir_exec}"
1947 );
1948 assert_eq!(with.enforcement_tier, without.enforcement_tier);
1949 assert_eq!(with.floor_met, without.floor_met);
1950 assert_eq!(with.shortfall_reason, without.shortfall_reason);
1951 assert_eq!(
1952 ScopeReport {
1953 peer_files: None,
1954 peer_fetch_allow: Vec::new(),
1955 ..with.clone()
1956 },
1957 without
1958 );
1959 assert_eq!(
1960 with.peer_files,
1961 Some(PeerFilesReport {
1962 root: "out/".to_string(),
1963 max_ttl_secs: 900,
1964 max_bytes: 10 * 1024 * 1024,
1965 })
1966 );
1967 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
1968 assert_eq!(with.network_allow, without.network_allow);
1972 }
1973 }
1974 }
1975
1976 #[test]
1980 fn the_peer_fields_are_always_serialized() {
1981 let undeclared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1982 &sample_policy(),
1983 ContainmentClass::Advisory,
1984 EnforcementTier::KernelSealed,
1985 None,
1986 None,
1987 None,
1988 Vec::new(),
1989 Vec::new(),
1990 Vec::new(),
1991 IoMaxReport::default(),
1992 ))
1993 .unwrap();
1994 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
1995 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
1996
1997 let declared: serde_json::Value = serde_json::to_value(scope_report_for_tier(
1998 &CapabilityPolicy {
1999 peer_fetch_allow: vec!["localhost:41234".to_string()],
2000 ..sample_policy()
2001 },
2002 ContainmentClass::Advisory,
2003 EnforcementTier::KernelSealed,
2004 None,
2005 None,
2006 Some(&Exports {
2007 files: None,
2008 peer_files: Some(PeerFilesExport {
2009 root: "out/".to_string(),
2010 max_ttl_secs: None,
2011 max_bytes: 10 * 1024 * 1024,
2012 }),
2013 }),
2014 Vec::new(),
2015 Vec::new(),
2016 Vec::new(),
2017 IoMaxReport::default(),
2018 ))
2019 .unwrap();
2020 assert_eq!(
2021 declared["peer_files"],
2022 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
2023 );
2024 assert_eq!(
2025 declared["peer_fetch_allow"],
2026 serde_json::json!(["localhost:41234"])
2027 );
2028 }
2029
2030 #[test]
2031 fn render_names_peer_handoff_in_both_directions() {
2032 let undeclared = scope_report_for_tier(
2033 &sample_policy(),
2034 ContainmentClass::Advisory,
2035 EnforcementTier::KernelSealed,
2036 None,
2037 None,
2038 None,
2039 Vec::new(),
2040 Vec::new(),
2041 Vec::new(),
2042 IoMaxReport::default(),
2043 )
2044 .render();
2045 assert!(undeclared.contains("Peer handoff"));
2046 assert!(undeclared.contains("exports.peer_files: <none>"));
2047 assert!(undeclared.contains("peer_fetch allow: <none>"));
2048
2049 let declared = scope_report_for_tier(
2050 &CapabilityPolicy {
2051 peer_fetch_allow: vec!["localhost:41234".to_string()],
2052 ..sample_policy()
2053 },
2054 ContainmentClass::Advisory,
2055 EnforcementTier::KernelSealed,
2056 None,
2057 None,
2058 Some(&Exports {
2059 files: None,
2060 peer_files: Some(PeerFilesExport {
2061 root: "out/".to_string(),
2062 max_ttl_secs: Some(900),
2063 max_bytes: 4096,
2064 }),
2065 }),
2066 Vec::new(),
2067 Vec::new(),
2068 Vec::new(),
2069 IoMaxReport::default(),
2070 )
2071 .render();
2072 assert!(declared.contains("exports.peer_files root: out/"));
2073 assert!(declared.contains("max handle ttl: 900s"));
2074 assert!(declared.contains("max bytes: 4096 (per file)"));
2075 assert!(declared.contains("- localhost:41234"));
2076 }
2077
2078 #[test]
2082 fn a_state_store_is_reported_and_moves_no_other_field() {
2083 let policy = sample_policy();
2084 let stores = vec![StateStoreReport {
2085 artifact: "murmur-tool-corpus".to_string(),
2086 store: "shey".to_string(),
2087 host_path: "/home/dev/.murmur/state/shey".to_string(),
2088 }];
2089
2090 let without = scope_report_for_tier(
2091 &policy,
2092 ContainmentClass::Scoped,
2093 EnforcementTier::KernelFull,
2094 None,
2095 None,
2096 None,
2097 Vec::new(),
2098 Vec::new(),
2099 Vec::new(),
2100 IoMaxReport::default(),
2101 );
2102 let with = scope_report_for_tier(
2103 &policy,
2104 ContainmentClass::Scoped,
2105 EnforcementTier::KernelFull,
2106 None,
2107 None,
2108 None,
2109 stores.clone(),
2110 Vec::new(),
2111 Vec::new(),
2112 IoMaxReport::default(),
2113 );
2114
2115 assert_eq!(with.state_stores, stores);
2116 assert!(without.state_stores.is_empty());
2117 assert_eq!(with.achieved_containment, without.achieved_containment);
2118 assert_eq!(with.floor_met, without.floor_met);
2119 assert_eq!(with.enforcement_tier, without.enforcement_tier);
2120 assert_eq!(with.shortfall_reason, without.shortfall_reason);
2121 assert_eq!(
2123 ScopeReport {
2124 state_stores: Vec::new(),
2125 ..with.clone()
2126 },
2127 without
2128 );
2129
2130 assert!(with
2132 .render()
2133 .contains("- murmur-tool-corpus: shey -> /home/dev/.murmur/state/shey"));
2134 assert!(without.render().contains("state stores: <none>"));
2135
2136 let json = serde_json::to_value(&without).unwrap();
2139 assert_eq!(json["state_stores"], serde_json::json!([]));
2140 assert_eq!(
2141 serde_json::to_value(&with).unwrap()["state_stores"],
2142 serde_json::json!([{
2143 "artifact": "murmur-tool-corpus",
2144 "store": "shey",
2145 "host_path": "/home/dev/.murmur/state/shey",
2146 }])
2147 );
2148 }
2149
2150 #[test]
2155 fn preopens_are_reported_and_move_no_other_field() {
2156 let policy = sample_policy();
2157 let preopens = vec![
2158 PreopenReport {
2159 artifact: "notes-tool".to_string(),
2160 role: "tool".to_string(),
2161 scope: None,
2162 surface: PreopenSurface::WholeWorkdir,
2163 },
2164 PreopenReport {
2165 artifact: "cache-tool".to_string(),
2166 role: "tool".to_string(),
2167 scope: Some("cache".to_string()),
2168 surface: PreopenSurface::ScopedSubtree,
2169 },
2170 PreopenReport {
2171 artifact: "telemetry-hook".to_string(),
2172 role: "hook".to_string(),
2173 scope: None,
2174 surface: PreopenSurface::Nothing,
2175 },
2176 ];
2177
2178 let without = scope_report_for_tier(
2179 &policy,
2180 ContainmentClass::Scoped,
2181 EnforcementTier::KernelFull,
2182 None,
2183 None,
2184 None,
2185 Vec::new(),
2186 Vec::new(),
2187 Vec::new(),
2188 IoMaxReport::default(),
2189 );
2190 let with = scope_report_for_tier(
2191 &policy,
2192 ContainmentClass::Scoped,
2193 EnforcementTier::KernelFull,
2194 None,
2195 None,
2196 None,
2197 Vec::new(),
2198 Vec::new(),
2199 preopens.clone(),
2200 IoMaxReport::default(),
2201 );
2202
2203 assert_eq!(with.preopens, preopens);
2204 assert!(without.preopens.is_empty());
2205 assert_eq!(
2207 ScopeReport {
2208 preopens: Vec::new(),
2209 ..with.clone()
2210 },
2211 without
2212 );
2213
2214 let rendered = with.render();
2216 assert!(rendered.contains(
2217 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
2218 declared"
2219 ));
2220 assert!(rendered.contains(
2221 "- cache-tool (tool): one subtree of the accessible workdir — \
2222 capabilities.filesystem.scope: cache"
2223 ));
2224 assert!(rendered.contains(
2225 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
2226 ));
2227 assert!(without.render().contains("preopens: <none>"));
2228
2229 assert_eq!(
2231 serde_json::to_value(&without).unwrap()["preopens"],
2232 serde_json::json!([])
2233 );
2234 assert_eq!(
2235 serde_json::to_value(&with).unwrap()["preopens"],
2236 serde_json::json!([
2237 {
2238 "artifact": "notes-tool",
2239 "role": "tool",
2240 "scope": null,
2241 "surface": "whole-workdir",
2242 },
2243 {
2244 "artifact": "cache-tool",
2245 "role": "tool",
2246 "scope": "cache",
2247 "surface": "scoped-subtree",
2248 },
2249 {
2250 "artifact": "telemetry-hook",
2251 "role": "hook",
2252 "scope": null,
2253 "surface": "nothing",
2254 },
2255 ])
2256 );
2257 }
2258
2259 #[test]
2263 fn exports_files_is_always_serialized() {
2264 let undeclared = serde_json::to_value(scope_report_for_tier(
2265 &sample_policy(),
2266 ContainmentClass::Advisory,
2267 EnforcementTier::KernelFull,
2268 None,
2269 None,
2270 None,
2271 Vec::new(),
2272 Vec::new(),
2273 Vec::new(),
2274 IoMaxReport::default(),
2275 ))
2276 .unwrap();
2277 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
2278
2279 let declared = serde_json::to_value(scope_report_for_tier(
2280 &sample_policy(),
2281 ContainmentClass::Advisory,
2282 EnforcementTier::KernelFull,
2283 None,
2284 None,
2285 Some(&Exports {
2286 files: Some(FileExport {
2287 root: "out/".to_string(),
2288 mode: ExportMode::ReadOnly,
2289 max_bytes: 10_485_760,
2290 }),
2291 peer_files: None,
2292 }),
2293 Vec::new(),
2294 Vec::new(),
2295 Vec::new(),
2296 IoMaxReport::default(),
2297 ))
2298 .unwrap();
2299 assert_eq!(
2300 declared["exports_files"],
2301 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
2302 );
2303 }
2304
2305 #[test]
2306 fn render_names_the_resource_plane_in_both_directions() {
2307 let undeclared = scope_report_for_tier(
2308 &sample_policy(),
2309 ContainmentClass::Advisory,
2310 EnforcementTier::KernelFull,
2311 None,
2312 None,
2313 None,
2314 Vec::new(),
2315 Vec::new(),
2316 Vec::new(),
2317 IoMaxReport::default(),
2318 )
2319 .render();
2320 assert!(
2321 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
2322 "{undeclared}"
2323 );
2324
2325 let declared = scope_report_for_tier(
2326 &sample_policy(),
2327 ContainmentClass::Advisory,
2328 EnforcementTier::KernelFull,
2329 None,
2330 None,
2331 Some(&Exports {
2332 files: Some(FileExport {
2333 root: "out/".to_string(),
2334 mode: ExportMode::ReadOnly,
2335 max_bytes: 10_485_760,
2336 }),
2337 peer_files: None,
2338 }),
2339 Vec::new(),
2340 Vec::new(),
2341 Vec::new(),
2342 IoMaxReport::default(),
2343 )
2344 .render();
2345 assert!(declared.contains("exports.files root: out/"), "{declared}");
2346 assert!(
2347 declared.contains("mode: read-only"),
2348 "{declared}"
2349 );
2350 assert!(
2351 declared.contains("max bytes: 10485760 (per file)"),
2352 "{declared}"
2353 );
2354 }
2355
2356 fn report_for(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
2359 let policy = CapabilityPolicy {
2360 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
2361 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
2362 ..CapabilityPolicy::default()
2363 };
2364 scope_report_for_tier(
2365 &policy,
2366 ContainmentClass::Scoped,
2367 EnforcementTier::KernelFull,
2368 None,
2369 None,
2370 None,
2371 Vec::new(),
2372 Vec::new(),
2373 Vec::new(),
2374 IoMaxReport::default(),
2375 )
2376 }
2377
2378 #[test]
2381 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
2382 let report = report_for(&["tests", "bench/fixtures"], &["git"]);
2383 assert_eq!(
2384 report.read_only_paths,
2385 vec!["tests".to_string(), "bench/fixtures".to_string()]
2386 );
2387 assert!(report.read_only_advisory_for.is_empty());
2388
2389 let rendered = report.render();
2390 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
2391 assert!(rendered.contains(
2392 "read_only enforcement: enforced for every tool call and every shell command the \
2393 dispatch check can read"
2394 ));
2395 assert!(!rendered.contains("advisory against"));
2396 }
2397
2398 #[test]
2401 fn an_allowlisted_interpreter_is_named_as_advisory() {
2402 let report = report_for(&["tests"], &["git", "python3"]);
2403 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
2404
2405 let rendered = report.render();
2406 assert!(rendered.contains("read_only enforcement: advisory against python3"));
2407 assert!(rendered
2408 .contains("enforced for every tool call and for every shell command it can read"));
2409 }
2410
2411 #[test]
2415 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
2416 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
2417 .iter()
2418 .map(|b| (*b).to_string())
2419 .collect();
2420 let declared = vec!["tests".to_string()];
2421 assert_eq!(
2422 report_for(&["tests"], &["git", "python3", "node", "make", "bash"])
2423 .read_only_advisory_for,
2424 read_only_advisory_for(&declared, &shell_allow)
2425 );
2426 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
2428 }
2429
2430 #[test]
2434 fn both_read_only_keys_are_always_serialized_arrays() {
2435 let json = serde_json::to_value(report_for(&[], &["python3"])).unwrap();
2436 assert_eq!(json["read_only_paths"], serde_json::json!([]));
2437 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
2438
2439 let json = serde_json::to_value(report_for(&["tests"], &["python3"])).unwrap();
2440 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
2441 assert_eq!(
2442 json["read_only_advisory_for"],
2443 serde_json::json!(["python3"])
2444 );
2445 }
2446
2447 #[test]
2450 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
2451 let rendered = report_for(&[], &["python3"]).render();
2452 assert!(rendered.contains(" read_only: <none>\n"));
2453 assert!(!rendered.contains("read_only enforcement:"));
2454 }
2455
2456 #[test]
2468 fn every_capability_policy_field_is_reported_or_exempted() {
2469 const EXEMPT: &[(&str, &str)] = &[
2471 (
2472 "shell_strip_env",
2473 "Names host variables removed from the shell child's environment on top of the \
2474 credential patterns the runtime always strips. It subtracts from what the \
2475 capsule sees and can open nothing, so reporting it beside the grants would pad \
2476 the list an operator audits for reach.",
2477 ),
2478 (
2479 "shell_baseline_env",
2480 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
2481 environment of a subprocess the capsule was already granted, and names no host \
2482 resource `shell allow` and `env allow` do not already account for.",
2483 ),
2484 (
2485 "limits",
2486 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
2487 deadline. A bound on how much the capsule may consume, never a reach it gains.",
2488 ),
2489 (
2490 "resources",
2491 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
2492 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
2493 ),
2494 (
2495 "declared_deadline_seconds",
2496 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
2497 can apply a lower default. Reporting it would report one ceiling twice.",
2498 ),
2499 (
2500 "state_declared",
2501 "Records that a capsule-wide `capabilities.state` block was declared where \
2502 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
2503 exist are reported per artifact in `state_stores`.",
2504 ),
2505 (
2506 "conversation_declared",
2507 "Records a capsule-wide `capabilities.conversation` block that reaches no \
2508 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
2509 and the grant itself is per artifact.",
2510 ),
2511 ];
2512
2513 const REPORTED: &[(&str, &str)] = &[
2515 ("network_allow", "network_allow"),
2516 ("peer_fetch_allow", "peer_fetch_allow"),
2517 ("unix_sockets_allowed", "unix_sockets"),
2518 ("filesystem_scope", "filesystem_scope"),
2519 ("workdir_exec_allowed", "workdir_exec"),
2520 ("read_only_paths", "read_only_paths"),
2521 ("shell_allow", "shell_allow"),
2522 ("spawn_allow", "spawn_allow"),
2523 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
2524 ("shell_staged_runtime", "staged_runtime_grants"),
2525 ("env_allow", "env_allow"),
2526 ("plan_submit", "plan_submit"),
2527 ("install_skill", "install_skill"),
2528 ("install_tool", "install_tool"),
2529 ("containment_floor", "declared_containment"),
2530 ];
2531
2532 let declared = capability_policy_field_names(include_str!("types.rs"));
2533 assert!(
2534 declared.len() > 10,
2535 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
2536 );
2537
2538 for field in &declared {
2539 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
2540 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
2541 assert!(
2542 reported || exempt,
2543 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
2544 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
2545 with a written reason for why an operator does not need to see it."
2546 );
2547 assert!(
2548 !(reported && exempt),
2549 "CapabilityPolicy::{field} is listed as both reported and exempt"
2550 );
2551 }
2552
2553 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
2554 assert!(
2555 declared.iter().any(|field| field == policy_field),
2556 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
2557 declares — remove the stale row"
2558 );
2559 }
2560
2561 let json = serde_json::to_value(scope_report_for_tier(
2564 &sample_policy(),
2565 ContainmentClass::Scoped,
2566 EnforcementTier::KernelFull,
2567 None,
2568 None,
2569 None,
2570 Vec::new(),
2571 Vec::new(),
2572 Vec::new(),
2573 IoMaxReport::default(),
2574 ))
2575 .unwrap();
2576 let object = json.as_object().expect("a scope report is a JSON object");
2577 for (policy_field, report_key) in REPORTED {
2578 assert!(
2579 object.contains_key(*report_key),
2580 "the table maps CapabilityPolicy::{policy_field} to a report key \
2581 '{report_key}' that a serialized ScopeReport does not carry"
2582 );
2583 }
2584 }
2585
2586 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
2589 scope_report_for_tier(
2590 &sample_policy(),
2591 declared,
2592 tier,
2593 None,
2594 None,
2595 None,
2596 Vec::new(),
2597 Vec::new(),
2598 Vec::new(),
2599 IoMaxReport::default(),
2600 )
2601 }
2602
2603 #[test]
2605 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
2606 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2607
2608 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2609 assert_eq!(
2610 report.filesystem_boundary.restriction,
2611 FilesystemRestriction::Advisory
2612 );
2613 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2614
2615 let filesystem = &report.filesystem_boundary.not_protected[0];
2616 assert!(filesystem.contains("advisory"), "{filesystem}");
2617 assert!(
2618 filesystem.contains("denies a read or a write outside the session workdir"),
2619 "{filesystem}"
2620 );
2621
2622 let rewriting = &report.filesystem_boundary.not_protected[1];
2623 assert!(rewriting.contains("not a boundary"), "{rewriting}");
2624 assert!(rewriting.contains("HOME"), "{rewriting}");
2625
2626 let rendered = report.render();
2627 let heading = rendered
2628 .find("\nNot protected here\n")
2629 .expect("a session that composes no root prints the block");
2630 assert!(
2631 heading > rendered.find("Containment\n").unwrap(),
2632 "the block belongs under Containment:\n{rendered}"
2633 );
2634 assert!(
2635 heading < rendered.find("\nEffective grants\n").unwrap(),
2636 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
2637 );
2638 for statement in &report.filesystem_boundary.not_protected {
2639 assert!(rendered.contains(statement.as_str()), "{rendered}");
2640 }
2641 }
2642
2643 #[test]
2646 fn a_landlock_session_reports_denial_rather_than_absence() {
2647 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2648
2649 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
2650 assert_eq!(
2651 report.filesystem_boundary.restriction,
2652 FilesystemRestriction::Enforced
2653 );
2654 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2655
2656 let filesystem = &report.filesystem_boundary.not_protected[0];
2657 assert!(
2658 !filesystem.contains("advisory"),
2659 "Landlock mediates the filesystem here: {filesystem}"
2660 );
2661 assert!(
2662 filesystem.contains("denied rather than absent"),
2663 "{filesystem}"
2664 );
2665 for probe in ["stat", "access", "readlink"] {
2666 assert!(filesystem.contains(probe), "{filesystem}");
2667 }
2668 }
2669
2670 #[test]
2675 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
2676 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
2677
2678 assert_eq!(
2679 report.achieved_containment,
2680 ContainmentClass::Sealed,
2681 "the host can back sealed, which is what `achieved` answers"
2682 );
2683 assert_eq!(
2684 report.filesystem_boundary.restriction,
2685 FilesystemRestriction::Enforced,
2686 "no composed root is installed for a capsule that did not declare one"
2687 );
2688 assert_eq!(
2689 report.filesystem_boundary.not_protected,
2690 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
2691 .filesystem_boundary
2692 .not_protected,
2693 "the same mechanism gets the same statements on either host"
2694 );
2695 assert!(report.render().contains("Not protected here"));
2696 }
2697
2698 #[test]
2701 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
2702 let advisory =
2703 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
2704 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
2705
2706 assert_eq!(
2707 advisory.filesystem_boundary.not_protected[1],
2708 enforced.filesystem_boundary.not_protected[1]
2709 );
2710 assert!(
2711 advisory.filesystem_boundary.not_protected[1]
2712 .contains(crate::shell::SYNTHETIC_HOME_DIR_NAME),
2713 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
2714 );
2715 }
2716
2717 #[test]
2720 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
2721 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
2722
2723 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
2724 assert_eq!(
2725 report.filesystem_boundary.restriction,
2726 FilesystemRestriction::Absent
2727 );
2728 assert!(report.filesystem_boundary.not_protected.is_empty());
2729 assert!(
2730 !report.render().contains("Not protected here"),
2731 "{}",
2732 report.render()
2733 );
2734
2735 let json = serde_json::to_value(&report).unwrap();
2736 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
2737 assert_eq!(
2738 json["filesystem_boundary"]["not_protected"],
2739 serde_json::json!([]),
2740 "the key is present and empty at absent, never missing"
2741 );
2742 }
2743
2744 #[test]
2748 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
2749 let policy = CapabilityPolicy {
2750 workdir_exec_allowed: true,
2751 ..CapabilityPolicy::default()
2752 };
2753
2754 let report = scope_report_for_tier(
2755 &policy,
2756 ContainmentClass::Advisory,
2757 EnforcementTier::KernelSealed,
2758 None,
2759 None,
2760 None,
2761 Vec::new(),
2762 Vec::new(),
2763 Vec::new(),
2764 IoMaxReport::default(),
2765 );
2766
2767 assert_eq!(
2768 report.enforcement_tier,
2769 "mountns+pivot_root+landlock+seccomp"
2770 );
2771 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
2772 assert_eq!(
2773 report.filesystem_boundary.restriction,
2774 FilesystemRestriction::Enforced
2775 );
2776 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
2777 assert!(report.render().contains("Not protected here"));
2778 }
2779
2780 #[test]
2783 fn the_restriction_follows_the_tier_the_session_would_install() {
2784 for tier in ALL_TIERS {
2785 for declared in [
2786 ContainmentClass::Advisory,
2787 ContainmentClass::Scoped,
2788 ContainmentClass::Sealed,
2789 ] {
2790 let report = boundary_report(declared, *tier);
2791 let expected = match crate::sandbox::applied_tier(*tier, declared) {
2792 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
2793 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
2794 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
2795 FilesystemRestriction::Advisory
2796 }
2797 };
2798 assert_eq!(
2799 report.filesystem_boundary.restriction, expected,
2800 "{tier:?} / {declared:?}"
2801 );
2802 assert_eq!(
2803 report.filesystem_boundary.not_protected.is_empty(),
2804 expected == FilesystemRestriction::Absent,
2805 "{tier:?} / {declared:?}"
2806 );
2807 let wire = match expected {
2808 FilesystemRestriction::Advisory => "advisory",
2809 FilesystemRestriction::Enforced => "enforced",
2810 FilesystemRestriction::Absent => "absent",
2811 };
2812 assert_eq!(
2813 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
2814 serde_json::Value::String(wire.to_string())
2815 );
2816 }
2817 }
2818 }
2819
2820 #[test]
2833 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
2834 if crate::network_namespace::skip_without_egress_namespace(
2835 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
2836 ) {
2837 return;
2838 }
2839 if crate::sandbox::find_on_path("bash").is_none() {
2840 eprintln!(
2841 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
2842 no bash on PATH"
2843 );
2844 return;
2845 }
2846 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
2847 let secret = outside.path().join("host-file");
2848 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
2849 let secret = secret.to_str().unwrap().to_string();
2850
2851 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
2852 let workdir = tempfile::tempdir().unwrap();
2853 let policy = CapabilityPolicy {
2854 shell_allow: vec!["bash".to_string()],
2855 ..CapabilityPolicy::default()
2856 };
2857 let probe = crate::sandbox::HostProbe::probe();
2858 let enforcement =
2859 crate::sandbox::ShellEnforcement::resolve(&policy, declared, probe).unwrap();
2860 let report = scope_report_for_tier(
2861 &policy,
2862 declared,
2863 probe.tier(),
2864 None,
2865 None,
2866 None,
2867 Vec::new(),
2868 Vec::new(),
2869 Vec::new(),
2870 IoMaxReport::default(),
2871 );
2872
2873 let command = format!(
2874 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
2875 else echo read=refused; fi; \
2876 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
2877 );
2878 let result = crate::shell::execute_shell(
2879 "bash",
2880 &["-c", &command],
2881 &[],
2882 workdir.path(),
2883 workdir.path(),
2884 &policy,
2885 &enforcement,
2886 )
2887 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
2888 let seen = result.stdout;
2889
2890 match report.filesystem_boundary.restriction {
2891 FilesystemRestriction::Advisory => assert!(
2892 seen.contains("read=PROBE-CONTENTS"),
2893 "reported advisory, so nothing denies the read: {seen}"
2894 ),
2895 FilesystemRestriction::Enforced => assert!(
2896 seen.contains("read=refused") && seen.contains("exists=yes"),
2897 "reported enforced, so the read is denied and the path still exists: {seen}"
2898 ),
2899 FilesystemRestriction::Absent => assert!(
2900 seen.contains("read=refused") && seen.contains("exists=no"),
2901 "reported absent, so the path is not in the composed root: {seen}"
2902 ),
2903 }
2904 }
2905 }
2906
2907 fn capability_policy_field_names(source: &str) -> Vec<String> {
2909 source
2910 .split_once("pub struct CapabilityPolicy {")
2911 .expect("types.rs declares CapabilityPolicy")
2912 .1
2913 .split_once("\n}")
2914 .expect("CapabilityPolicy's declaration is closed at column 0")
2915 .0
2916 .lines()
2917 .filter_map(|line| {
2918 line.strip_prefix(" pub ")
2919 .and_then(|rest| rest.split_once(':'))
2920 .map(|(name, _)| name.to_string())
2921 })
2922 .collect()
2923 }
2924}