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