1use murmur_artifact::{ContainmentClass, ExportMode, Exports, FileExport, PeerFilesExport};
34use serde::Serialize;
35
36use crate::{
37 cgroup::{CgroupScopeReport, CgroupScopeStatus, IoMaxReport, IoMaxStatus},
38 errors::RuntimeError,
39 runtime_dir::RuntimeDirPlacement,
40 sandbox::{EnforcementTier, HostProbe},
41 sealed::{SealedShortfall, UsernsGrant},
42 types::CapabilityPolicy,
43};
44
45pub use crate::sandbox::{detect_sealed_shortfall, detect_userns_grant};
46
47pub(crate) fn achieved_class_for_tier(tier: EnforcementTier) -> ContainmentClass {
52 match tier {
53 EnforcementTier::KernelSealed => ContainmentClass::Sealed,
58 EnforcementTier::KernelFull => ContainmentClass::Scoped,
61 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
65 ContainmentClass::Advisory
66 }
67 EnforcementTier::Unconfined => ContainmentClass::Unconfined,
69 }
70}
71
72fn achieved_containment_class(
91 tier: EnforcementTier,
92 workdir_exec: bool,
93 runtime_dir_inside_workdir: bool,
94) -> ContainmentClass {
95 let class = achieved_class_for_tier(tier);
96 if workdir_exec || runtime_dir_inside_workdir {
97 class.min(ContainmentClass::Advisory)
98 } else {
99 class
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct ContainmentDecision {
113 declared: ContainmentClass,
114 host_tier: EnforcementTier,
115 applied_tier: EnforcementTier,
116 achieved: ContainmentClass,
117}
118
119impl ContainmentDecision {
120 pub(crate) fn decide(
124 host_tier: EnforcementTier,
125 declared: ContainmentClass,
126 workdir_exec: bool,
127 runtime_dir_inside_workdir: bool,
128 ) -> Self {
129 let applied_tier = crate::sandbox::applied_tier(host_tier, declared);
130 Self {
131 declared,
132 host_tier,
133 applied_tier,
134 achieved: achieved_containment_class(
135 applied_tier,
136 workdir_exec,
137 runtime_dir_inside_workdir,
138 ),
139 }
140 }
141
142 pub fn declared(&self) -> ContainmentClass {
144 self.declared
145 }
146
147 pub fn achieved(&self) -> ContainmentClass {
149 self.achieved
150 }
151
152 pub fn host_containment(&self) -> ContainmentClass {
155 achieved_class_for_tier(self.host_tier)
156 }
157
158 pub fn enforcement_tier(&self) -> &'static str {
161 enforcement_tier_name(self.applied_tier)
162 }
163
164 pub(crate) fn applied_tier(&self) -> EnforcementTier {
166 self.applied_tier
167 }
168}
169
170pub fn detect_containment_decision(
178 declared: ContainmentClass,
179) -> (ContainmentDecision, Option<SealedShortfall>) {
180 let probe = HostProbe::probe();
181 (
182 ContainmentDecision::decide(probe.tier(), declared, false, false),
183 probe.sealed_shortfall(),
184 )
185}
186
187pub fn containment_shortfall_reason(
205 declared: ContainmentClass,
206 achieved: ContainmentClass,
207 sealed_shortfall: Option<&SealedShortfall>,
208 workdir_exec: bool,
209 runtime_dir_inside: Option<&RuntimeDirPlacement>,
210) -> Option<String> {
211 containment_shortfall(
212 declared,
213 achieved,
214 sealed_shortfall,
215 workdir_exec,
216 runtime_dir_inside,
217 )
218 .map(|(reason, _)| reason)
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum ShortfallCause {
225 Host,
227 WorkdirExec,
229 InsideWorkdir,
232}
233
234fn containment_shortfall(
236 declared: ContainmentClass,
237 achieved: ContainmentClass,
238 sealed_shortfall: Option<&SealedShortfall>,
239 workdir_exec: bool,
240 runtime_dir_inside: Option<&RuntimeDirPlacement>,
241) -> Option<(String, ShortfallCause)> {
242 if achieved >= declared {
243 return None;
244 }
245
246 if workdir_exec && declared > ContainmentClass::Advisory {
247 return Some((
248 WORKDIR_EXEC_SHORTFALL.to_string(),
249 ShortfallCause::WorkdirExec,
250 ));
251 }
252
253 if let Some(placement) = runtime_dir_inside.filter(|_| declared > ContainmentClass::Advisory) {
254 return Some((placement.inside_shortfall(), ShortfallCause::InsideWorkdir));
255 }
256
257 let reason = match declared {
258 ContainmentClass::Unconfined => {
261 "unconfined declares no floor and is met by every session".to_string()
262 }
263 ContainmentClass::Advisory => {
267 "advisory is the weakest containment class and is satisfied by every host".to_string()
268 }
269 ContainmentClass::Scoped => {
270 "scoped requires Landlock filesystem mediation (Linux 5.13+ with a usable Landlock \
271 ABI); this host provides no kernel filesystem mediation, so paths outside the \
272 workdir are constrained by convention only"
273 .to_string()
274 }
275 ContainmentClass::Sealed => match sealed_shortfall {
278 Some(shortfall) => shortfall.reason(),
279 None => "sealed requires a private mount namespace pivoted onto a composed root \
280 (unshare(CLONE_NEWUSER|CLONE_NEWNS) + pivot_root), which this host did not \
281 provide"
282 .to_string(),
283 },
284 };
285
286 Some((reason, ShortfallCause::Host))
287}
288
289const WORKDIR_EXEC_SHORTFALL: &str =
294 "capabilities.filesystem.workdir_exec: true keeps the Landlock Execute right on the session \
295 workdir, so a binary the capsule compiles, downloads or renames inside it runs regardless of \
296 capabilities.shell.allow — the allowlist stops being an enforceable property of this capsule. \
297 No host can back a class above advisory for it. Either remove workdir_exec (the allowlist is \
298 then enforced by the kernel on the resolved path) or lower the declared containment floor to \
299 advisory";
300
301pub fn check_containment_floor(
308 declared: ContainmentClass,
309 achieved: ContainmentClass,
310 sealed_shortfall: Option<&SealedShortfall>,
311 workdir_exec: bool,
312 runtime_dir_inside: Option<&RuntimeDirPlacement>,
313) -> Result<(), RuntimeError> {
314 match containment_shortfall(
315 declared,
316 achieved,
317 sealed_shortfall,
318 workdir_exec,
319 runtime_dir_inside,
320 ) {
321 None => Ok(()),
322 Some((reason, cause)) => Err(RuntimeError::ContainmentFloorUnmet {
323 declared,
324 achieved,
325 reason,
326 cause,
327 required_by: Vec::new(),
328 }),
329 }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
339pub struct ExportsFilesReport {
340 pub root: String,
342 pub mode: ExportMode,
343 pub max_bytes: u64,
345}
346
347impl From<&FileExport> for ExportsFilesReport {
348 fn from(export: &FileExport) -> Self {
349 Self {
350 root: export.root.clone(),
351 mode: export.mode,
352 max_bytes: export.max_bytes,
353 }
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
363pub struct PeerFilesReport {
364 pub root: String,
366 pub max_ttl_secs: u64,
370 pub max_bytes: u64,
372}
373
374impl From<&PeerFilesExport> for PeerFilesReport {
375 fn from(export: &PeerFilesExport) -> Self {
376 Self {
377 root: export.root.clone(),
378 max_ttl_secs: export.effective_max_ttl_secs(),
379 max_bytes: export.max_bytes,
380 }
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
390pub struct StateStoreReport {
391 pub artifact: String,
393 pub store: String,
396 pub host_path: String,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
410#[serde(rename_all = "kebab-case")]
411pub enum PreopenSurface {
412 WholeWorkdir,
418 ScopedSubtree,
421 Nothing,
425 RecordsHooks,
429}
430
431impl PreopenSurface {
432 fn summary(self) -> &'static str {
436 match self {
437 PreopenSurface::WholeWorkdir => "the whole accessible workdir",
438 PreopenSurface::ScopedSubtree => "one subtree of the accessible workdir",
439 PreopenSurface::Nothing => "nothing preopened",
440 PreopenSurface::RecordsHooks => "the records directory's hooks/",
441 }
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
454pub struct PreopenReport {
455 pub artifact: String,
457 pub role: String,
460 pub scope: Option<String>,
463 pub surface: PreopenSurface,
466 pub runtime_dir_reach: Option<RuntimeDirReach>,
472 pub extra_roots: Vec<ExtraRootReport>,
478}
479
480#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
482pub struct ExtraRootReport {
483 pub guest_path: String,
485 pub path: String,
487 pub access: crate::host_paths::HostPathAccess,
489 pub source: crate::guest_roots::GuestRootSource,
491}
492
493impl From<&crate::guest_roots::GuestRoot> for ExtraRootReport {
494 fn from(root: &crate::guest_roots::GuestRoot) -> Self {
495 Self {
496 guest_path: root.guest_path.clone(),
497 path: root.host_path.display().to_string(),
498 access: root.access,
499 source: root.source,
500 }
501 }
502}
503
504pub fn attach_extra_roots(reports: &mut [PreopenReport], roots: &[crate::guest_roots::GuestRoot]) {
509 for report in reports {
510 report.extra_roots = if report.runtime_dir_reach.is_some() {
511 roots.iter().map(ExtraRootReport::from).collect()
512 } else {
513 Vec::new()
514 };
515 }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
521#[serde(rename_all = "kebab-case")]
522pub enum RuntimeDirReach {
523 MurmurRuntime,
525 Environment,
527 Unresolved,
529}
530
531#[must_use]
535pub fn resolve_runtime_dir_reach(
536 reach: Option<RuntimeDirReach>,
537 implementation: Option<murmur_artifact::ArtifactImplementation>,
538) -> Option<RuntimeDirReach> {
539 reach?;
540 Some(match implementation {
541 Some(murmur_artifact::ArtifactImplementation::Native) => RuntimeDirReach::Environment,
542 Some(murmur_artifact::ArtifactImplementation::Wasm) => RuntimeDirReach::MurmurRuntime,
543 None => RuntimeDirReach::Unresolved,
544 })
545}
546
547pub fn resolve_runtime_dir_reaches(
551 reports: &mut [PreopenReport],
552 implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
553) {
554 for report in reports {
555 report.runtime_dir_reach = resolve_runtime_dir_reach(
556 report.runtime_dir_reach,
557 implementation_of(&report.artifact),
558 );
559 if !matches!(
560 report.runtime_dir_reach,
561 Some(RuntimeDirReach::MurmurRuntime | RuntimeDirReach::Unresolved)
562 ) {
563 report.extra_roots.clear();
564 }
565 }
566}
567
568impl PreopenReport {
569 pub fn render(&self, runtime_dir_protected: bool) -> String {
576 let artifact = &self.artifact;
577 let role = &self.role;
578 let surface = self.surface.summary();
579 let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
580 let env = crate::runtime_dir::RUNTIME_DIR_ENV;
581 let runtime_dir = match (self.runtime_dir_reach, runtime_dir_protected) {
582 (None, _) => String::new(),
583 (Some(RuntimeDirReach::MurmurRuntime), _) => {
584 format!("; runtime directory read-only at {guest}/")
585 }
586 (Some(RuntimeDirReach::Environment), true) => {
587 format!("; runtime directory read-only at ${env}")
588 }
589 (Some(RuntimeDirReach::Environment), false) => {
590 format!("; runtime directory at ${env}, which commands can change on this host")
591 }
592 (Some(RuntimeDirReach::Unresolved), true) => {
593 format!("; runtime directory read-only at {guest}/ if WASM, at ${env} if native")
594 }
595 (Some(RuntimeDirReach::Unresolved), false) => format!(
596 "; runtime directory read-only at {guest}/ if WASM, at ${env} if native, which \
597 commands can change on this host"
598 ),
599 };
600 let extra_roots = self.render_extra_roots();
601 match &self.scope {
602 Some(scope) => format!(
603 "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
604 {scope}{runtime_dir}{extra_roots}"
605 ),
606 None => format!(
607 "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
608 declared{runtime_dir}{extra_roots}"
609 ),
610 }
611 }
612
613 fn render_extra_roots(&self) -> String {
616 if self.extra_roots.is_empty() {
617 return String::new();
618 }
619 let guest = crate::guest_roots::HOST_ROOT_GUEST_PATH;
620 let qualifier = if self.runtime_dir_reach == Some(RuntimeDirReach::Unresolved) {
621 " if WASM"
622 } else {
623 ""
624 };
625 let whole_host = self
626 .extra_roots
627 .iter()
628 .any(|root| root.source == crate::guest_roots::GuestRootSource::Unconfined);
629 if whole_host {
630 return format!("; whole host read-write under {guest}/{qualifier}");
631 }
632 let folders: Vec<String> = self
633 .extra_roots
634 .iter()
635 .map(|root| format!("{} ({})", root.path, root.access))
636 .collect();
637 format!(
638 "; host folders under {guest}/{qualifier}: {}",
639 folders.join(", ")
640 )
641 }
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
652#[serde(rename_all = "kebab-case")]
653pub enum FilesystemRestriction {
654 Advisory,
656 Enforced,
658 Absent,
660 None,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
675pub struct FilesystemBoundaryReport {
676 pub restriction: FilesystemRestriction,
678 pub not_protected: Vec<String>,
683}
684
685const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
690 "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
691 outside the work folder, so capabilities.filesystem.scope bounds what the runtime hands a \
692 subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
693 ABI installs a Landlock domain instead, where the kernel refuses the open.";
694
695const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
701 "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
702 stat, access and readlink on that path still succeed and still report the real file's \
703 metadata, because Landlock mediates the operations that touch a file and not path resolution. \
704 A probe that only asks whether a path exists therefore learns nothing about what was denied — \
705 read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
706 reaches the sealed tier composes a root the path is not in at all.";
707
708const ADVISORY_HOME_NOT_PROTECTED: &str =
713 "HOME is relocated, not protected: it points at the session's home folder, and the real home \
714 directory stays readable and writable by absolute path, because no kernel mechanism mediates \
715 the filesystem on this tier.";
716
717const PATH_REWRITING_NOT_PROTECTED: &str =
723 "Path rewriting is a convenience, not a boundary: HOME is set to the session's home folder, \
724 home/ in its records directory, on every tier, so `cat ~/.ssh/id_rsa` answers \"No such file \
725 or directory\" whether or not anything was denied — the tilde resolved into that folder and \
726 the host file was never looked for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
727 to a real file outside the workdir instead.";
728
729pub(crate) const UNCONFINED_NOT_PROTECTED: &str =
731 "Nothing is contained: this session runs unconfined, so its shell commands, plan shell steps, \
732 native tools and process harness run as the operator, with the operator's HOME, environment \
733 (credential variables included), filesystem and network, and sudo works wherever the \
734 operator's sudoers allows it. The trace and the records directory are written as always but \
735 are not protected from those commands.";
736
737pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
750 if applied == EnforcementTier::Unconfined {
754 return FilesystemBoundaryReport {
756 restriction: FilesystemRestriction::None,
757 not_protected: vec![UNCONFINED_NOT_PROTECTED.to_string()],
758 };
759 }
760 let not_protected: &[&str] = match applied {
763 EnforcementTier::KernelSealed => &[],
764 EnforcementTier::Unconfined => unreachable!("returned above"),
765 EnforcementTier::KernelFull => &[
766 ENFORCED_FILESYSTEM_NOT_PROTECTED,
767 PATH_REWRITING_NOT_PROTECTED,
768 ],
769 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => &[
770 ADVISORY_FILESYSTEM_NOT_PROTECTED,
771 PATH_REWRITING_NOT_PROTECTED,
772 ADVISORY_HOME_NOT_PROTECTED,
773 ],
774 };
775 let restriction = match applied {
776 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
777 EnforcementTier::Unconfined => unreachable!("returned above"),
778 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
779 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
780 FilesystemRestriction::Advisory
781 }
782 };
783 let not_protected = not_protected
784 .iter()
785 .map(|statement| (*statement).to_string())
786 .collect();
787
788 FilesystemBoundaryReport {
789 restriction,
790 not_protected,
791 }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
799pub struct RuntimeDirReport {
800 pub path: String,
804 pub read_only_to_commands: bool,
806 pub enforced_by: Option<&'static str>,
809 pub not_protected_because: Option<String>,
811}
812
813const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
815
816const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
818
819const LANDLOCK_TRUNCATE_ABI: u8 = 3;
821
822#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825enum ProtectedDir {
826 Runtime,
827 Records,
828}
829
830impl ProtectedDir {
831 fn noun(self) -> &'static str {
832 match self {
833 Self::Runtime => "runtime directory",
834 Self::Records => "records directory",
835 }
836 }
837
838 fn file(self) -> &'static str {
840 match self {
841 Self::Runtime => "runtime file",
842 Self::Records => "record",
843 }
844 }
845
846 fn heading(self) -> &'static str {
847 match self {
848 Self::Runtime => "Runtime directory",
849 Self::Records => "Records directory",
850 }
851 }
852}
853
854#[must_use]
862pub(crate) fn runtime_dir_report(
863 applied: EnforcementTier,
864 landlock_abi: Option<u8>,
865 placement: &RuntimeDirPlacement,
866) -> RuntimeDirReport {
867 protected_dir_report(
868 ProtectedDir::Runtime,
869 applied,
870 landlock_abi,
871 placement.report_path(),
872 placement.sessions_inside_workdir,
873 &placement.workdir,
874 )
875}
876
877#[must_use]
880pub(crate) fn records_dir_report(
881 applied: EnforcementTier,
882 landlock_abi: Option<u8>,
883 placement: &RuntimeDirPlacement,
884) -> RuntimeDirReport {
885 protected_dir_report(
886 ProtectedDir::Records,
887 applied,
888 landlock_abi,
889 placement.records_report_path(),
890 placement.records_inside_workdir,
891 &placement.workdir,
892 )
893}
894
895fn protected_dir_report(
896 dir: ProtectedDir,
897 applied: EnforcementTier,
898 landlock_abi: Option<u8>,
899 path: String,
900 inside_workdir: bool,
901 workdir: &std::path::Path,
902) -> RuntimeDirReport {
903 let noun = dir.noun();
904 let not_protected = |reason: String| RuntimeDirReport {
905 path: path.clone(),
906 read_only_to_commands: false,
907 enforced_by: None,
908 not_protected_because: Some(reason),
909 };
910 match applied {
911 EnforcementTier::Unconfined => not_protected(format!(
912 "the capsule runs unconfined, so its commands run as the operator and can change the \
913 {noun}"
914 )),
915 EnforcementTier::EnvironmentOnly => not_protected(format!(
916 "this host has no kernel containment for capsule commands — macOS and every \
917 non-Linux host run them as the operator's own processes, so a command can change \
918 the {noun}"
919 )),
920 EnforcementTier::KernelSeccompOnly => not_protected(format!(
921 "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
922 {noun}"
923 )),
924 _ if inside_workdir => not_protected(format!(
925 "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
926 it; run with a --workdir that does not contain ~/.murmur",
927 workdir.display()
928 )),
929 EnforcementTier::KernelSealed => RuntimeDirReport {
930 path,
931 read_only_to_commands: true,
932 enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
933 not_protected_because: None,
934 },
935 EnforcementTier::KernelFull => {
936 let abi = landlock_abi.unwrap_or(1);
937 if abi < LANDLOCK_TRUNCATE_ABI {
938 not_protected(format!(
939 "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
940 {} though not replace it; kernel 6.2 or later refuses it",
941 dir.file()
942 ))
943 } else {
944 RuntimeDirReport {
945 path,
946 read_only_to_commands: true,
947 enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
948 not_protected_because: None,
949 }
950 }
951 }
952 }
953}
954
955#[must_use]
958pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
959 render_protected_dir(ProtectedDir::Runtime, report)
960}
961
962#[must_use]
965pub fn render_records_dir(report: &RuntimeDirReport) -> String {
966 render_protected_dir(ProtectedDir::Records, report)
967}
968
969fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
970 let commands = match (&report.not_protected_because, report.enforced_by) {
971 (Some(reason), _) => format!("can change it — {reason}"),
972 (None, Some(mechanism)) => format!("read only ({mechanism})"),
973 (None, None) => "read only".to_string(),
974 };
975 format!(
976 "{}\n path: {}\n commands: {commands}\n",
977 dir.heading(),
978 report.path
979 )
980}
981
982#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
986pub struct TempDirReport {
987 pub path: String,
990 pub tmpdir: String,
993 pub tmp_mapped: bool,
995 pub mapped_by: Option<&'static str>,
998 pub not_mapped_because: Option<String>,
1001}
1002
1003#[must_use]
1006pub(crate) fn temp_dir_report(
1007 mapping: crate::temp_dir::TempMapping,
1008 placement: &RuntimeDirPlacement,
1009) -> TempDirReport {
1010 let path = std::path::Path::new(&placement.records_report_path())
1011 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
1012 .display()
1013 .to_string();
1014 TempDirReport {
1015 tmpdir: mapping.tmpdir_value(std::path::Path::new(&path)),
1016 path,
1017 tmp_mapped: mapping.is_mapped(),
1018 mapped_by: mapping.mapped_by(),
1019 not_mapped_because: match mapping {
1020 crate::temp_dir::TempMapping::NotMapped(reason) => Some(reason.describe().to_string()),
1021 _ => None,
1022 },
1023 }
1024}
1025
1026#[must_use]
1029pub fn render_temp_dir(report: &TempDirReport) -> String {
1030 let tmp = match (report.mapped_by, &report.not_mapped_because) {
1031 (Some(mechanism), _) => format!("mapped ({mechanism})"),
1032 (None, Some(reason)) => format!("not mapped — {reason}"),
1033 (None, None) => "not mapped".to_string(),
1034 };
1035 format!(
1036 "Temporary folder\n path: {}\n TMPDIR: {}\n /tmp: {tmp}\n",
1037 report.path, report.tmpdir
1038 )
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1047pub struct ScopeReport {
1048 pub declared_containment: ContainmentClass,
1050 pub achieved_containment: ContainmentClass,
1054 pub host_containment: ContainmentClass,
1058 pub floor_met: bool,
1060 #[serde(skip_serializing_if = "Option::is_none")]
1062 pub shortfall_reason: Option<String>,
1063 pub enforcement_tier: &'static str,
1066 pub routes: Vec<RouteReport>,
1075 pub userns_grant: Option<UsernsGrant>,
1085 pub landlock_abi: Option<u8>,
1095 pub filesystem_scope: Option<String>,
1097 pub workdir_exec: bool,
1105 pub read_only_paths: Vec<String>,
1115 pub read_only_advisory_for: Vec<String>,
1125 pub preopens: Vec<PreopenReport>,
1138 pub network_allow: Vec<String>,
1140 pub unix_sockets: bool,
1142 pub shell_allow: Vec<String>,
1144 pub spawn_allow: Vec<String>,
1146 pub spawn_workdir_snapshot: Vec<String>,
1150 pub parent_workdir: Option<crate::workdir_snapshot::ParentWorkdirReport>,
1158 pub images: Option<ImagesReport>,
1162 pub env_allow: Vec<String>,
1164 pub plan_submit: bool,
1172 pub install_skill: Vec<String>,
1180 pub install_tool: Vec<String>,
1184 pub install_from: Vec<String>,
1189 pub interpreter_runtime_grants: Vec<String>,
1193 pub derived_interpreter_runtime: Vec<String>,
1198 pub trust_store: Vec<String>,
1204 pub host_paths: Vec<crate::host_paths::HostPathReport>,
1209 pub exports_files: Option<ExportsFilesReport>,
1219 pub peer_files: Option<PeerFilesReport>,
1227 pub peer_tasks: bool,
1233 pub peer_fetch_allow: Vec<String>,
1240 pub state_stores: Vec<StateStoreReport>,
1250 pub configured_artifacts: Vec<String>,
1264 pub staged_runtime_grants: Vec<String>,
1273 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1288 pub io_max: IoMaxReport,
1304 pub cgroup_scope: CgroupScopeReport,
1313 pub filesystem_boundary: FilesystemBoundaryReport,
1325 pub runtime_dir: RuntimeDirReport,
1330 pub records_dir: RuntimeDirReport,
1334 pub temp_dir: TempDirReport,
1338 pub unconfined: Option<UnconfinedReport>,
1341}
1342
1343#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1345pub struct UnconfinedReport {
1346 pub user: Option<String>,
1348 pub uid: u32,
1350 pub home: Option<String>,
1353 pub applies_to: Vec<&'static str>,
1355 pub resource_limits: Vec<&'static str>,
1358}
1359
1360pub const UNCONFINED_APPLIES_TO: &[&str] =
1363 &["shell", "plan-shell-step", "native-tool", "process-harness"];
1364
1365impl UnconfinedReport {
1366 pub(crate) fn for_this_process(declared: &murmur_artifact::ResourceCapabilities) -> Self {
1369 let uid = current_uid();
1370 Self {
1371 user: account_name(uid),
1372 uid,
1373 home: std::env::var_os("HOME").map(|home| home.to_string_lossy().into_owned()),
1374 applies_to: UNCONFINED_APPLIES_TO.to_vec(),
1375 resource_limits: crate::resources::declared_resource_fields(declared),
1376 }
1377 }
1378
1379 #[must_use]
1381 pub fn render(&self) -> String {
1382 let applies_to = self
1383 .applies_to
1384 .iter()
1385 .map(|route| match *route {
1386 "shell" => "shell commands",
1387 "plan-shell-step" => "plan shell steps",
1388 "native-tool" => "native tools",
1389 "process-harness" => "process harness",
1390 other => other,
1391 })
1392 .collect::<Vec<_>>()
1393 .join(", ");
1394 let limits = if self.resource_limits.is_empty() {
1395 "none declared".to_string()
1396 } else {
1397 self.resource_limits.join(", ")
1398 };
1399 format!(
1400 "Unconfined\n user: {}\n uid: {}\n HOME: {}\n applies to: \
1401 {applies_to}\n resource limits: {limits}\n",
1402 self.user.as_deref().unwrap_or("<unknown>"),
1403 self.uid,
1404 self.home.as_deref().unwrap_or("<unset>"),
1405 )
1406 }
1407}
1408
1409pub(crate) fn unconfined_warning(report: &UnconfinedReport) -> String {
1411 let user = match &report.user {
1412 Some(user) => format!("{user} (uid {})", report.uid),
1413 None => format!("uid {}", report.uid),
1414 };
1415 let home = report.home.as_deref().unwrap_or("<unset>");
1416 format!(
1417 "this capsule runs unconfined: its shell commands, plan shell steps, native tools and \
1418 process harness run as {user} with HOME {home}, the whole environment mur run was \
1419 started with (credential variables included), the host filesystem and network as that \
1420 account has them, and no no_new_privs, so sudo works wherever that account's sudoers \
1421 allows it"
1422 )
1423}
1424
1425pub(crate) fn warn_unconfined_launch(
1428 records_root: &crate::workdir_fs::WorkdirRoot,
1429 report: &UnconfinedReport,
1430) {
1431 let code = murmur_artifact::security_warnings::W_SEC_036;
1432 let message = unconfined_warning(report);
1433 let link = murmur_artifact::security_warning_link(code);
1434 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
1435 crate::agent::append_bootstrap_log(
1436 records_root,
1437 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
1438 );
1439}
1440
1441#[cfg(unix)]
1443pub(crate) fn current_uid() -> u32 {
1444 rustix::process::getuid().as_raw()
1445}
1446
1447#[cfg(not(unix))]
1448pub(crate) fn current_uid() -> u32 {
1449 0
1450}
1451
1452#[cfg(unix)]
1454#[allow(unsafe_code)]
1455pub(crate) fn account_name(uid: u32) -> Option<String> {
1456 let mut buffer = vec![0 as libc::c_char; 16 * 1024];
1457 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
1460 let mut result: *mut libc::passwd = std::ptr::null_mut();
1461 let rc = unsafe {
1464 libc::getpwuid_r(
1465 uid,
1466 &mut entry,
1467 buffer.as_mut_ptr(),
1468 buffer.len(),
1469 &mut result,
1470 )
1471 };
1472 if rc != 0 || result.is_null() || entry.pw_name.is_null() {
1473 return None;
1474 }
1475 let name = unsafe { std::ffi::CStr::from_ptr(entry.pw_name) };
1478 Some(name.to_string_lossy().into_owned())
1479}
1480
1481#[cfg(not(unix))]
1482pub(crate) fn account_name(_uid: u32) -> Option<String> {
1483 None
1484}
1485
1486impl ScopeReport {
1487 pub fn render(&self) -> String {
1489 let mut out = String::new();
1490 out.push_str("Containment\n");
1491 out.push_str(&format!(" declared: {}\n", self.declared_containment));
1492 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
1493 out.push_str(&format!(" host can back: {}\n", self.host_containment));
1494 out.push_str(&format!(
1495 " floor met: {}\n",
1496 if self.floor_met { "yes" } else { "no" }
1497 ));
1498 if let Some(reason) = &self.shortfall_reason {
1499 out.push_str(&format!(" reason: {reason}\n"));
1500 }
1501 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
1502 out.push_str(&format!(
1503 " userns grant: {}\n",
1504 match self.userns_grant {
1505 Some(grant) => grant.wire_name(),
1506 None => "n/a",
1507 }
1508 ));
1509 out.push_str(&format!(
1510 " landlock ABI: {}\n",
1511 match self.landlock_abi {
1512 Some(abi) => abi.to_string(),
1513 None => "n/a".to_string(),
1514 }
1515 ));
1516 out.push_str(&format!(
1517 " {}\n",
1518 crate::cgroup::render_cgroup_scope(&self.cgroup_scope)
1519 ));
1520
1521 out.push('\n');
1522 out.push_str(&render_routes(&self.routes));
1523
1524 if let Some(unconfined) = &self.unconfined {
1525 out.push('\n');
1526 out.push_str(&unconfined.render());
1527 }
1528
1529 out.push('\n');
1530 out.push_str(&render_runtime_dir(&self.runtime_dir));
1531 out.push('\n');
1532 out.push_str(&render_records_dir(&self.records_dir));
1533 out.push('\n');
1534 out.push_str(&render_temp_dir(&self.temp_dir));
1535
1536 out.push_str(&render_not_protected(&self.filesystem_boundary));
1537
1538 out.push_str("\nEffective grants\n");
1539 out.push_str(&format!(
1540 " filesystem scope: {}\n",
1541 self.filesystem_scope.as_deref().unwrap_or("<none>")
1542 ));
1543 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
1544 out.push_str(&render_read_only(
1545 &self.read_only_paths,
1546 &self.read_only_advisory_for,
1547 ));
1548 push_list(
1549 &mut out,
1550 "preopens",
1551 &self
1552 .preopens
1553 .iter()
1554 .map(|preopen| preopen.render(self.runtime_dir.read_only_to_commands))
1555 .collect::<Vec<_>>(),
1556 );
1557 push_list(&mut out, "network allow", &self.network_allow);
1558 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
1559 push_list(&mut out, "shell allow", &self.shell_allow);
1560 push_list(&mut out, "spawn allow", &self.spawn_allow);
1561 push_list(
1562 &mut out,
1563 "spawn workdir snapshot",
1564 &self.spawn_workdir_snapshot,
1565 );
1566 push_list(&mut out, "env allow", &self.env_allow);
1567 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
1568 out.push_str(&render_install(
1569 &self.install_skill,
1570 &self.install_tool,
1571 &self.install_from,
1572 ));
1573 push_list(
1574 &mut out,
1575 "interpreter runtime",
1576 &self.interpreter_runtime_grants,
1577 );
1578 push_list(
1579 &mut out,
1580 "derived interpreter runtime",
1581 &self.derived_interpreter_runtime,
1582 );
1583 push_list(&mut out, "trust store", &self.trust_store);
1584 push_list(
1585 &mut out,
1586 "host paths",
1587 &self
1588 .host_paths
1589 .iter()
1590 .map(crate::host_paths::HostPathReport::render)
1591 .collect::<Vec<_>>(),
1592 );
1593 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1594 push_list(
1595 &mut out,
1596 "state stores",
1597 &self
1598 .state_stores
1599 .iter()
1600 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1601 .collect::<Vec<_>>(),
1602 );
1603 push_list(&mut out, "artifact config", &self.configured_artifacts);
1604
1605 if let Some(parent) = &self.parent_workdir {
1606 out.push_str(&render_parent_workdir(parent));
1607 }
1608
1609 if let Some(images) = &self.images {
1610 out.push_str(&render_images(images));
1611 }
1612
1613 out.push_str("\nResource plane\n");
1614 match &self.exports_files {
1615 None => out.push_str(" exports.files: <none>\n"),
1616 Some(export) => {
1617 out.push_str(&format!(" exports.files root: {}\n", export.root));
1618 out.push_str(&format!(" mode: {}\n", export.mode));
1619 out.push_str(&format!(
1620 " max bytes: {} (per file)\n",
1621 export.max_bytes
1622 ));
1623 }
1624 }
1625
1626 out.push_str(&format!(
1627 " io.max: {} ({} bytes/s declared)\n",
1628 match self.io_max.status {
1629 IoMaxStatus::Enforced => "enforced",
1630 IoMaxStatus::Unavailable => "unavailable",
1631 IoMaxStatus::NotRequired => "not-required",
1632 IoMaxStatus::NotProbed => "not-probed",
1633 },
1634 self.io_max.declared_bytes_per_sec
1635 ));
1636 if let Some(reason) = &self.io_max.reason {
1637 out.push_str(&format!(" {reason}\n"));
1638 }
1639
1640 out.push_str("\nPeer handoff\n");
1641 match &self.peer_files {
1642 None => out.push_str(" exports.peer_files: <none>\n"),
1643 Some(peer) => {
1644 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
1645 out.push_str(&format!(
1646 " max handle ttl: {}s\n",
1647 peer.max_ttl_secs
1648 ));
1649 out.push_str(&format!(
1650 " max bytes: {} (per file)\n",
1651 peer.max_bytes
1652 ));
1653 }
1654 }
1655 out.push_str(if self.peer_tasks {
1656 " exports.peer_tasks: accept\n"
1657 } else {
1658 " exports.peer_tasks: refuse\n"
1659 });
1660 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1661
1662 out.push_str("\nRuntime writes\n");
1663 out.push_str(&crate::workdir_writes::render_runtime_writes(
1664 &self.runtime_writes,
1665 ));
1666
1667 if !self.floor_met {
1668 out.push_str(
1669 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1670 launch here.\n",
1671 );
1672 } else if self.cgroup_scope.status == CgroupScopeStatus::Unavailable {
1673 out.push_str(
1674 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1675 launch here with E-RUN-012: this capsule can run a native subprocess and no \
1676 cgroup scope can be created to bound it (see `cgroup scope:` above).\n",
1677 );
1678 }
1679 out
1680 }
1681}
1682
1683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1686pub struct ImagesReport {
1687 pub keep: u32,
1689 pub max_bytes: u64,
1691 pub max_per_turn: u32,
1694 pub keep_applies: bool,
1697}
1698
1699impl ScopeReport {
1700 #[must_use]
1703 pub fn with_images(
1704 mut self,
1705 images: murmur_artifact::ContextImagesConfig,
1706 transport: Option<&str>,
1707 ) -> Self {
1708 self.images = Some(ImagesReport {
1709 keep: images.keep,
1710 max_bytes: images.max_bytes,
1711 max_per_turn: images.max_per_turn,
1712 keep_applies: transport != Some("process"),
1713 });
1714 self
1715 }
1716
1717 #[must_use]
1724 pub fn with_routes(
1725 mut self,
1726 implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
1727 transport: Option<&str>,
1728 ) -> Self {
1729 let inputs = RouteInputs::new(
1730 &self.shell_allow,
1731 self.plan_submit,
1732 &self.install_tool,
1733 &self.preopens,
1734 implementation_of,
1735 transport,
1736 );
1737 self.routes = route_table(&inputs, self.enforcement_tier);
1738 self
1739 }
1740
1741 #[must_use]
1743 pub fn with_cgroup_scope(mut self, cgroup_scope: CgroupScopeReport) -> Self {
1744 self.cgroup_scope = cgroup_scope;
1745 self
1746 }
1747
1748 #[must_use]
1752 pub fn with_parent_workdir(
1753 mut self,
1754 snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
1755 ) -> Self {
1756 let restriction = self.filesystem_boundary.restriction;
1757 self.parent_workdir =
1758 snapshot.map(|snapshot| crate::workdir_snapshot::ParentWorkdirReport {
1759 snapshot,
1760 restriction,
1761 });
1762 self
1763 }
1764}
1765
1766#[must_use]
1768pub fn render_images(images: &ImagesReport) -> String {
1769 let mut out = String::from("\nImages\n");
1770 out.push_str(&format!(" max_bytes: {}\n", images.max_bytes));
1771 if images.keep_applies {
1772 out.push_str(&format!(" keep: {}\n", images.keep));
1773 out.push_str(&format!(" max_per_turn: {}\n", images.max_per_turn));
1774 } else {
1775 out.push_str(&format!(
1776 " keep: {} (does nothing here: the harness holds the conversation)\n",
1777 images.keep
1778 ));
1779 out.push_str(&format!(
1780 " max_per_turn: {} (counts the images of one tool call here)\n",
1781 images.max_per_turn
1782 ));
1783 }
1784 out
1785}
1786
1787#[must_use]
1790pub fn render_parent_workdir(parent: &crate::workdir_snapshot::ParentWorkdirReport) -> String {
1791 let snapshot = &parent.snapshot;
1792 let mut out = String::from("\nParent work folder\n");
1793 out.push_str(&format!(" source: {}\n", snapshot.source));
1794 out.push_str(&format!(
1795 " copied: {} files, {} directories, {} symlinks ({} re-pointed), {} bytes\n",
1796 snapshot.files,
1797 snapshot.dirs,
1798 snapshot.symlinks,
1799 snapshot.repointed_symlinks,
1800 snapshot.bytes
1801 ));
1802 if snapshot.skipped_count == 0 {
1803 out.push_str(" skipped: <none>\n");
1804 } else {
1805 out.push_str(&format!(" skipped: {}\n", snapshot.skipped_count));
1806 for entry in &snapshot.skipped {
1807 let reason = serde_json::to_value(entry.reason)
1808 .ok()
1809 .and_then(|value| value.as_str().map(str::to_string))
1810 .unwrap_or_default();
1811 out.push_str(&format!(" - {} ({reason})\n", entry.path));
1812 }
1813 }
1814 out.push_str(&format!(
1815 " restriction: {}\n",
1816 parent.restriction_sentence()
1817 ));
1818 out
1819}
1820
1821#[must_use]
1829pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
1830 if read_only_paths.is_empty() {
1831 return Vec::new();
1832 }
1833 shell_allow
1834 .iter()
1835 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
1836 .cloned()
1837 .collect()
1838}
1839
1840#[must_use]
1848pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
1849 let mut out = String::new();
1850 push_list(&mut out, "read_only", read_only_paths);
1851 if read_only_paths.is_empty() {
1852 return out;
1853 }
1854 if advisory_for.is_empty() {
1855 out.push_str(
1856 " read_only enforcement: enforced for every tool call and every shell command the \
1857 dispatch check can read\n",
1858 );
1859 } else {
1860 out.push_str(&format!(
1861 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
1862 something the dispatch check can read; enforced for every tool call and for every \
1863 shell command it can read\n",
1864 advisory_for.join(", ")
1865 ));
1866 }
1867 out
1868}
1869
1870#[must_use]
1878pub fn render_install(
1879 install_skill: &[String],
1880 install_tool: &[String],
1881 install_from: &[String],
1882) -> String {
1883 let mut out = String::new();
1884 push_list(&mut out, "install skill", install_skill);
1885 push_list(&mut out, "install tool", install_tool);
1886 if !install_from.is_empty() {
1887 out.push_str(&format!(" install from: {}\n", install_from.join(", ")));
1888 }
1889 out
1890}
1891
1892#[must_use]
1894pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
1895 if !crate::install_grant::is_granted(policy) {
1896 return Vec::new();
1897 }
1898 let mut places = vec!["stores".to_string()];
1899 if policy.install_from_sources {
1900 places.push("sources".to_string());
1901 }
1902 places
1903}
1904
1905fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
1913 if boundary.not_protected.is_empty() {
1914 return String::new();
1915 }
1916 let mut out = String::from("\nNot protected here\n");
1917 for statement in &boundary.not_protected {
1918 out.push_str(&format!(" - {statement}\n"));
1919 }
1920 out
1921}
1922
1923fn push_list(out: &mut String, label: &str, values: &[String]) {
1924 if values.is_empty() {
1925 out.push_str(&format!(" {label}: <none>\n"));
1926 return;
1927 }
1928 out.push_str(&format!(" {label}:\n"));
1929 for value in values {
1930 out.push_str(&format!(" - {value}\n"));
1931 }
1932}
1933
1934#[allow(clippy::too_many_arguments)]
1950pub fn explain_scope(
1951 policy: &CapabilityPolicy,
1952 declared: ContainmentClass,
1953 exports: Option<&Exports>,
1954 state_stores: Vec<StateStoreReport>,
1955 configured_artifacts: Vec<String>,
1956 preopens: Vec<PreopenReport>,
1957 io_max: IoMaxReport,
1958 workdir: &std::path::Path,
1959) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
1960 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1961 let probe = HostProbe::probe();
1962 let decision = ContainmentDecision::decide(
1963 probe.tier(),
1964 declared,
1965 policy.workdir_exec_allowed,
1966 placement.inside_workdir(),
1967 );
1968 let temp_mapping = crate::temp_dir::temp_mapping(
1969 decision.applied_tier(),
1970 probe.mount_namespace_usable(),
1971 &placement.workdir,
1972 );
1973 Ok(scope_report_for_tier(
1974 policy,
1975 &decision,
1976 probe.sealed_shortfall().as_ref(),
1977 probe.userns_grant(),
1978 probe.landlock_kernel_abi(),
1979 exports,
1980 state_stores,
1981 configured_artifacts,
1982 preopens,
1983 &placement,
1984 temp_mapping,
1985 io_max,
1986 ))
1987}
1988
1989pub fn doctor_runtime_dir(
1997 probe: &HostProbe,
1998 declared: ContainmentClass,
1999 workdir: &std::path::Path,
2000) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
2001 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
2002 let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
2004 let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
2005 let temp_mapping =
2006 crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
2007 Ok(DoctorDirReports {
2008 runtime_dir: runtime_dir_report(applied, abi, &placement),
2009 records_dir: records_dir_report(applied, abi, &placement),
2010 temp_dir: temp_dir_report(temp_mapping, &placement),
2011 placement,
2012 })
2013}
2014
2015#[derive(Debug, Clone, PartialEq, Eq)]
2017pub struct DoctorDirReports {
2018 pub runtime_dir: RuntimeDirReport,
2019 pub records_dir: RuntimeDirReport,
2020 pub temp_dir: TempDirReport,
2021 pub placement: RuntimeDirPlacement,
2022}
2023
2024#[allow(clippy::too_many_arguments)]
2031pub(crate) fn scope_report_for_tier(
2032 policy: &CapabilityPolicy,
2033 decision: &ContainmentDecision,
2034 sealed_shortfall: Option<&SealedShortfall>,
2035 userns_grant: Option<UsernsGrant>,
2036 landlock_kernel_abi: Option<u8>,
2037 exports: Option<&Exports>,
2038 state_stores: Vec<StateStoreReport>,
2039 configured_artifacts: Vec<String>,
2040 preopens: Vec<PreopenReport>,
2041 runtime_dir_placement: &RuntimeDirPlacement,
2042 temp_mapping: crate::temp_dir::TempMapping,
2043 io_max: IoMaxReport,
2044) -> ScopeReport {
2045 debug_assert_eq!(
2046 *decision,
2047 ContainmentDecision::decide(
2048 decision.host_tier,
2049 decision.declared,
2050 policy.workdir_exec_allowed,
2051 runtime_dir_placement.inside_workdir(),
2052 ),
2053 "the decision must be made from the policy and placement this report states"
2054 );
2055 let declared = decision.declared();
2056 let achieved = decision.achieved();
2057 let applied = decision.applied_tier();
2061 let shortfall_reason = containment_shortfall_reason(
2062 declared,
2063 achieved,
2064 sealed_shortfall,
2065 policy.workdir_exec_allowed,
2066 runtime_dir_placement.inside(),
2067 );
2068 let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
2069 let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
2070 let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
2071 let mut filesystem_boundary = filesystem_boundary(applied);
2072 let enforcement_tier = decision.enforcement_tier();
2073 let routes = route_table(
2076 &RouteInputs::new(
2077 &policy.shell_allow,
2078 policy.plan_submit,
2079 &policy.install_tool,
2080 &preopens,
2081 |_| None,
2082 None,
2083 ),
2084 enforcement_tier,
2085 );
2086 if applied != EnforcementTier::Unconfined {
2088 for report in [&runtime_dir, &records_dir] {
2089 if let Some(reason) = &report.not_protected_because {
2090 filesystem_boundary.not_protected.push(reason.clone());
2091 }
2092 }
2093 }
2094 let unconfined = (applied == EnforcementTier::Unconfined)
2095 .then(|| UnconfinedReport::for_this_process(&policy.declared_resources));
2096
2097 ScopeReport {
2098 declared_containment: declared,
2099 achieved_containment: achieved,
2100 host_containment: decision.host_containment(),
2101 floor_met: shortfall_reason.is_none(),
2102 shortfall_reason,
2103 enforcement_tier,
2104 routes,
2105 userns_grant,
2106 landlock_abi,
2107 exports_files: exports
2110 .and_then(|exports| exports.files.as_ref())
2111 .map(ExportsFilesReport::from),
2112 peer_files: exports
2113 .and_then(|exports| exports.peer_files.as_ref())
2114 .map(PeerFilesReport::from),
2115 peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
2116 peer_fetch_allow: policy.peer_fetch_allow.clone(),
2117 state_stores,
2121 configured_artifacts,
2125 filesystem_scope: policy.filesystem_scope.clone(),
2126 workdir_exec: policy.workdir_exec_allowed,
2127 read_only_paths: policy.read_only_paths.clone(),
2128 read_only_advisory_for: read_only_advisory_for(
2129 &policy.read_only_paths,
2130 &policy.shell_allow,
2131 ),
2132 preopens,
2136 network_allow: policy.network_allow.clone(),
2137 unix_sockets: policy.unix_sockets_allowed,
2138 shell_allow: policy.shell_allow.clone(),
2139 spawn_allow: policy.spawn_allow.clone(),
2140 spawn_workdir_snapshot: policy.spawn_workdir_snapshot.clone(),
2141 parent_workdir: None,
2144 images: None,
2146 env_allow: policy.env_allow.clone(),
2147 plan_submit: policy.plan_submit,
2148 install_skill: policy.install_skill.clone(),
2149 install_tool: policy.install_tool.clone(),
2150 install_from: install_from_places(policy),
2151 interpreter_runtime_grants: policy
2152 .shell_interpreter_runtime
2153 .iter()
2154 .flat_map(|grant| {
2155 grant.dirs.iter().map(move |dir| {
2156 let listable = if dir.list_dir { " (list_dir)" } else { "" };
2157 format!("{}: {}{}", grant.binary, dir.path, listable)
2158 })
2159 })
2160 .collect(),
2161 derived_interpreter_runtime: policy
2162 .shell_derived_interpreter_runtime
2163 .iter()
2164 .flat_map(|runtime| {
2165 let binary = runtime.binary.display();
2166 runtime
2167 .entries()
2168 .map(|(path, is_dir)| {
2169 let listable = if is_dir { " (list_dir)" } else { "" };
2170 format!("{binary}: {}{listable}", path.display())
2171 })
2172 .chain(
2173 runtime
2174 .libraries
2175 .iter()
2176 .map(|library| format!("{binary}: {}", library.display())),
2177 )
2178 .collect::<Vec<_>>()
2179 })
2180 .collect(),
2181 trust_store: policy
2182 .shell_trust_store
2183 .iter()
2184 .map(|entry| {
2185 let listable = if entry.directory { " (list_dir)" } else { "" };
2186 format!("{}{listable}", entry.path.display())
2187 })
2188 .collect(),
2189 host_paths: crate::host_paths::host_path_reports(
2192 &policy.host_paths,
2193 crate::host_paths::HostPathMechanism::for_enforcement_tier(decision.enforcement_tier()),
2194 ),
2195 staged_runtime_grants: policy
2196 .shell_staged_runtime
2197 .iter()
2198 .map(|grant| {
2199 format!(
2200 "{}: {} (pin: {})",
2201 grant.binary, grant.source_path, grant.pin
2202 )
2203 })
2204 .collect(),
2205 runtime_writes: crate::workdir_writes::runtime_writes(),
2209 io_max,
2213 cgroup_scope: CgroupScopeReport::default(),
2216 filesystem_boundary,
2220 runtime_dir,
2221 records_dir,
2222 temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
2223 unconfined,
2224 }
2225}
2226
2227#[cfg(test)]
2230#[allow(clippy::too_many_arguments)]
2231pub(crate) fn report_for(
2232 policy: &CapabilityPolicy,
2233 declared: ContainmentClass,
2234 tier: EnforcementTier,
2235 sealed_shortfall: Option<&SealedShortfall>,
2236 userns_grant: Option<UsernsGrant>,
2237 landlock_kernel_abi: Option<u8>,
2238 exports: Option<&Exports>,
2239 state_stores: Vec<StateStoreReport>,
2240 configured_artifacts: Vec<String>,
2241 preopens: Vec<PreopenReport>,
2242 runtime_dir_placement: &RuntimeDirPlacement,
2243 temp_mapping: crate::temp_dir::TempMapping,
2244 io_max: IoMaxReport,
2245) -> ScopeReport {
2246 let decision = ContainmentDecision::decide(
2247 tier,
2248 declared,
2249 policy.workdir_exec_allowed,
2250 runtime_dir_placement.inside_workdir(),
2251 );
2252 scope_report_for_tier(
2253 policy,
2254 &decision,
2255 sealed_shortfall,
2256 userns_grant,
2257 landlock_kernel_abi,
2258 exports,
2259 state_stores,
2260 configured_artifacts,
2261 preopens,
2262 runtime_dir_placement,
2263 temp_mapping,
2264 io_max,
2265 )
2266}
2267
2268pub const HOST_PROCESS_MECHANISM: &str = "host-process";
2271
2272const UNCONFINED_ROUTE_NOTE: &str = "Runs as the operator, with their HOME, whole environment, \
2274 filesystem, network and sudo; nothing holds it.";
2275
2276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2278#[serde(rename_all = "kebab-case")]
2279pub enum Route {
2280 Shell,
2282 PlanShellSteps,
2284 NativeTools,
2286 WasmTools,
2288 Drivers,
2290 Hooks,
2292 ProcessHarness,
2294 HarnessTools,
2296}
2297
2298impl Route {
2299 pub const ALL: [Self; 8] = [
2301 Self::Shell,
2302 Self::PlanShellSteps,
2303 Self::NativeTools,
2304 Self::WasmTools,
2305 Self::Drivers,
2306 Self::Hooks,
2307 Self::ProcessHarness,
2308 Self::HarnessTools,
2309 ];
2310
2311 #[must_use]
2313 pub fn label(self) -> &'static str {
2314 match self {
2315 Self::Shell => "shell commands",
2316 Self::PlanShellSteps => "plan shell steps",
2317 Self::NativeTools => "native tools",
2318 Self::WasmTools => "WASM tools",
2319 Self::Drivers => "drivers",
2320 Self::Hooks => "hooks",
2321 Self::ProcessHarness => "the process harness",
2322 Self::HarnessTools => "harness tools",
2323 }
2324 }
2325
2326 #[must_use]
2331 pub fn under_class(self) -> bool {
2332 match self {
2333 Self::Shell | Self::PlanShellSteps | Self::NativeTools => true,
2334 Self::WasmTools
2335 | Self::Drivers
2336 | Self::Hooks
2337 | Self::ProcessHarness
2338 | Self::HarnessTools => false,
2339 }
2340 }
2341
2342 #[must_use]
2345 pub fn runs_unconfined(self) -> bool {
2346 match self {
2347 Self::Shell | Self::PlanShellSteps | Self::NativeTools | Self::ProcessHarness => true,
2348 Self::WasmTools | Self::Drivers | Self::Hooks | Self::HarnessTools => false,
2349 }
2350 }
2351
2352 #[must_use]
2355 pub fn note(self) -> Option<&'static str> {
2356 match self {
2357 Self::Shell | Self::PlanShellSteps | Self::NativeTools => None,
2358 Self::WasmTools => Some(
2359 "Runs inside the mur process, held by its WASI preopens and the host-side network \
2360 allowlist only.",
2361 ),
2362 Self::Drivers => Some(
2363 "Runs inside the mur process, held by its WASI preopens and the host-side network \
2364 allowlist only.",
2365 ),
2366 Self::Hooks => Some(
2367 "Runs inside the mur process, held only by the preopens and network grants its \
2368 own capabilities block declares.",
2369 ),
2370 Self::ProcessHarness => Some(
2371 "A child of mur with a cleared environment and nothing else: no Landlock, \
2372 seccomp, network namespace or rlimits, in a cgroup with no limits.",
2373 ),
2374 Self::HarnessTools => Some(
2375 "Run by the harness with the user's authority, kept off only by the arguments \
2376 the process driver passes.",
2377 ),
2378 }
2379 }
2380}
2381
2382#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2384pub struct RouteReport {
2385 pub route: Route,
2387 pub present: bool,
2389 pub under_class: bool,
2391 pub mechanism: &'static str,
2395 pub note: Option<&'static str>,
2399 pub artifacts: Vec<String>,
2402}
2403
2404#[derive(Debug, Clone, Default, PartialEq, Eq)]
2406pub struct RouteInputs {
2407 pub shell_allow: bool,
2409 pub plan_submit: bool,
2411 pub install_tool: bool,
2413 pub tools: Vec<(String, Option<murmur_artifact::ArtifactImplementation>)>,
2415 pub drivers: Vec<String>,
2417 pub hooks: Vec<String>,
2419 pub process_transport: bool,
2421}
2422
2423impl RouteInputs {
2424 #[must_use]
2428 pub fn new(
2429 shell_allow: &[String],
2430 plan_submit: bool,
2431 install_tool: &[String],
2432 preopens: &[PreopenReport],
2433 implementation_of: impl Fn(&str) -> Option<murmur_artifact::ArtifactImplementation>,
2434 transport: Option<&str>,
2435 ) -> Self {
2436 let named = |role: murmur_artifact::ArtifactRuntime| {
2437 preopens
2438 .iter()
2439 .filter(|preopen| preopen.role == role.as_str())
2440 .map(|preopen| preopen.artifact.clone())
2441 .collect::<Vec<_>>()
2442 };
2443 Self {
2444 shell_allow: !shell_allow.is_empty(),
2445 plan_submit,
2446 install_tool: !install_tool.is_empty(),
2447 tools: named(murmur_artifact::ArtifactRuntime::Tool)
2448 .into_iter()
2449 .map(|name| {
2450 let implementation = implementation_of(&name);
2451 (name, implementation)
2452 })
2453 .collect(),
2454 drivers: named(murmur_artifact::ArtifactRuntime::Driver),
2455 hooks: named(murmur_artifact::ArtifactRuntime::Hook),
2456 process_transport: transport == Some("process"),
2457 }
2458 }
2459}
2460
2461#[must_use]
2468pub fn route_table(inputs: &RouteInputs, enforcement_tier: &'static str) -> Vec<RouteReport> {
2469 use murmur_artifact::ArtifactImplementation;
2470 let unconfined = enforcement_tier == enforcement_tier_name(EnforcementTier::Unconfined);
2471 let tools_of = |native: bool| -> Vec<String> {
2472 inputs
2473 .tools
2474 .iter()
2475 .filter(|(_, implementation)| {
2476 (*implementation == Some(ArtifactImplementation::Native)) == native
2477 })
2478 .map(|(name, _)| name.clone())
2479 .collect()
2480 };
2481 Route::ALL
2482 .into_iter()
2483 .map(|route| {
2484 let (present, artifacts) = match route {
2485 Route::Shell => (inputs.shell_allow, Vec::new()),
2486 Route::PlanShellSteps => (inputs.plan_submit && inputs.shell_allow, Vec::new()),
2488 Route::NativeTools => {
2489 let native = tools_of(true);
2490 (!native.is_empty() || inputs.install_tool, native)
2491 }
2492 Route::WasmTools => {
2495 let wasm = tools_of(false);
2496 (!wasm.is_empty() || inputs.install_tool, wasm)
2497 }
2498 Route::Drivers => (!inputs.drivers.is_empty(), inputs.drivers.clone()),
2499 Route::Hooks => (!inputs.hooks.is_empty(), inputs.hooks.clone()),
2500 Route::ProcessHarness | Route::HarnessTools => {
2501 (inputs.process_transport, Vec::new())
2502 }
2503 };
2504 if unconfined && route.runs_unconfined() {
2505 return RouteReport {
2506 route,
2507 present,
2508 under_class: true,
2509 mechanism: enforcement_tier,
2510 note: Some(UNCONFINED_ROUTE_NOTE),
2511 artifacts,
2512 };
2513 }
2514 let under_class = route.under_class();
2515 RouteReport {
2516 route,
2517 present,
2518 under_class,
2519 mechanism: if under_class {
2520 enforcement_tier
2521 } else {
2522 HOST_PROCESS_MECHANISM
2523 },
2524 note: route.note(),
2525 artifacts,
2526 }
2527 })
2528 .collect()
2529}
2530
2531#[must_use]
2535pub fn render_routes(routes: &[RouteReport]) -> String {
2536 let mut out = String::from("Routes\n");
2537 for row in routes {
2538 let label = match row.route {
2539 Route::ProcessHarness => "process harness",
2540 route => route.label(),
2541 };
2542 let presence = if row.present { "present" } else { "absent" };
2543 let mechanism = if row.under_class {
2544 row.mechanism
2545 } else {
2546 "host process, not confined by the class"
2547 };
2548 let artifacts = if row.artifacts.is_empty() {
2549 String::new()
2550 } else {
2551 format!(" ({})", row.artifacts.join(", "))
2552 };
2553 out.push_str(&format!(
2554 " {label:<17} {presence:<8} {mechanism}{artifacts}\n"
2555 ));
2556 if let (true, Some(note)) = (row.present, row.note) {
2557 out.push_str(&format!(" {note}\n"));
2558 }
2559 }
2560 out
2561}
2562
2563#[must_use]
2568pub fn uncovered_route_warning(
2569 declared: ContainmentClass,
2570 routes: &[RouteReport],
2571) -> Option<String> {
2572 if declared < ContainmentClass::Scoped {
2573 return None;
2574 }
2575 let uncovered: Vec<String> = routes
2576 .iter()
2577 .filter(|row| row.present && !row.under_class)
2578 .map(|row| {
2579 if row.artifacts.is_empty() {
2580 row.route.label().to_string()
2581 } else {
2582 format!("{} ({})", row.route.label(), row.artifacts.join(", "))
2583 }
2584 })
2585 .collect();
2586 let named = match uncovered.as_slice() {
2587 [] => return None,
2588 [only] => only.clone(),
2589 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2590 };
2591 Some(format!(
2592 "the declared {declared} floor does not confine every route this capsule runs code on: \
2593 {named} run outside the class, with none of its Landlock, seccomp or network namespace \
2594 around them (`mur run --explain-scope` lists what holds each under Routes). The class \
2595 confines shell commands, plan shell steps and native tools; nothing is refused"
2596 ))
2597}
2598
2599pub fn warn_on_uncovered_routes(
2603 declared: ContainmentClass,
2604 routes: &[RouteReport],
2605 bootstrap_log: Option<&crate::workdir_fs::WorkdirRoot>,
2606) {
2607 let Some(message) = uncovered_route_warning(declared, routes) else {
2608 return;
2609 };
2610 let code = murmur_artifact::W_SEC_034;
2611 let link = murmur_artifact::security_warning_link(code);
2612 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2613 if let Some(records) = bootstrap_log {
2614 crate::agent::append_bootstrap_log(
2615 records,
2616 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2617 );
2618 }
2619}
2620
2621pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
2626 ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
2628}
2629
2630fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
2633 match tier {
2634 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
2635 EnforcementTier::KernelFull => "landlock+seccomp",
2636 EnforcementTier::KernelSeccompOnly => "seccomp-only",
2637 EnforcementTier::EnvironmentOnly => "none",
2638 EnforcementTier::Unconfined => "unconfined",
2639 }
2640}
2641
2642#[cfg(test)]
2643#[allow(clippy::print_stdout, clippy::print_stderr)]
2644mod tests {
2645 use super::*;
2646 use crate::sealed::SealedBlocker;
2647
2648 fn blocked(blocker: SealedBlocker) -> SealedShortfall {
2650 SealedShortfall {
2651 blocker,
2652 failed_call: None,
2653 errno: None,
2654 userns_grant: UsernsGrant::Withheld,
2655 attachment: crate::sealed::ProfileAttachment::NotReported,
2656 }
2657 }
2658 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
2659
2660 const ALL_TIERS: &[EnforcementTier] = &[
2662 EnforcementTier::KernelSealed,
2663 EnforcementTier::KernelFull,
2664 EnforcementTier::KernelSeccompOnly,
2665 EnforcementTier::EnvironmentOnly,
2666 ];
2667
2668 #[test]
2669 fn kernel_full_achieves_scoped() {
2670 assert_eq!(
2671 achieved_class_for_tier(EnforcementTier::KernelFull),
2672 ContainmentClass::Scoped
2673 );
2674 }
2675
2676 #[test]
2678 fn only_the_sealed_tier_achieves_sealed() {
2679 assert_eq!(
2680 achieved_class_for_tier(EnforcementTier::KernelSealed),
2681 ContainmentClass::Sealed
2682 );
2683 for tier in ALL_TIERS
2684 .iter()
2685 .filter(|t| **t != EnforcementTier::KernelSealed)
2686 {
2687 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
2688 }
2689 }
2690
2691 #[test]
2692 fn tiers_without_landlock_achieve_only_advisory() {
2693 assert_eq!(
2694 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
2695 ContainmentClass::Advisory
2696 );
2697 assert_eq!(
2698 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
2699 ContainmentClass::Advisory
2700 );
2701 }
2702
2703 #[test]
2709 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
2710 assert_eq!(
2712 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
2713 ContainmentClass::Sealed
2714 );
2715 assert_eq!(
2716 achieved_containment_class(EnforcementTier::KernelFull, false, false),
2717 ContainmentClass::Scoped
2718 );
2719 assert_eq!(
2720 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
2721 ContainmentClass::Advisory
2722 );
2723 assert_eq!(
2724 achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
2725 ContainmentClass::Advisory
2726 );
2727
2728 for tier in ALL_TIERS {
2730 assert_eq!(
2731 achieved_containment_class(*tier, true, false),
2732 ContainmentClass::Advisory,
2733 "workdir_exec must cap tier {tier:?} at advisory"
2734 );
2735 }
2736 }
2737
2738 #[test]
2741 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
2742 for tier in ALL_TIERS {
2743 assert_eq!(
2744 achieved_class_for_tier(*tier),
2745 achieved_containment_class(*tier, false, false),
2746 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
2747 );
2748 }
2749 }
2750
2751 #[test]
2755 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
2756 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
2757 assert_eq!(achieved, ContainmentClass::Advisory);
2758
2759 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
2760 .expect_err("workdir_exec + scoped must refuse");
2761 match error {
2762 RuntimeError::ContainmentFloorUnmet {
2763 declared,
2764 achieved,
2765 reason,
2766 ..
2767 } => {
2768 assert_eq!(declared, ContainmentClass::Scoped);
2769 assert_eq!(achieved, ContainmentClass::Advisory);
2770 assert!(
2771 reason.contains("workdir_exec"),
2772 "the reason must name the manifest key, got: {reason}"
2773 );
2774 assert!(
2775 !reason.contains("this host provides no kernel filesystem mediation"),
2776 "the reason must not blame the host for a manifest declaration: {reason}"
2777 );
2778 }
2779 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2780 }
2781 }
2782
2783 #[test]
2787 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
2788 let reason = containment_shortfall_reason(
2789 ContainmentClass::Sealed,
2790 ContainmentClass::Advisory,
2791 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2792 true,
2793 None,
2794 )
2795 .expect("refusal");
2796 assert!(reason.contains("workdir_exec"));
2797 assert!(!reason.contains("apparmor_parser"));
2798 }
2799
2800 #[test]
2803 fn workdir_exec_alone_refuses_nothing() {
2804 for tier in ALL_TIERS {
2805 let achieved = achieved_containment_class(*tier, true, false);
2806 assert!(
2807 check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
2808 .is_ok(),
2809 "workdir_exec at an advisory floor must launch on tier {tier:?}"
2810 );
2811 }
2812 }
2813
2814 #[test]
2815 fn advisory_floor_is_met_on_every_tier() {
2816 for tier in ALL_TIERS {
2817 let achieved = achieved_class_for_tier(*tier);
2818 assert!(check_containment_floor(
2819 ContainmentClass::Advisory,
2820 achieved,
2821 None,
2822 false,
2823 None
2824 )
2825 .is_ok());
2826 }
2827 }
2828
2829 #[test]
2830 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
2831 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
2832 let achieved = achieved_class_for_tier(tier);
2833 assert!(
2834 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
2835 .is_ok()
2836 );
2837 }
2838
2839 for tier in [
2840 EnforcementTier::KernelSeccompOnly,
2841 EnforcementTier::EnvironmentOnly,
2842 ] {
2843 let achieved = achieved_class_for_tier(tier);
2844 let error =
2845 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
2846 .expect_err("scoped must refuse without Landlock");
2847 match error {
2848 RuntimeError::ContainmentFloorUnmet {
2849 declared,
2850 achieved,
2851 reason,
2852 ..
2853 } => {
2854 assert_eq!(declared, ContainmentClass::Scoped);
2855 assert_eq!(achieved, ContainmentClass::Advisory);
2856 assert!(
2857 reason.contains("Landlock"),
2858 "reason must name the missing mechanism, got: {reason}"
2859 );
2860 }
2861 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2862 }
2863 }
2864 }
2865
2866 #[test]
2870 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
2871 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
2872 let decision =
2873 ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
2874 assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
2875 assert_eq!(decision.achieved(), ContainmentClass::Scoped);
2876 assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
2877 assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
2878 assert!(
2879 check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
2880 );
2881 assert_eq!(
2882 containment_shortfall_reason(declared, decision.achieved(), None, false, None),
2883 None
2884 );
2885 }
2886 }
2887
2888 const ALL_CLASSES: &[ContainmentClass] = &[
2889 ContainmentClass::Advisory,
2890 ContainmentClass::Scoped,
2891 ContainmentClass::Sealed,
2892 ];
2893
2894 #[test]
2898 fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
2899 use ContainmentClass::{Advisory, Scoped, Sealed};
2900 use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
2901 let table = [
2903 (
2904 (KernelSealed, Advisory),
2905 (KernelFull, Scoped, "landlock+seccomp"),
2906 ),
2907 (
2908 (KernelSealed, Scoped),
2909 (KernelFull, Scoped, "landlock+seccomp"),
2910 ),
2911 (
2912 (KernelSealed, Sealed),
2913 (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
2914 ),
2915 (
2916 (KernelFull, Advisory),
2917 (KernelFull, Scoped, "landlock+seccomp"),
2918 ),
2919 (
2920 (KernelFull, Scoped),
2921 (KernelFull, Scoped, "landlock+seccomp"),
2922 ),
2923 (
2924 (KernelFull, Sealed),
2925 (KernelFull, Scoped, "landlock+seccomp"),
2926 ),
2927 (
2928 (KernelSeccompOnly, Advisory),
2929 (KernelSeccompOnly, Advisory, "seccomp-only"),
2930 ),
2931 (
2932 (KernelSeccompOnly, Scoped),
2933 (KernelSeccompOnly, Advisory, "seccomp-only"),
2934 ),
2935 (
2936 (KernelSeccompOnly, Sealed),
2937 (KernelSeccompOnly, Advisory, "seccomp-only"),
2938 ),
2939 (
2940 (EnvironmentOnly, Advisory),
2941 (EnvironmentOnly, Advisory, "none"),
2942 ),
2943 (
2944 (EnvironmentOnly, Scoped),
2945 (EnvironmentOnly, Advisory, "none"),
2946 ),
2947 (
2948 (EnvironmentOnly, Sealed),
2949 (EnvironmentOnly, Advisory, "none"),
2950 ),
2951 ];
2952 assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
2953 for ((host_tier, declared), (applied, class, wire)) in table {
2954 let host_class = match host_tier {
2955 KernelSealed => Sealed,
2956 KernelFull => Scoped,
2957 KernelSeccompOnly | EnvironmentOnly => Advisory,
2958 EnforcementTier::Unconfined => unreachable!("never a host tier"),
2959 };
2960 for workdir_exec in [false, true] {
2961 for inside in [false, true] {
2962 let decision =
2963 ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
2964 let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
2965 assert_eq!(decision.declared(), declared, "{what}");
2966 assert_eq!(decision.applied_tier(), applied, "{what}");
2967 assert_eq!(decision.enforcement_tier(), wire, "{what}");
2968 assert_eq!(decision.host_containment(), host_class, "{what}");
2969 let expected = if workdir_exec || inside {
2970 Advisory
2971 } else {
2972 class
2973 };
2974 assert_eq!(decision.achieved(), expected, "{what}");
2975 assert!(decision.achieved() <= decision.host_containment(), "{what}");
2978 assert!(
2979 decision.achieved() != Sealed || declared == Sealed,
2980 "{what}"
2981 );
2982 }
2983 }
2984 }
2985
2986 let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
2988 assert_eq!(
2989 (
2990 scoped.achieved(),
2991 scoped.enforcement_tier(),
2992 scoped.host_containment()
2993 ),
2994 (Scoped, "landlock+seccomp", Sealed)
2995 );
2996 let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
2997 assert_eq!(
2998 (sealed.achieved(), sealed.enforcement_tier()),
2999 (Sealed, "mountns+pivot_root+landlock+seccomp")
3000 );
3001 let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
3002 assert_eq!(
3003 (advisory.achieved(), advisory.enforcement_tier()),
3004 (Scoped, "landlock+seccomp")
3005 );
3006 }
3007
3008 #[test]
3012 fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
3013 for &host_tier in ALL_TIERS {
3014 for &declared in ALL_CLASSES {
3015 for workdir_exec in [false, true] {
3016 for inside in [None, Some(inside_placement())] {
3017 let in_workdir = inside.is_some();
3018 let decision = ContainmentDecision::decide(
3019 host_tier,
3020 declared,
3021 workdir_exec,
3022 in_workdir,
3023 );
3024 let host_class =
3025 achieved_containment_class(host_tier, workdir_exec, in_workdir);
3026 let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
3027 assert_eq!(
3028 containment_shortfall_reason(
3029 declared,
3030 decision.achieved(),
3031 Some(&shortfall),
3032 workdir_exec,
3033 inside.as_ref(),
3034 ),
3035 containment_shortfall_reason(
3036 declared,
3037 host_class,
3038 Some(&shortfall),
3039 workdir_exec,
3040 inside.as_ref(),
3041 ),
3042 "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
3043 );
3044 }
3045 }
3046 }
3047 }
3048 }
3049
3050 #[test]
3051 fn sealed_floor_is_met_only_on_the_sealed_tier() {
3052 assert!(check_containment_floor(
3053 ContainmentClass::Sealed,
3054 achieved_class_for_tier(EnforcementTier::KernelSealed),
3055 None,
3056 false,
3057 None
3058 )
3059 .is_ok());
3060
3061 for tier in ALL_TIERS
3062 .iter()
3063 .filter(|t| **t != EnforcementTier::KernelSealed)
3064 {
3065 let achieved = achieved_class_for_tier(*tier);
3066 let error = check_containment_floor(
3067 ContainmentClass::Sealed,
3068 achieved,
3069 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3070 false,
3071 None,
3072 )
3073 .expect_err("sealed must refuse on a host that cannot back it");
3074 match error {
3075 RuntimeError::ContainmentFloorUnmet {
3076 declared, reason, ..
3077 } => {
3078 assert_eq!(declared, ContainmentClass::Sealed);
3079 assert!(
3080 reason.contains("seccomp profile") && !reason.contains("--cap-add"),
3081 "reason must name the blocking mechanism's remediation, got: {reason}"
3082 );
3083 }
3084 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3085 }
3086 }
3087 }
3088
3089 #[test]
3092 fn the_sealed_refusal_names_the_specific_blocker() {
3093 let achieved = ContainmentClass::Scoped;
3094
3095 let apparmor = containment_shortfall_reason(
3096 ContainmentClass::Sealed,
3097 achieved,
3098 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3099 false,
3100 None,
3101 )
3102 .expect("refusal");
3103 assert!(apparmor.contains("mur-sealed"));
3104 assert!(apparmor.contains("apparmor_parser -r"));
3105 assert!(!apparmor.contains("--cap-add"));
3106
3107 let container = containment_shortfall_reason(
3108 ContainmentClass::Sealed,
3109 achieved,
3110 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3111 false,
3112 None,
3113 )
3114 .expect("refusal");
3115 assert!(!container.contains("--cap-add"), "{container}");
3116 assert!(container.contains("seccomp profile"), "{container}");
3117 assert!(container.contains("unshare(CLONE_NEWUSER)"), "{container}");
3118 assert!(container.contains("--security-opt seccomp="), "{container}");
3119 assert!(
3120 container.contains("seccompProfile: Localhost"),
3121 "{container}"
3122 );
3123 assert!(container.contains("outside the container"));
3124 assert!(!container.contains("apparmor_parser"));
3125
3126 let pivot = containment_shortfall_reason(
3129 ContainmentClass::Sealed,
3130 ContainmentClass::Advisory,
3131 Some(&blocked(SealedBlocker::PivotRootDenied)),
3132 false,
3133 None,
3134 )
3135 .expect("refusal");
3136 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
3137 assert_ne!(
3138 pivot,
3139 containment_shortfall_reason(
3140 ContainmentClass::Sealed,
3141 ContainmentClass::Advisory,
3142 Some(&blocked(SealedBlocker::MountDenied)),
3143 false,
3144 None,
3145 )
3146 .expect("refusal")
3147 );
3148
3149 for blocker in [
3151 SealedBlocker::NotLinux,
3152 SealedBlocker::AppArmorProfileMissing,
3153 SealedBlocker::NamespaceCreationDenied,
3154 SealedBlocker::MountDenied,
3155 SealedBlocker::PivotRootDenied,
3156 SealedBlocker::KernelUnsupported,
3157 SealedBlocker::LandlockUnavailable,
3158 ] {
3159 let reason = containment_shortfall_reason(
3160 ContainmentClass::Sealed,
3161 achieved,
3162 Some(&blocked(blocker)),
3163 false,
3164 None,
3165 )
3166 .expect("refusal");
3167 assert!(
3168 !reason.contains("no host can provide it today"),
3169 "got: {reason}"
3170 );
3171 }
3172 }
3173
3174 #[test]
3177 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
3178 let reason = containment_shortfall_reason(
3179 ContainmentClass::Sealed,
3180 ContainmentClass::Advisory,
3181 None,
3182 false,
3183 None,
3184 )
3185 .expect("refusal");
3186 assert!(reason.contains("pivot_root"));
3187 assert!(reason.contains("CLONE_NEWNS"));
3188 }
3189
3190 #[test]
3191 fn shortfall_reason_is_absent_when_the_floor_is_met() {
3192 assert_eq!(
3193 containment_shortfall_reason(
3194 ContainmentClass::Advisory,
3195 ContainmentClass::Scoped,
3196 None,
3197 false,
3198 None
3199 ),
3200 None
3201 );
3202 assert_eq!(
3203 containment_shortfall_reason(
3204 ContainmentClass::Scoped,
3205 ContainmentClass::Scoped,
3206 None,
3207 false,
3208 None
3209 ),
3210 None
3211 );
3212 assert_eq!(
3213 containment_shortfall_reason(
3214 ContainmentClass::Sealed,
3215 ContainmentClass::Sealed,
3216 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3217 false,
3218 None
3219 ),
3220 None,
3221 "a met floor must ignore a stale blocker rather than manufacture a refusal"
3222 );
3223 }
3224
3225 fn sample_policy() -> CapabilityPolicy {
3226 CapabilityPolicy {
3227 network_allow: vec!["https://api.example.com".to_string()],
3228 unix_sockets_allowed: false,
3229 filesystem_scope: Some("workdir".to_string()),
3230 shell_allow: vec!["python3".to_string()],
3231 spawn_allow: vec!["helper".to_string()],
3232 env_allow: vec!["TZ".to_string()],
3233 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3234 binary: "python3".to_string(),
3235 dirs: vec![InterpreterRuntimeDir {
3236 path: "/usr/lib/python3.11".to_string(),
3237 list_dir: true,
3238 }],
3239 }],
3240 ..CapabilityPolicy::default()
3241 }
3242 }
3243
3244 #[test]
3248 fn an_unconfined_report_names_no_protection_it_does_not_install() {
3249 for host_tier in [
3250 EnforcementTier::KernelSealed,
3251 EnforcementTier::KernelFull,
3252 EnforcementTier::KernelSeccompOnly,
3253 EnforcementTier::EnvironmentOnly,
3254 ] {
3255 let decision =
3256 ContainmentDecision::decide(host_tier, ContainmentClass::Unconfined, true, true);
3257 assert_eq!(decision.applied_tier(), EnforcementTier::Unconfined);
3258 assert_eq!(decision.achieved(), ContainmentClass::Unconfined);
3259 assert_eq!(decision.enforcement_tier(), "unconfined");
3260 assert_eq!(
3261 decision.host_containment(),
3262 achieved_class_for_tier(host_tier)
3263 );
3264 }
3265 let policy = CapabilityPolicy {
3266 containment_floor: ContainmentClass::Unconfined,
3267 declared_resources: murmur_artifact::ResourceCapabilities {
3268 max_open_files: Some(256),
3269 ..Default::default()
3270 },
3271 ..sample_policy()
3272 };
3273 let report = report_for(
3274 &policy,
3275 ContainmentClass::Unconfined,
3276 EnforcementTier::KernelFull,
3277 None,
3278 None,
3279 Some(4),
3280 None,
3281 Vec::new(),
3282 Vec::new(),
3283 Vec::new(),
3284 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3285 crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
3286 IoMaxReport::default(),
3287 );
3288 assert!(report.floor_met);
3289 assert_eq!(report.achieved_containment, ContainmentClass::Unconfined);
3290 assert_eq!(report.enforcement_tier, "unconfined");
3291 for row in &report.routes {
3293 if row.route.runs_unconfined() {
3294 assert!(row.under_class, "{row:?}");
3295 assert_eq!(row.mechanism, "unconfined", "{row:?}");
3296 assert_eq!(row.note, Some(UNCONFINED_ROUTE_NOTE), "{row:?}");
3297 } else {
3298 assert_eq!(row.note, row.route.note(), "{row:?}");
3299 }
3300 }
3301 assert_eq!(
3302 uncovered_route_warning(report.declared_containment, &report.routes),
3303 None,
3304 "W-SEC-034 keys on a scoped or sealed floor"
3305 );
3306 assert_eq!(report.landlock_abi, None);
3307 assert_eq!(
3308 report.filesystem_boundary.restriction,
3309 FilesystemRestriction::None
3310 );
3311 assert_eq!(
3312 report.filesystem_boundary.not_protected,
3313 vec![UNCONFINED_NOT_PROTECTED.to_string()]
3314 );
3315 for dir in [&report.runtime_dir, &report.records_dir] {
3316 assert!(!dir.read_only_to_commands);
3317 assert_eq!(dir.enforced_by, None);
3318 assert!(dir
3319 .not_protected_because
3320 .as_deref()
3321 .unwrap()
3322 .contains("unconfined"));
3323 }
3324 assert!(!report.temp_dir.tmp_mapped);
3325 assert_eq!(
3326 report.temp_dir.not_mapped_because.as_deref(),
3327 Some(crate::temp_dir::NotMappedReason::Unconfined.describe())
3328 );
3329 let unconfined = report.unconfined.as_ref().expect("an unconfined report");
3330 assert_eq!(unconfined.uid, current_uid());
3331 assert_eq!(unconfined.applies_to, UNCONFINED_APPLIES_TO.to_vec());
3332 assert_eq!(unconfined.resource_limits, vec!["max_open_files"]);
3333 let json = serde_json::to_value(&report).unwrap();
3334 assert_eq!(json["enforcement_tier"], "unconfined");
3335 assert_eq!(json["achieved_containment"], "unconfined");
3336 assert_eq!(json["filesystem_boundary"]["restriction"], "none");
3337 assert!(json["unconfined"].is_object(), "{json}");
3338 let rendered = report.render();
3339 assert!(rendered.contains(" achieved: unconfined\n"), "{rendered}");
3340 assert!(rendered.contains("\nUnconfined\n user:"), "{rendered}");
3341 assert!(rendered.contains(" uid: "), "{rendered}");
3342 assert!(rendered.contains(" HOME: "), "{rendered}");
3343 assert!(
3344 rendered.contains(
3345 "applies to: shell commands, plan shell steps, native tools, process harness"
3346 ),
3347 "{rendered}"
3348 );
3349 assert!(unconfined_warning(unconfined).contains("sudo"));
3350
3351 let contained = report_for(
3353 &sample_policy(),
3354 ContainmentClass::Scoped,
3355 EnforcementTier::KernelFull,
3356 None,
3357 None,
3358 None,
3359 None,
3360 Vec::new(),
3361 Vec::new(),
3362 Vec::new(),
3363 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3364 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3365 IoMaxReport::default(),
3366 );
3367 let json = serde_json::to_value(&contained).unwrap();
3368 assert!(json["unconfined"].is_null(), "{json}");
3369 assert!(json.as_object().unwrap().contains_key("unconfined"));
3370 }
3371
3372 #[test]
3373 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
3374 let report = report_for(
3375 &sample_policy(),
3376 ContainmentClass::Scoped,
3377 EnforcementTier::KernelFull,
3378 None,
3379 None,
3380 None,
3381 None,
3382 Vec::new(),
3383 Vec::new(),
3384 Vec::new(),
3385 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3386 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3387 EnforcementTier::KernelFull,
3388 ContainmentClass::Scoped,
3389 )),
3390 IoMaxReport::default(),
3391 );
3392
3393 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
3394 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3395 assert!(report.floor_met);
3396 assert_eq!(report.shortfall_reason, None);
3397 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3398 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
3399 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
3400 assert!(!report.unix_sockets);
3401 assert_eq!(report.shell_allow, vec!["python3"]);
3402 assert_eq!(report.spawn_allow, vec!["helper"]);
3403 assert_eq!(report.env_allow, vec!["TZ"]);
3404 assert!(!report.workdir_exec);
3405 assert_eq!(
3406 report.interpreter_runtime_grants,
3407 vec!["python3: /usr/lib/python3.11 (list_dir)"]
3408 );
3409 }
3410
3411 #[test]
3415 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
3416 let policy = CapabilityPolicy {
3417 shell_allow: vec!["gcc".to_string()],
3418 workdir_exec_allowed: true,
3419 ..CapabilityPolicy::default()
3420 };
3421
3422 let report = report_for(
3423 &policy,
3424 ContainmentClass::Scoped,
3425 EnforcementTier::KernelFull,
3426 None,
3427 None,
3428 None,
3429 None,
3430 Vec::new(),
3431 Vec::new(),
3432 Vec::new(),
3433 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3434 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3435 EnforcementTier::KernelFull,
3436 ContainmentClass::Scoped,
3437 )),
3438 IoMaxReport::default(),
3439 );
3440
3441 assert!(report.workdir_exec);
3442 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3443 assert!(!report.floor_met);
3444 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3447 assert!(report
3448 .shortfall_reason
3449 .as_deref()
3450 .expect("refusal")
3451 .contains("workdir_exec"));
3452
3453 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3454 assert_eq!(value["workdir_exec"], true);
3455
3456 let rendered = report.render();
3457 assert!(rendered.contains("workdir exec: true"));
3458 assert!(rendered.contains("achieved: advisory"));
3459 }
3460
3461 #[test]
3465 fn scope_report_json_always_carries_io_max() {
3466 let unprobed: serde_json::Value = serde_json::to_value(report_for(
3467 &sample_policy(),
3468 ContainmentClass::Scoped,
3469 EnforcementTier::KernelFull,
3470 None,
3471 None,
3472 None,
3473 None,
3474 Vec::new(),
3475 Vec::new(),
3476 Vec::new(),
3477 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3478 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3479 EnforcementTier::KernelFull,
3480 ContainmentClass::Scoped,
3481 )),
3482 IoMaxReport::default(),
3483 ))
3484 .unwrap();
3485 assert_eq!(unprobed["io_max"]["status"], "not-probed");
3486 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
3487 assert!(unprobed["io_max"].get("reason").is_none());
3488
3489 let enforced: serde_json::Value = serde_json::to_value(report_for(
3490 &sample_policy(),
3491 ContainmentClass::Scoped,
3492 EnforcementTier::KernelFull,
3493 None,
3494 None,
3495 None,
3496 None,
3497 Vec::new(),
3498 Vec::new(),
3499 Vec::new(),
3500 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3501 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3502 EnforcementTier::KernelFull,
3503 ContainmentClass::Scoped,
3504 )),
3505 IoMaxReport {
3506 declared_bytes_per_sec: 52_428_800,
3507 status: IoMaxStatus::Enforced,
3508 reason: None,
3509 },
3510 ))
3511 .unwrap();
3512 assert_eq!(enforced["io_max"]["status"], "enforced");
3513 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
3514 }
3515
3516 #[test]
3520 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
3521 let rendered = report_for(
3522 &sample_policy(),
3523 ContainmentClass::Scoped,
3524 EnforcementTier::KernelFull,
3525 None,
3526 None,
3527 None,
3528 None,
3529 Vec::new(),
3530 Vec::new(),
3531 Vec::new(),
3532 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3533 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3534 EnforcementTier::KernelFull,
3535 ContainmentClass::Scoped,
3536 )),
3537 IoMaxReport {
3538 declared_bytes_per_sec: 52_428_800,
3539 status: IoMaxStatus::Unavailable,
3540 reason: Some("the io controller is not delegated".to_string()),
3541 },
3542 )
3543 .render();
3544
3545 assert!(rendered.contains("Resource plane"), "{rendered}");
3546 assert!(rendered.contains("io.max:"), "{rendered}");
3547 assert!(rendered.contains("unavailable"), "{rendered}");
3548 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
3549 assert!(
3550 rendered.contains("the io controller is not delegated"),
3551 "{rendered}"
3552 );
3553 }
3554
3555 #[test]
3558 fn scope_report_json_always_carries_workdir_exec() {
3559 let value: serde_json::Value = serde_json::to_value(report_for(
3560 &sample_policy(),
3561 ContainmentClass::Scoped,
3562 EnforcementTier::KernelFull,
3563 None,
3564 None,
3565 None,
3566 None,
3567 Vec::new(),
3568 Vec::new(),
3569 Vec::new(),
3570 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3571 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3572 EnforcementTier::KernelFull,
3573 ContainmentClass::Scoped,
3574 )),
3575 IoMaxReport::default(),
3576 ))
3577 .unwrap();
3578 assert_eq!(value["workdir_exec"], false);
3579 }
3580
3581 #[test]
3585 fn staged_runtime_grants_are_reported_on_every_tier() {
3586 let policy = CapabilityPolicy {
3587 shell_allow: vec!["python3".to_string()],
3588 shell_staged_runtime: vec![StagedRuntimeGrant {
3589 binary: "python3".to_string(),
3590 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3591 pin: "conda-4.10.3/python-3.9.19".to_string(),
3592 }],
3593 ..CapabilityPolicy::default()
3594 };
3595
3596 for tier in ALL_TIERS {
3597 let report = report_for(
3598 &policy,
3599 ContainmentClass::Sealed,
3600 *tier,
3601 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3602 None,
3603 None,
3604 None,
3605 Vec::new(),
3606 Vec::new(),
3607 Vec::new(),
3608 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3609 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3610 *tier,
3611 ContainmentClass::Sealed,
3612 )),
3613 IoMaxReport::default(),
3614 );
3615 assert_eq!(
3616 report.staged_runtime_grants,
3617 vec![
3618 "python3: /opt/testbed/conda/envs/django__django \
3619 (pin: conda-4.10.3/python-3.9.19)"
3620 ],
3621 "tier {tier:?} must still report the declared staged runtime"
3622 );
3623 assert!(
3624 report.render().contains("staged runtime"),
3625 "the rendered report must carry a staged runtime section on tier {tier:?}"
3626 );
3627 }
3628 }
3629
3630 #[test]
3633 fn absent_staged_runtime_reports_nothing() {
3634 let report = report_for(
3635 &sample_policy(),
3636 ContainmentClass::Scoped,
3637 EnforcementTier::KernelFull,
3638 None,
3639 None,
3640 None,
3641 None,
3642 Vec::new(),
3643 Vec::new(),
3644 Vec::new(),
3645 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3646 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3647 EnforcementTier::KernelFull,
3648 ContainmentClass::Scoped,
3649 )),
3650 IoMaxReport::default(),
3651 );
3652 assert!(report.staged_runtime_grants.is_empty());
3653 assert!(report.render().contains("staged runtime: <none>"));
3654 }
3655
3656 #[test]
3660 fn grant_set_never_raises_the_achieved_class() {
3661 let report = report_for(
3662 &sample_policy(),
3663 ContainmentClass::Sealed,
3664 EnforcementTier::KernelFull,
3665 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3666 None,
3667 None,
3668 None,
3669 Vec::new(),
3670 Vec::new(),
3671 Vec::new(),
3672 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3673 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3674 EnforcementTier::KernelFull,
3675 ContainmentClass::Sealed,
3676 )),
3677 IoMaxReport::default(),
3678 );
3679 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3680 assert!(!report.floor_met);
3681 }
3682
3683 #[test]
3684 fn scope_report_still_reports_when_the_floor_is_unmet() {
3685 let report = report_for(
3686 &sample_policy(),
3687 ContainmentClass::Sealed,
3688 EnforcementTier::EnvironmentOnly,
3689 Some(&blocked(SealedBlocker::NotLinux)),
3690 None,
3691 None,
3692 None,
3693 Vec::new(),
3694 Vec::new(),
3695 Vec::new(),
3696 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3697 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3698 EnforcementTier::EnvironmentOnly,
3699 ContainmentClass::Sealed,
3700 )),
3701 IoMaxReport::default(),
3702 );
3703
3704 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
3705 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3706 assert!(!report.floor_met);
3707 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
3708 assert_eq!(report.enforcement_tier, "none");
3709 }
3710
3711 #[test]
3712 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
3713 let report = report_for(
3714 &sample_policy(),
3715 ContainmentClass::Sealed,
3716 EnforcementTier::KernelSealed,
3717 None,
3718 None,
3719 None,
3720 None,
3721 Vec::new(),
3722 Vec::new(),
3723 Vec::new(),
3724 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3725 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3726 EnforcementTier::KernelSealed,
3727 ContainmentClass::Sealed,
3728 )),
3729 IoMaxReport::default(),
3730 );
3731 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
3732 assert_eq!(report.host_containment, ContainmentClass::Sealed);
3733 assert!(report.floor_met);
3734 assert_eq!(
3735 report.enforcement_tier,
3736 "mountns+pivot_root+landlock+seccomp"
3737 );
3738 assert_eq!(
3739 report.filesystem_boundary.restriction,
3740 FilesystemRestriction::Absent
3741 );
3742 }
3743
3744 #[test]
3748 fn every_report_field_follows_the_decision() {
3749 for &host_tier in ALL_TIERS {
3750 for &declared in ALL_CLASSES {
3751 let decision = ContainmentDecision::decide(host_tier, declared, false, false);
3752 let report = scope_report_for_tier(
3753 &sample_policy(),
3754 &decision,
3755 None,
3756 None,
3757 Some(6),
3758 None,
3759 Vec::new(),
3760 Vec::new(),
3761 Vec::new(),
3762 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3763 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3764 IoMaxReport::default(),
3765 );
3766 let what = format!("{host_tier:?} {declared}");
3767 assert_eq!(report.declared_containment, declared, "{what}");
3768 assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
3769 assert_eq!(
3770 report.enforcement_tier,
3771 decision.enforcement_tier(),
3772 "{what}"
3773 );
3774 assert_eq!(
3775 report.host_containment,
3776 decision.host_containment(),
3777 "{what}"
3778 );
3779 assert_eq!(
3780 report.landlock_abi,
3781 crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
3782 "{what}"
3783 );
3784 let restriction = filesystem_boundary(decision.applied_tier()).restriction;
3785 assert_eq!(
3786 report.filesystem_boundary.restriction, restriction,
3787 "{what}"
3788 );
3789 assert_eq!(
3790 restriction,
3791 match report.enforcement_tier {
3792 "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
3793 "landlock+seccomp" => FilesystemRestriction::Enforced,
3794 _ => FilesystemRestriction::Advisory,
3795 },
3796 "{what}"
3797 );
3798 assert_eq!(
3799 report.temp_dir,
3800 temp_dir_report(
3801 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3802 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3803 ),
3804 "{what}"
3805 );
3806 }
3807 }
3808 }
3809
3810 #[test]
3813 fn host_containment_is_reported_beside_the_achieved_class() {
3814 let report = report_for(
3815 &sample_policy(),
3816 ContainmentClass::Scoped,
3817 EnforcementTier::KernelSealed,
3818 None,
3819 None,
3820 None,
3821 None,
3822 Vec::new(),
3823 Vec::new(),
3824 Vec::new(),
3825 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3826 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3827 IoMaxReport::default(),
3828 );
3829 let value = serde_json::to_value(&report).unwrap();
3830 assert_eq!(value["achieved_containment"], "scoped");
3831 assert_eq!(value["host_containment"], "sealed");
3832 assert_eq!(value["enforcement_tier"], "landlock+seccomp");
3833 assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
3834
3835 let rendered = report.render();
3836 assert!(
3837 rendered.contains(" achieved: scoped\n host can back: sealed\n floor met: yes\n"),
3838 "{rendered}"
3839 );
3840 assert!(
3841 rendered.contains(" mechanism: landlock+seccomp\n"),
3842 "{rendered}"
3843 );
3844 }
3845
3846 #[test]
3847 fn scope_report_json_uses_wire_names() {
3848 let report = report_for(
3849 &CapabilityPolicy::default(),
3850 ContainmentClass::Advisory,
3851 EnforcementTier::KernelSeccompOnly,
3852 None,
3853 None,
3854 None,
3855 None,
3856 Vec::new(),
3857 Vec::new(),
3858 Vec::new(),
3859 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3860 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3861 EnforcementTier::KernelSeccompOnly,
3862 ContainmentClass::Advisory,
3863 )),
3864 IoMaxReport::default(),
3865 );
3866 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3867
3868 assert_eq!(value["declared_containment"], "advisory");
3869 assert_eq!(value["achieved_containment"], "advisory");
3870 assert_eq!(value["host_containment"], "advisory");
3871 assert_eq!(value["floor_met"], true);
3872 assert_eq!(value["enforcement_tier"], "seccomp-only");
3873 assert!(value.get("shortfall_reason").is_none());
3875 }
3876
3877 #[test]
3880 fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
3881 let report = |tier, kernel| {
3882 report_for(
3883 &sample_policy(),
3884 ContainmentClass::Scoped,
3885 tier,
3886 None,
3887 None,
3888 kernel,
3889 None,
3890 Vec::new(),
3891 Vec::new(),
3892 Vec::new(),
3893 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3894 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3895 tier,
3896 ContainmentClass::Scoped,
3897 )),
3898 IoMaxReport::default(),
3899 )
3900 };
3901
3902 for (tier, kernel, expected) in [
3903 (EnforcementTier::KernelFull, Some(8), Some(3)),
3904 (EnforcementTier::KernelSealed, Some(8), Some(3)),
3905 (EnforcementTier::KernelFull, Some(2), Some(2)),
3906 (EnforcementTier::KernelFull, Some(1), Some(1)),
3907 (EnforcementTier::KernelFull, None, None),
3908 (EnforcementTier::KernelSeccompOnly, Some(8), None),
3909 (EnforcementTier::EnvironmentOnly, None, None),
3910 ] {
3911 let scope = report(tier, kernel);
3912 assert_eq!(
3913 scope.landlock_abi, expected,
3914 "{tier:?} with kernel {kernel:?}"
3915 );
3916
3917 let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
3918 assert!(value.as_object().unwrap().contains_key("landlock_abi"));
3919 match expected {
3920 Some(abi) => assert_eq!(value["landlock_abi"], abi),
3921 None => assert!(value["landlock_abi"].is_null()),
3922 }
3923
3924 let rendered = scope.render();
3925 let line = match expected {
3926 Some(abi) => format!(" landlock ABI: {abi}\n"),
3927 None => " landlock ABI: n/a\n".to_string(),
3928 };
3929 assert!(rendered.contains(&line), "{rendered}");
3930 assert!(
3931 rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
3932 );
3933 }
3934 }
3935
3936 #[test]
3939 fn the_userns_grant_is_reported_without_changing_any_verdict() {
3940 let baseline = report_for(
3941 &sample_policy(),
3942 ContainmentClass::Advisory,
3943 EnforcementTier::KernelSealed,
3944 None,
3945 None,
3946 None,
3947 None,
3948 Vec::new(),
3949 Vec::new(),
3950 Vec::new(),
3951 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3952 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3953 EnforcementTier::KernelSealed,
3954 ContainmentClass::Advisory,
3955 )),
3956 IoMaxReport::default(),
3957 );
3958
3959 for grant in UsernsGrant::ALL {
3960 let report = report_for(
3961 &sample_policy(),
3962 ContainmentClass::Advisory,
3963 EnforcementTier::KernelSealed,
3964 None,
3965 Some(*grant),
3966 None,
3967 None,
3968 Vec::new(),
3969 Vec::new(),
3970 Vec::new(),
3971 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3972 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3973 EnforcementTier::KernelSealed,
3974 ContainmentClass::Advisory,
3975 )),
3976 IoMaxReport::default(),
3977 );
3978 assert_eq!(report.achieved_containment, baseline.achieved_containment);
3979 assert_eq!(report.floor_met, baseline.floor_met);
3980 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
3981 assert_eq!(report.userns_grant, Some(*grant));
3982
3983 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3984 assert_eq!(value["userns_grant"], grant.wire_name());
3985 assert!(report
3986 .render()
3987 .contains(&format!("userns grant: {}", grant.wire_name())));
3988 }
3989
3990 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
3993 assert!(value["userns_grant"].is_null());
3994 assert!(baseline.render().contains("userns grant: n/a"));
3995 }
3996
3997 #[test]
3998 fn rendered_report_names_both_classes_and_the_refusal() {
3999 let rendered = report_for(
4000 &sample_policy(),
4001 ContainmentClass::Sealed,
4002 EnforcementTier::KernelFull,
4003 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
4004 None,
4005 None,
4006 None,
4007 Vec::new(),
4008 Vec::new(),
4009 Vec::new(),
4010 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4011 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4012 EnforcementTier::KernelFull,
4013 ContainmentClass::Sealed,
4014 )),
4015 IoMaxReport::default(),
4016 )
4017 .render();
4018
4019 assert!(rendered.contains("declared: sealed"));
4020 assert!(rendered.contains("achieved: scoped"));
4021 assert!(rendered.contains("floor met: no"));
4022 assert!(rendered.contains("mur-sealed"));
4023 assert!(rendered.contains("would refuse to launch"));
4024 }
4025
4026 #[test]
4030 fn an_export_never_changes_what_the_report_says_about_containment() {
4031 let export = FileExport {
4032 root: "out/".to_string(),
4033 mode: ExportMode::ReadOnly,
4034 max_bytes: 10 * 1024 * 1024,
4035 };
4036 for tier in ALL_TIERS {
4037 for workdir_exec in [false, true] {
4038 let policy = CapabilityPolicy {
4039 workdir_exec_allowed: workdir_exec,
4040 ..sample_policy()
4041 };
4042 let without = report_for(
4043 &policy,
4044 ContainmentClass::Scoped,
4045 *tier,
4046 None,
4047 None,
4048 None,
4049 None,
4050 Vec::new(),
4051 Vec::new(),
4052 Vec::new(),
4053 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4054 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4055 *tier,
4056 ContainmentClass::Scoped,
4057 )),
4058 IoMaxReport::default(),
4059 );
4060 let with = report_for(
4061 &policy,
4062 ContainmentClass::Scoped,
4063 *tier,
4064 None,
4065 None,
4066 None,
4067 Some(&Exports {
4068 files: Some(export.clone()),
4069 peer_files: None,
4070 peer_tasks: None,
4071 }),
4072 Vec::new(),
4073 Vec::new(),
4074 Vec::new(),
4075 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4076 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4077 *tier,
4078 ContainmentClass::Scoped,
4079 )),
4080 IoMaxReport::default(),
4081 );
4082 assert_eq!(
4083 with.achieved_containment, without.achieved_containment,
4084 "tier {tier:?}, workdir_exec {workdir_exec}"
4085 );
4086 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4087 assert_eq!(with.floor_met, without.floor_met);
4088 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4089 assert_eq!(
4092 ScopeReport {
4093 exports_files: None,
4094 ..with.clone()
4095 },
4096 without
4097 );
4098 assert_eq!(
4099 with.exports_files,
4100 Some(ExportsFilesReport {
4101 root: "out/".to_string(),
4102 mode: ExportMode::ReadOnly,
4103 max_bytes: 10 * 1024 * 1024,
4104 })
4105 );
4106 }
4107 }
4108 }
4109
4110 #[test]
4114 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
4115 let peer_files = PeerFilesExport {
4116 root: "out/".to_string(),
4117 max_ttl_secs: Some(900),
4118 max_bytes: 10 * 1024 * 1024,
4119 };
4120 for tier in ALL_TIERS {
4121 for workdir_exec in [false, true] {
4122 let without_policy = CapabilityPolicy {
4123 workdir_exec_allowed: workdir_exec,
4124 ..sample_policy()
4125 };
4126 let with_policy = CapabilityPolicy {
4127 peer_fetch_allow: vec!["localhost:41234".to_string()],
4128 ..without_policy.clone()
4129 };
4130 let without = report_for(
4131 &without_policy,
4132 ContainmentClass::Scoped,
4133 *tier,
4134 None,
4135 None,
4136 None,
4137 None,
4138 Vec::new(),
4139 Vec::new(),
4140 Vec::new(),
4141 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4142 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4143 *tier,
4144 ContainmentClass::Scoped,
4145 )),
4146 IoMaxReport::default(),
4147 );
4148 let with = report_for(
4149 &with_policy,
4150 ContainmentClass::Scoped,
4151 *tier,
4152 None,
4153 None,
4154 None,
4155 Some(&Exports {
4156 files: None,
4157 peer_files: Some(peer_files.clone()),
4158 peer_tasks: None,
4159 }),
4160 Vec::new(),
4161 Vec::new(),
4162 Vec::new(),
4163 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4164 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4165 *tier,
4166 ContainmentClass::Scoped,
4167 )),
4168 IoMaxReport::default(),
4169 );
4170 assert_eq!(
4171 with.achieved_containment, without.achieved_containment,
4172 "tier {tier:?}, workdir_exec {workdir_exec}"
4173 );
4174 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4175 assert_eq!(with.floor_met, without.floor_met);
4176 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4177 assert_eq!(
4178 ScopeReport {
4179 peer_files: None,
4180 peer_fetch_allow: Vec::new(),
4181 ..with.clone()
4182 },
4183 without
4184 );
4185 assert_eq!(
4186 with.peer_files,
4187 Some(PeerFilesReport {
4188 root: "out/".to_string(),
4189 max_ttl_secs: 900,
4190 max_bytes: 10 * 1024 * 1024,
4191 })
4192 );
4193 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
4194 assert_eq!(with.network_allow, without.network_allow);
4198 }
4199 }
4200 }
4201
4202 #[test]
4206 fn the_peer_fields_are_always_serialized() {
4207 let undeclared: serde_json::Value = serde_json::to_value(report_for(
4208 &sample_policy(),
4209 ContainmentClass::Advisory,
4210 EnforcementTier::KernelSealed,
4211 None,
4212 None,
4213 None,
4214 None,
4215 Vec::new(),
4216 Vec::new(),
4217 Vec::new(),
4218 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4219 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4220 EnforcementTier::KernelSealed,
4221 ContainmentClass::Advisory,
4222 )),
4223 IoMaxReport::default(),
4224 ))
4225 .unwrap();
4226 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
4227 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
4228
4229 let declared: serde_json::Value = serde_json::to_value(report_for(
4230 &CapabilityPolicy {
4231 peer_fetch_allow: vec!["localhost:41234".to_string()],
4232 ..sample_policy()
4233 },
4234 ContainmentClass::Advisory,
4235 EnforcementTier::KernelSealed,
4236 None,
4237 None,
4238 None,
4239 Some(&Exports {
4240 files: None,
4241 peer_files: Some(PeerFilesExport {
4242 root: "out/".to_string(),
4243 max_ttl_secs: None,
4244 max_bytes: 10 * 1024 * 1024,
4245 }),
4246 peer_tasks: None,
4247 }),
4248 Vec::new(),
4249 Vec::new(),
4250 Vec::new(),
4251 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4252 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4253 EnforcementTier::KernelSealed,
4254 ContainmentClass::Advisory,
4255 )),
4256 IoMaxReport::default(),
4257 ))
4258 .unwrap();
4259 assert_eq!(
4260 declared["peer_files"],
4261 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
4262 );
4263 assert_eq!(
4264 declared["peer_fetch_allow"],
4265 serde_json::json!(["localhost:41234"])
4266 );
4267 }
4268
4269 #[test]
4270 fn render_names_peer_handoff_in_both_directions() {
4271 let undeclared = report_for(
4272 &sample_policy(),
4273 ContainmentClass::Advisory,
4274 EnforcementTier::KernelSealed,
4275 None,
4276 None,
4277 None,
4278 None,
4279 Vec::new(),
4280 Vec::new(),
4281 Vec::new(),
4282 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4283 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4284 EnforcementTier::KernelSealed,
4285 ContainmentClass::Advisory,
4286 )),
4287 IoMaxReport::default(),
4288 )
4289 .render();
4290 assert!(undeclared.contains("Peer handoff"));
4291 assert!(undeclared.contains("exports.peer_files: <none>"));
4292 assert!(undeclared.contains("peer_fetch allow: <none>"));
4293
4294 let declared = report_for(
4295 &CapabilityPolicy {
4296 peer_fetch_allow: vec!["localhost:41234".to_string()],
4297 ..sample_policy()
4298 },
4299 ContainmentClass::Advisory,
4300 EnforcementTier::KernelSealed,
4301 None,
4302 None,
4303 None,
4304 Some(&Exports {
4305 files: None,
4306 peer_files: Some(PeerFilesExport {
4307 root: "out/".to_string(),
4308 max_ttl_secs: Some(900),
4309 max_bytes: 4096,
4310 }),
4311 peer_tasks: None,
4312 }),
4313 Vec::new(),
4314 Vec::new(),
4315 Vec::new(),
4316 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4317 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4318 EnforcementTier::KernelSealed,
4319 ContainmentClass::Advisory,
4320 )),
4321 IoMaxReport::default(),
4322 )
4323 .render();
4324 assert!(declared.contains("exports.peer_files root: out/"));
4325 assert!(declared.contains("max handle ttl: 900s"));
4326 assert!(declared.contains("max bytes: 4096 (per file)"));
4327 assert!(declared.contains("- localhost:41234"));
4328 }
4329
4330 fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
4331 report_for(
4332 &sample_policy(),
4333 ContainmentClass::Scoped,
4334 tier,
4335 None,
4336 None,
4337 None,
4338 peer_tasks
4339 .map(|accept| Exports {
4340 files: None,
4341 peer_files: None,
4342 peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
4343 })
4344 .as_ref(),
4345 Vec::new(),
4346 Vec::new(),
4347 Vec::new(),
4348 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4349 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4350 tier,
4351 ContainmentClass::Scoped,
4352 )),
4353 IoMaxReport::default(),
4354 )
4355 }
4356
4357 #[test]
4360 fn peer_tasks_is_reported_and_moves_no_other_field() {
4361 for tier in ALL_TIERS {
4362 let absent = report_with_peer_tasks(*tier, None);
4363 let refused = report_with_peer_tasks(*tier, Some(false));
4364 let accepted = report_with_peer_tasks(*tier, Some(true));
4365 assert!(!absent.peer_tasks);
4366 assert_eq!(refused, absent, "tier {tier:?}");
4367 assert!(accepted.peer_tasks);
4368 assert_eq!(accepted.achieved_containment, absent.achieved_containment);
4369 assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
4370 assert_eq!(accepted.floor_met, absent.floor_met);
4371 assert_eq!(
4372 ScopeReport {
4373 peer_tasks: false,
4374 ..accepted.clone()
4375 },
4376 absent
4377 );
4378 }
4379 }
4380
4381 #[test]
4383 fn peer_tasks_is_always_serialized() {
4384 let tier = EnforcementTier::KernelFull;
4385 let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
4386 assert_eq!(absent["peer_tasks"], serde_json::json!(false));
4387 let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
4388 assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
4389 }
4390
4391 #[test]
4392 fn render_names_the_peer_task_posture_under_peer_handoff() {
4393 let tier = EnforcementTier::KernelFull;
4394 for (declared, line) in [
4395 (None, " exports.peer_tasks: refuse\n"),
4396 (Some(false), " exports.peer_tasks: refuse\n"),
4397 (Some(true), " exports.peer_tasks: accept\n"),
4398 ] {
4399 let rendered = report_with_peer_tasks(tier, declared).render();
4400 let section = rendered
4401 .split("\nPeer handoff\n")
4402 .nth(1)
4403 .and_then(|rest| rest.split("\n\n").next())
4404 .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
4405 assert!(
4406 section.contains(line.trim_start_matches(' ').trim_end()),
4407 "{declared:?}:\n{section}"
4408 );
4409 assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
4410 }
4411 }
4412
4413 #[test]
4417 fn a_state_store_is_reported_and_moves_no_other_field() {
4418 let policy = sample_policy();
4419 let stores = vec![StateStoreReport {
4420 artifact: "murmur/tool-corpus".to_string(),
4421 store: "shey".to_string(),
4422 host_path: "/home/dev/.murmur/state/shey".to_string(),
4423 }];
4424
4425 let without = report_for(
4426 &policy,
4427 ContainmentClass::Scoped,
4428 EnforcementTier::KernelFull,
4429 None,
4430 None,
4431 None,
4432 None,
4433 Vec::new(),
4434 Vec::new(),
4435 Vec::new(),
4436 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4437 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4438 EnforcementTier::KernelFull,
4439 ContainmentClass::Scoped,
4440 )),
4441 IoMaxReport::default(),
4442 );
4443 let with = report_for(
4444 &policy,
4445 ContainmentClass::Scoped,
4446 EnforcementTier::KernelFull,
4447 None,
4448 None,
4449 None,
4450 None,
4451 stores.clone(),
4452 Vec::new(),
4453 Vec::new(),
4454 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4455 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4456 EnforcementTier::KernelFull,
4457 ContainmentClass::Scoped,
4458 )),
4459 IoMaxReport::default(),
4460 );
4461
4462 assert_eq!(with.state_stores, stores);
4463 assert!(without.state_stores.is_empty());
4464 assert_eq!(with.achieved_containment, without.achieved_containment);
4465 assert_eq!(with.floor_met, without.floor_met);
4466 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4467 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4468 assert_eq!(
4470 ScopeReport {
4471 state_stores: Vec::new(),
4472 ..with.clone()
4473 },
4474 without
4475 );
4476
4477 assert!(with
4479 .render()
4480 .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
4481 assert!(without.render().contains("state stores: <none>"));
4482
4483 let json = serde_json::to_value(&without).unwrap();
4486 assert_eq!(json["state_stores"], serde_json::json!([]));
4487 assert_eq!(
4488 serde_json::to_value(&with).unwrap()["state_stores"],
4489 serde_json::json!([{
4490 "artifact": "murmur/tool-corpus",
4491 "store": "shey",
4492 "host_path": "/home/dev/.murmur/state/shey",
4493 }])
4494 );
4495 }
4496
4497 fn extra_roots_preopen(artifact: &str, role: &str, scope: Option<&str>) -> PreopenReport {
4498 PreopenReport {
4499 artifact: artifact.to_string(),
4500 role: role.to_string(),
4501 scope: scope.map(str::to_string),
4502 surface: if scope.is_some() {
4503 PreopenSurface::ScopedSubtree
4504 } else {
4505 PreopenSurface::WholeWorkdir
4506 },
4507 runtime_dir_reach: (role == "tool" && scope.is_none())
4508 .then_some(RuntimeDirReach::Unresolved),
4509 extra_roots: Vec::new(),
4510 }
4511 }
4512
4513 fn two_host_folders() -> Vec<crate::guest_roots::GuestRoot> {
4514 let host_path = |path: &str, access| crate::host_paths::HostPath {
4515 declared: path.to_string(),
4516 path: std::path::PathBuf::from(path),
4517 access,
4518 credential_locations: Vec::new(),
4519 };
4520 crate::guest_roots::extra_guest_roots_for(
4521 ContainmentClass::Advisory,
4522 &[
4523 host_path("/data/ref", crate::host_paths::HostPathAccess::Read),
4524 host_path("/data/out", crate::host_paths::HostPathAccess::ReadWrite),
4525 ],
4526 )
4527 }
4528
4529 #[test]
4531 fn extra_roots_serialize_with_four_keys_always_present() {
4532 let mut reports = vec![
4533 extra_roots_preopen("probe", "tool", None),
4534 extra_roots_preopen("cache-tool", "tool", Some("cache")),
4535 extra_roots_preopen("driver", "driver", None),
4536 ];
4537 attach_extra_roots(&mut reports, &two_host_folders());
4538 let json = serde_json::to_value(&reports).unwrap();
4539 assert_eq!(
4540 json[0]["extra_roots"],
4541 serde_json::json!([
4542 {
4543 "guest_path": "murmur-host/data/ref",
4544 "path": "/data/ref",
4545 "access": "read",
4546 "source": "host_paths",
4547 },
4548 {
4549 "guest_path": "murmur-host/data/out",
4550 "path": "/data/out",
4551 "access": "read-write",
4552 "source": "host_paths",
4553 },
4554 ])
4555 );
4556 assert_eq!(json[1]["extra_roots"], serde_json::json!([]));
4557 assert_eq!(json[2]["extra_roots"], serde_json::json!([]));
4558
4559 let mut reports = vec![extra_roots_preopen("probe", "tool", None)];
4560 attach_extra_roots(
4561 &mut reports,
4562 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4563 );
4564 assert_eq!(
4565 serde_json::to_value(&reports[0].extra_roots).unwrap(),
4566 serde_json::json!([{
4567 "guest_path": "murmur-host",
4568 "path": "/",
4569 "access": "read-write",
4570 "source": "unconfined",
4571 }])
4572 );
4573 }
4574
4575 #[test]
4578 fn extra_roots_clear_for_native_and_qualify_when_unresolved() {
4579 let mut reports = vec![
4580 extra_roots_preopen("wasm-tool", "tool", None),
4581 extra_roots_preopen("native-tool", "tool", None),
4582 extra_roots_preopen("unknown-tool", "tool", None),
4583 ];
4584 attach_extra_roots(&mut reports, &two_host_folders());
4585 resolve_runtime_dir_reaches(&mut reports, |name| match name {
4586 "wasm-tool" => Some(murmur_artifact::ArtifactImplementation::Wasm),
4587 "native-tool" => Some(murmur_artifact::ArtifactImplementation::Native),
4588 _ => None,
4589 });
4590 assert_eq!(reports[0].extra_roots.len(), 2);
4591 assert!(reports[1].extra_roots.is_empty());
4592 assert_eq!(reports[2].extra_roots.len(), 2);
4593
4594 assert!(
4595 reports[0].render(true).ends_with(
4596 "; runtime directory read-only at murmur-runtime/; host folders under \
4597 murmur-host/: /data/ref (read), /data/out (read-write)"
4598 ),
4599 "{}",
4600 reports[0].render(true)
4601 );
4602 assert!(!reports[1].render(true).contains("murmur-host"));
4603 assert!(
4604 reports[2].render(true).ends_with(
4605 "; host folders under murmur-host/ if WASM: /data/ref (read), /data/out \
4606 (read-write)"
4607 ),
4608 "{}",
4609 reports[2].render(true)
4610 );
4611
4612 let mut whole = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4613 attach_extra_roots(
4614 &mut whole,
4615 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4616 );
4617 let unresolved = whole[0].render(true);
4618 assert!(
4619 unresolved.ends_with("; whole host read-write under murmur-host/ if WASM"),
4620 "{unresolved}"
4621 );
4622 resolve_runtime_dir_reaches(&mut whole, |_| {
4623 Some(murmur_artifact::ArtifactImplementation::Wasm)
4624 });
4625 assert!(whole[0].render(true).ends_with(
4626 "; runtime directory read-only at murmur-runtime/; whole host read-write \
4627 under murmur-host/"
4628 ));
4629 }
4630
4631 #[test]
4633 fn extra_roots_absent_leave_the_line_unchanged() {
4634 let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4635 attach_extra_roots(&mut reports, &[]);
4636 assert_eq!(
4637 reports[0].render(true),
4638 "wasm-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4639 declared; runtime directory read-only at murmur-runtime/ if WASM, at \
4640 $MURMUR_RUNTIME_DIR if native"
4641 );
4642 }
4643
4644 #[test]
4649 fn preopens_are_reported_and_move_no_other_field() {
4650 let policy = sample_policy();
4651 let preopens = vec![
4652 PreopenReport {
4653 artifact: "notes-tool".to_string(),
4654 role: "tool".to_string(),
4655 scope: None,
4656 surface: PreopenSurface::WholeWorkdir,
4657 runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
4658 extra_roots: Vec::new(),
4659 },
4660 PreopenReport {
4661 artifact: "cache-tool".to_string(),
4662 role: "tool".to_string(),
4663 scope: Some("cache".to_string()),
4664 surface: PreopenSurface::ScopedSubtree,
4665 runtime_dir_reach: None,
4666 extra_roots: Vec::new(),
4667 },
4668 PreopenReport {
4669 artifact: "telemetry-hook".to_string(),
4670 role: "hook".to_string(),
4671 scope: None,
4672 surface: PreopenSurface::Nothing,
4673 runtime_dir_reach: None,
4674 extra_roots: Vec::new(),
4675 },
4676 ];
4677
4678 let without = report_for(
4679 &policy,
4680 ContainmentClass::Scoped,
4681 EnforcementTier::KernelFull,
4682 None,
4683 None,
4684 None,
4685 None,
4686 Vec::new(),
4687 Vec::new(),
4688 Vec::new(),
4689 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4690 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4691 EnforcementTier::KernelFull,
4692 ContainmentClass::Scoped,
4693 )),
4694 IoMaxReport::default(),
4695 );
4696 let with = report_for(
4697 &policy,
4698 ContainmentClass::Scoped,
4699 EnforcementTier::KernelFull,
4700 None,
4701 None,
4702 None,
4703 None,
4704 Vec::new(),
4705 Vec::new(),
4706 preopens.clone(),
4707 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4708 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4709 EnforcementTier::KernelFull,
4710 ContainmentClass::Scoped,
4711 )),
4712 IoMaxReport::default(),
4713 );
4714
4715 assert_eq!(with.preopens, preopens);
4716 assert!(without.preopens.is_empty());
4717 assert_eq!(
4720 ScopeReport {
4721 preopens: Vec::new(),
4722 routes: without.routes.clone(),
4723 ..with.clone()
4724 },
4725 without
4726 );
4727
4728 let rendered = with.render();
4730 assert!(rendered.contains(
4731 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4732 declared; runtime directory read-only at murmur-runtime/"
4733 ));
4734 assert!(rendered.contains(
4735 "- cache-tool (tool): one subtree of the accessible workdir — \
4736 capabilities.filesystem.scope: cache"
4737 ));
4738 assert!(rendered.contains(
4739 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
4740 ));
4741 assert!(without.render().contains("preopens: <none>"));
4742
4743 assert_eq!(
4745 serde_json::to_value(&without).unwrap()["preopens"],
4746 serde_json::json!([])
4747 );
4748 assert_eq!(
4749 serde_json::to_value(&with).unwrap()["preopens"],
4750 serde_json::json!([
4751 {
4752 "artifact": "notes-tool",
4753 "role": "tool",
4754 "scope": null,
4755 "surface": "whole-workdir",
4756 "runtime_dir_reach": "murmur-runtime",
4757 "extra_roots": [],
4758 },
4759 {
4760 "artifact": "cache-tool",
4761 "role": "tool",
4762 "scope": "cache",
4763 "surface": "scoped-subtree",
4764 "runtime_dir_reach": null,
4765 "extra_roots": [],
4766 },
4767 {
4768 "artifact": "telemetry-hook",
4769 "role": "hook",
4770 "scope": null,
4771 "surface": "nothing",
4772 "runtime_dir_reach": null,
4773 "extra_roots": [],
4774 },
4775 ])
4776 );
4777 }
4778
4779 #[test]
4783 fn exports_files_is_always_serialized() {
4784 let undeclared = serde_json::to_value(report_for(
4785 &sample_policy(),
4786 ContainmentClass::Advisory,
4787 EnforcementTier::KernelFull,
4788 None,
4789 None,
4790 None,
4791 None,
4792 Vec::new(),
4793 Vec::new(),
4794 Vec::new(),
4795 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4796 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4797 EnforcementTier::KernelFull,
4798 ContainmentClass::Advisory,
4799 )),
4800 IoMaxReport::default(),
4801 ))
4802 .unwrap();
4803 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
4804
4805 let declared = serde_json::to_value(report_for(
4806 &sample_policy(),
4807 ContainmentClass::Advisory,
4808 EnforcementTier::KernelFull,
4809 None,
4810 None,
4811 None,
4812 Some(&Exports {
4813 files: Some(FileExport {
4814 root: "out/".to_string(),
4815 mode: ExportMode::ReadOnly,
4816 max_bytes: 10_485_760,
4817 }),
4818 peer_files: None,
4819 peer_tasks: None,
4820 }),
4821 Vec::new(),
4822 Vec::new(),
4823 Vec::new(),
4824 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4825 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4826 EnforcementTier::KernelFull,
4827 ContainmentClass::Advisory,
4828 )),
4829 IoMaxReport::default(),
4830 ))
4831 .unwrap();
4832 assert_eq!(
4833 declared["exports_files"],
4834 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
4835 );
4836 }
4837
4838 #[test]
4839 fn render_names_the_resource_plane_in_both_directions() {
4840 let undeclared = report_for(
4841 &sample_policy(),
4842 ContainmentClass::Advisory,
4843 EnforcementTier::KernelFull,
4844 None,
4845 None,
4846 None,
4847 None,
4848 Vec::new(),
4849 Vec::new(),
4850 Vec::new(),
4851 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4852 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4853 EnforcementTier::KernelFull,
4854 ContainmentClass::Advisory,
4855 )),
4856 IoMaxReport::default(),
4857 )
4858 .render();
4859 assert!(
4860 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
4861 "{undeclared}"
4862 );
4863
4864 let declared = report_for(
4865 &sample_policy(),
4866 ContainmentClass::Advisory,
4867 EnforcementTier::KernelFull,
4868 None,
4869 None,
4870 None,
4871 Some(&Exports {
4872 files: Some(FileExport {
4873 root: "out/".to_string(),
4874 mode: ExportMode::ReadOnly,
4875 max_bytes: 10_485_760,
4876 }),
4877 peer_files: None,
4878 peer_tasks: None,
4879 }),
4880 Vec::new(),
4881 Vec::new(),
4882 Vec::new(),
4883 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4884 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4885 EnforcementTier::KernelFull,
4886 ContainmentClass::Advisory,
4887 )),
4888 IoMaxReport::default(),
4889 )
4890 .render();
4891 assert!(declared.contains("exports.files root: out/"), "{declared}");
4892 assert!(
4893 declared.contains("mode: read-only"),
4894 "{declared}"
4895 );
4896 assert!(
4897 declared.contains("max bytes: 10485760 (per file)"),
4898 "{declared}"
4899 );
4900 }
4901
4902 fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
4905 let policy = CapabilityPolicy {
4906 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
4907 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
4908 ..CapabilityPolicy::default()
4909 };
4910 report_for(
4911 &policy,
4912 ContainmentClass::Scoped,
4913 EnforcementTier::KernelFull,
4914 None,
4915 None,
4916 None,
4917 None,
4918 Vec::new(),
4919 Vec::new(),
4920 Vec::new(),
4921 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4922 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4923 EnforcementTier::KernelFull,
4924 ContainmentClass::Scoped,
4925 )),
4926 IoMaxReport::default(),
4927 )
4928 }
4929
4930 #[test]
4933 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
4934 let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
4935 assert_eq!(
4936 report.read_only_paths,
4937 vec!["tests".to_string(), "bench/fixtures".to_string()]
4938 );
4939 assert!(report.read_only_advisory_for.is_empty());
4940
4941 let rendered = report.render();
4942 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
4943 assert!(rendered.contains(
4944 "read_only enforcement: enforced for every tool call and every shell command the \
4945 dispatch check can read"
4946 ));
4947 assert!(!rendered.contains("advisory against"));
4948 }
4949
4950 #[test]
4953 fn an_allowlisted_interpreter_is_named_as_advisory() {
4954 let report = read_only_report(&["tests"], &["git", "python3"]);
4955 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
4956
4957 let rendered = report.render();
4958 assert!(rendered.contains("read_only enforcement: advisory against python3"));
4959 assert!(rendered
4960 .contains("enforced for every tool call and for every shell command it can read"));
4961 }
4962
4963 #[test]
4967 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
4968 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
4969 .iter()
4970 .map(|b| (*b).to_string())
4971 .collect();
4972 let declared = vec!["tests".to_string()];
4973 assert_eq!(
4974 read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
4975 .read_only_advisory_for,
4976 read_only_advisory_for(&declared, &shell_allow)
4977 );
4978 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
4980 }
4981
4982 #[test]
4986 fn both_read_only_keys_are_always_serialized_arrays() {
4987 let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
4988 assert_eq!(json["read_only_paths"], serde_json::json!([]));
4989 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
4990
4991 let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
4992 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
4993 assert_eq!(
4994 json["read_only_advisory_for"],
4995 serde_json::json!(["python3"])
4996 );
4997 }
4998
4999 #[test]
5002 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
5003 let rendered = read_only_report(&[], &["python3"]).render();
5004 assert!(rendered.contains(" read_only: <none>\n"));
5005 assert!(!rendered.contains("read_only enforcement:"));
5006 }
5007
5008 #[test]
5020 fn every_capability_policy_field_is_reported_or_exempted() {
5021 const EXEMPT: &[(&str, &str)] = &[
5023 (
5024 "shell_strip_env",
5025 "Names host variables removed from the shell child's environment on top of the \
5026 credential patterns the runtime always strips. It subtracts from what the \
5027 capsule sees and can open nothing, so reporting it beside the grants would pad \
5028 the list an operator audits for reach.",
5029 ),
5030 (
5031 "shell_baseline_env",
5032 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
5033 environment of a subprocess the capsule was already granted, and names no host \
5034 resource `shell allow` and `env allow` do not already account for.",
5035 ),
5036 (
5037 "limits",
5038 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
5039 deadline. A bound on how much the capsule may consume, never a reach it gains.",
5040 ),
5041 (
5042 "resources",
5043 "The same, for native subprocesses: rlimits, the Linux cgroup v2 scope and the \
5044 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
5045 ),
5046 (
5047 "declared_deadline_seconds",
5048 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
5049 can apply a lower default. Reporting it would report one ceiling twice.",
5050 ),
5051 (
5052 "state_declared",
5053 "Records that a capsule-wide `capabilities.state` block was declared where \
5054 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
5055 exist are reported per artifact in `state_stores`.",
5056 ),
5057 (
5058 "conversation_declared",
5059 "Records a capsule-wide `capabilities.conversation` block that reaches no \
5060 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
5061 and the grant itself is per artifact.",
5062 ),
5063 ];
5064
5065 const REPORTED: &[(&str, &str)] = &[
5067 ("network_allow", "network_allow"),
5068 ("peer_fetch_allow", "peer_fetch_allow"),
5069 ("unix_sockets_allowed", "unix_sockets"),
5070 ("filesystem_scope", "filesystem_scope"),
5071 ("workdir_exec_allowed", "workdir_exec"),
5072 ("read_only_paths", "read_only_paths"),
5073 ("shell_allow", "shell_allow"),
5074 ("spawn_allow", "spawn_allow"),
5075 ("spawn_workdir_snapshot", "spawn_workdir_snapshot"),
5076 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
5077 (
5078 "shell_derived_interpreter_runtime",
5079 "derived_interpreter_runtime",
5080 ),
5081 ("shell_trust_store", "trust_store"),
5082 ("host_paths", "host_paths"),
5083 ("shell_staged_runtime", "staged_runtime_grants"),
5084 ("env_allow", "env_allow"),
5085 ("plan_submit", "plan_submit"),
5086 ("install_skill", "install_skill"),
5087 ("install_tool", "install_tool"),
5088 ("install_from_sources", "install_from"),
5089 ("containment_floor", "declared_containment"),
5090 ("declared_resources", "unconfined"),
5091 ];
5092
5093 let declared = capability_policy_field_names(include_str!("types.rs"));
5094 assert!(
5095 declared.len() > 10,
5096 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
5097 );
5098
5099 for field in &declared {
5100 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
5101 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
5102 assert!(
5103 reported || exempt,
5104 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
5105 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
5106 with a written reason for why an operator does not need to see it."
5107 );
5108 assert!(
5109 !(reported && exempt),
5110 "CapabilityPolicy::{field} is listed as both reported and exempt"
5111 );
5112 }
5113
5114 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
5115 assert!(
5116 declared.iter().any(|field| field == policy_field),
5117 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
5118 declares — remove the stale row"
5119 );
5120 }
5121
5122 let json = serde_json::to_value(report_for(
5125 &sample_policy(),
5126 ContainmentClass::Scoped,
5127 EnforcementTier::KernelFull,
5128 None,
5129 None,
5130 None,
5131 None,
5132 Vec::new(),
5133 Vec::new(),
5134 Vec::new(),
5135 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5136 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5137 EnforcementTier::KernelFull,
5138 ContainmentClass::Scoped,
5139 )),
5140 IoMaxReport::default(),
5141 ))
5142 .unwrap();
5143 let object = json.as_object().expect("a scope report is a JSON object");
5144 for (policy_field, report_key) in REPORTED {
5145 assert!(
5146 object.contains_key(*report_key),
5147 "the table maps CapabilityPolicy::{policy_field} to a report key \
5148 '{report_key}' that a serialized ScopeReport does not carry"
5149 );
5150 }
5151 }
5152
5153 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
5156 report_for(
5157 &sample_policy(),
5158 declared,
5159 tier,
5160 None,
5161 None,
5162 Some(6),
5165 None,
5166 Vec::new(),
5167 Vec::new(),
5168 Vec::new(),
5169 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5170 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5171 IoMaxReport::default(),
5172 )
5173 }
5174
5175 fn sample_snapshot() -> crate::workdir_snapshot::WorkdirSnapshot {
5176 crate::workdir_snapshot::WorkdirSnapshot {
5177 source: "/home/you/project".to_string(),
5178 files: 12,
5179 dirs: 3,
5180 symlinks: 1,
5181 repointed_symlinks: 0,
5182 bytes: 40960,
5183 skipped_count: 1,
5184 skipped: vec![crate::workdir_snapshot::SkippedEntry {
5185 path: "run/pipe".to_string(),
5186 reason: crate::workdir_snapshot::SkipReason::Fifo,
5187 }],
5188 }
5189 }
5190
5191 #[test]
5194 fn spawn_workdir_snapshot_and_parent_workdir_are_always_reported() {
5195 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5196 let json = serde_json::to_value(&report).unwrap();
5197 assert_eq!(json["spawn_workdir_snapshot"], serde_json::json!([]));
5198 assert!(json.as_object().unwrap().contains_key("parent_workdir"));
5199 assert_eq!(json["parent_workdir"], serde_json::Value::Null);
5200 let rendered = report.render();
5201 let snapshot_line = rendered
5202 .find("\n spawn workdir snapshot: <none>\n")
5203 .unwrap_or_else(|| panic!("{rendered}"));
5204 assert!(
5205 rendered.find("\n spawn allow:").unwrap() < snapshot_line,
5206 "{rendered}"
5207 );
5208
5209 assert!(!rendered.contains("Parent work folder"), "{rendered}");
5210
5211 let mut policy = sample_policy();
5212 policy.spawn_allow = vec!["local/reviewer".to_string(), "local/worker".to_string()];
5213 policy.spawn_workdir_snapshot = vec!["local/reviewer".to_string()];
5214 let report = report_for(
5215 &policy,
5216 ContainmentClass::Scoped,
5217 EnforcementTier::KernelFull,
5218 None,
5219 None,
5220 Some(6),
5221 None,
5222 Vec::new(),
5223 Vec::new(),
5224 Vec::new(),
5225 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5226 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5227 EnforcementTier::KernelFull,
5228 ContainmentClass::Scoped,
5229 )),
5230 IoMaxReport::default(),
5231 );
5232 assert_eq!(report.spawn_workdir_snapshot, vec!["local/reviewer"]);
5233 assert_eq!(
5234 serde_json::to_value(&report).unwrap()["spawn_workdir_snapshot"],
5235 serde_json::json!(["local/reviewer"])
5236 );
5237 assert!(
5238 report
5239 .render()
5240 .contains(" spawn workdir snapshot:\n - local/reviewer\n"),
5241 "{}",
5242 report.render()
5243 );
5244 }
5245
5246 #[test]
5249 fn parent_workdir_restriction_follows_the_applied_tier() {
5250 for (declared, tier, restriction) in [
5251 (
5252 ContainmentClass::Sealed,
5253 EnforcementTier::KernelSealed,
5254 "absent",
5255 ),
5256 (
5257 ContainmentClass::Scoped,
5258 EnforcementTier::KernelFull,
5259 "enforced",
5260 ),
5261 (
5262 ContainmentClass::Advisory,
5263 EnforcementTier::KernelSeccompOnly,
5264 "advisory",
5265 ),
5266 (
5267 ContainmentClass::Advisory,
5268 EnforcementTier::EnvironmentOnly,
5269 "advisory",
5270 ),
5271 ] {
5272 let report =
5273 boundary_report(declared, tier).with_parent_workdir(Some(sample_snapshot()));
5274 let json = serde_json::to_value(&report).unwrap();
5275 let parent = &json["parent_workdir"];
5276 assert_eq!(parent["restriction"], restriction, "{tier:?}");
5277 assert_eq!(
5278 parent["restriction"],
5279 json["filesystem_boundary"]["restriction"]
5280 );
5281 assert_eq!(parent["source"], "/home/you/project");
5282 assert_eq!(parent["files"], 12);
5283 assert_eq!(parent["skipped"][0]["reason"], "fifo");
5284
5285 let rendered = report.render();
5286 assert!(
5287 rendered.contains(
5288 "\nParent work folder\n source: /home/you/project\n copied: \
5289 12 files, 3 directories, 1 symlinks (0 re-pointed), 40960 bytes\n \
5290 skipped: 1\n - run/pipe (fifo)\n restriction: "
5291 ),
5292 "{rendered}"
5293 );
5294 assert!(
5295 rendered.contains(&format!(" restriction: {restriction}: ")),
5296 "{rendered}"
5297 );
5298 }
5299 assert_eq!(
5300 boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull)
5301 .with_parent_workdir(None)
5302 .parent_workdir,
5303 None
5304 );
5305 }
5306
5307 fn presence_cases() -> Vec<(&'static str, RouteInputs, Vec<Route>)> {
5309 use murmur_artifact::ArtifactImplementation::{Native, Wasm};
5310 let tool = |implementation| RouteInputs {
5311 tools: vec![("t".to_string(), implementation)],
5312 ..RouteInputs::default()
5313 };
5314 vec![
5315 ("nothing", RouteInputs::default(), vec![]),
5316 ("one WASM tool", tool(Some(Wasm)), vec![Route::WasmTools]),
5317 (
5318 "one native tool",
5319 tool(Some(Native)),
5320 vec![Route::NativeTools],
5321 ),
5322 (
5323 "a tool of unknown implementation",
5324 tool(None),
5325 vec![Route::WasmTools],
5326 ),
5327 (
5328 "a driver",
5329 RouteInputs {
5330 drivers: vec!["d".to_string()],
5331 ..RouteInputs::default()
5332 },
5333 vec![Route::Drivers],
5334 ),
5335 (
5336 "a hook",
5337 RouteInputs {
5338 hooks: vec!["h".to_string()],
5339 ..RouteInputs::default()
5340 },
5341 vec![Route::Hooks],
5342 ),
5343 (
5344 "install.tool",
5345 RouteInputs {
5346 install_tool: true,
5347 ..RouteInputs::default()
5348 },
5349 vec![Route::NativeTools, Route::WasmTools],
5350 ),
5351 (
5352 "transport: process",
5353 RouteInputs {
5354 process_transport: true,
5355 ..RouteInputs::default()
5356 },
5357 vec![Route::ProcessHarness, Route::HarnessTools],
5358 ),
5359 (
5360 "plan.submit without shell.allow",
5361 RouteInputs {
5362 plan_submit: true,
5363 ..RouteInputs::default()
5364 },
5365 vec![],
5366 ),
5367 (
5368 "plan.submit with shell.allow",
5369 RouteInputs {
5370 plan_submit: true,
5371 shell_allow: true,
5372 ..RouteInputs::default()
5373 },
5374 vec![Route::Shell, Route::PlanShellSteps],
5375 ),
5376 (
5377 "shell.allow alone",
5378 RouteInputs {
5379 shell_allow: true,
5380 ..RouteInputs::default()
5381 },
5382 vec![Route::Shell],
5383 ),
5384 ]
5385 }
5386
5387 #[test]
5391 fn the_route_table_holds_its_invariants_on_every_input() {
5392 for tier in ALL_TIERS {
5393 for declared in [
5394 ContainmentClass::Advisory,
5395 ContainmentClass::Scoped,
5396 ContainmentClass::Sealed,
5397 ] {
5398 let mechanism =
5399 ContainmentDecision::decide(*tier, declared, false, false).enforcement_tier();
5400 for (case, inputs, present) in presence_cases() {
5401 let rows = route_table(&inputs, mechanism);
5402 assert_eq!(
5403 rows.iter().map(|row| row.route).collect::<Vec<_>>(),
5404 Route::ALL.to_vec(),
5405 "{case}"
5406 );
5407 for row in &rows {
5408 let covered = matches!(
5409 row.route,
5410 Route::Shell | Route::PlanShellSteps | Route::NativeTools
5411 );
5412 assert_eq!(row.under_class, covered, "{case}: {row:?}");
5413 if covered {
5414 assert_eq!(row.mechanism, mechanism, "{case}: {row:?}");
5415 assert_eq!(row.note, None, "{case}: {row:?}");
5416 } else {
5417 assert_eq!(row.mechanism, HOST_PROCESS_MECHANISM, "{case}: {row:?}");
5418 assert!(row.note.is_some(), "{case}: {row:?}");
5419 }
5420 assert_eq!(
5421 row.present,
5422 present.contains(&row.route),
5423 "{case} on {tier:?}/{declared}: {row:?}"
5424 );
5425 }
5426 }
5427 }
5428 }
5429 }
5430
5431 #[test]
5433 fn each_uncovered_route_names_what_holds_it() {
5434 let notes = |route: Route| route.note().unwrap();
5435 for route in [Route::WasmTools, Route::Drivers] {
5436 assert!(notes(route).contains("inside the mur process"), "{route:?}");
5437 assert!(notes(route).contains("WASI preopens"), "{route:?}");
5438 assert!(
5439 notes(route).contains("host-side network allowlist only"),
5440 "{route:?}"
5441 );
5442 }
5443 assert!(notes(Route::Hooks).contains("its own capabilities block declares"));
5444 let harness = notes(Route::ProcessHarness);
5445 for word in [
5446 "cleared environment",
5447 "Landlock",
5448 "seccomp",
5449 "network namespace",
5450 "rlimits",
5451 "cgroup with no limits",
5452 ] {
5453 assert!(harness.contains(word), "{word}: {harness}");
5454 }
5455 assert!(notes(Route::HarnessTools).contains("the user's authority"));
5456 assert!(notes(Route::HarnessTools).contains("arguments the process driver passes"));
5457 }
5458
5459 #[test]
5462 fn route_inputs_read_roles_and_implementations_off_the_preopens() {
5463 let preopen = |artifact: &str, role: &str| PreopenReport {
5464 artifact: artifact.to_string(),
5465 role: role.to_string(),
5466 scope: None,
5467 surface: PreopenSurface::WholeWorkdir,
5468 runtime_dir_reach: None,
5469 extra_roots: Vec::new(),
5470 };
5471 let preopens = vec![
5472 preopen("fs-tool", "tool"),
5473 preopen("browser", "tool"),
5474 preopen("driver-anthropic", "driver"),
5475 preopen("audit", "hook"),
5476 ];
5477 let inputs = RouteInputs::new(
5478 &[],
5479 false,
5480 &[],
5481 &preopens,
5482 |name| (name == "browser").then_some(murmur_artifact::ArtifactImplementation::Native),
5483 Some("http"),
5484 );
5485 let rows = route_table(&inputs, "landlock+seccomp");
5486 let row = |route: Route| rows.iter().find(|row| row.route == route).unwrap();
5487 assert_eq!(row(Route::WasmTools).artifacts, vec!["fs-tool"]);
5488 assert_eq!(row(Route::NativeTools).artifacts, vec!["browser"]);
5489 assert_eq!(row(Route::Drivers).artifacts, vec!["driver-anthropic"]);
5490 assert_eq!(row(Route::Hooks).artifacts, vec!["audit"]);
5491 assert!(!row(Route::ProcessHarness).present);
5492 assert!(
5493 RouteInputs::new(&[], false, &[], &[], |_| None, Some("process")).process_transport
5494 );
5495 }
5496
5497 #[test]
5500 fn the_routes_json_is_pinned() {
5501 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
5502 .with_routes(
5503 |_| Some(murmur_artifact::ArtifactImplementation::Wasm),
5504 Some("process"),
5505 );
5506 let json = serde_json::to_value(&report).unwrap();
5507 assert!(json.get("mechanism_covers").is_none(), "{json}");
5508 let routes = json["routes"].as_array().expect("routes is an array");
5509 assert_eq!(routes.len(), 8);
5510 let shell_present = !report.shell_allow.is_empty();
5511 assert_eq!(
5512 routes[0],
5513 serde_json::json!({
5514 "route": "shell",
5515 "present": shell_present,
5516 "under_class": true,
5517 "mechanism": "landlock+seccomp",
5518 "note": null,
5519 "artifacts": [],
5520 })
5521 );
5522 assert_eq!(
5523 routes[6],
5524 serde_json::json!({
5525 "route": "process-harness",
5526 "present": true,
5527 "under_class": false,
5528 "mechanism": "host-process",
5529 "note": Route::ProcessHarness.note(),
5530 "artifacts": [],
5531 })
5532 );
5533 assert_eq!(
5534 routes
5535 .iter()
5536 .map(|row| row["route"].as_str().unwrap())
5537 .collect::<Vec<_>>(),
5538 [
5539 "shell",
5540 "plan-shell-steps",
5541 "native-tools",
5542 "wasm-tools",
5543 "drivers",
5544 "hooks",
5545 "process-harness",
5546 "harness-tools"
5547 ]
5548 );
5549 assert_eq!(
5550 json["cgroup_scope"],
5551 serde_json::json!({"status": "not-probed"})
5552 );
5553 }
5554
5555 #[test]
5557 fn the_routes_block_follows_the_containment_block() {
5558 let preopens = vec![PreopenReport {
5559 artifact: "fs-tool".to_string(),
5560 role: "tool".to_string(),
5561 scope: None,
5562 surface: PreopenSurface::WholeWorkdir,
5563 runtime_dir_reach: None,
5564 extra_roots: Vec::new(),
5565 }];
5566 let report = report_for(
5567 &sample_policy(),
5568 ContainmentClass::Advisory,
5569 EnforcementTier::KernelSealed,
5570 None,
5571 None,
5572 Some(6),
5573 None,
5574 Vec::new(),
5575 Vec::new(),
5576 preopens,
5577 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5578 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
5579 IoMaxReport::default(),
5580 )
5581 .with_routes(|_| None, None);
5582 let rendered = report.render();
5583 assert!(!rendered.contains("covers:"), "{rendered}");
5584 let routes = rendered.find("\nRoutes\n").expect("a Routes block");
5585 assert!(
5586 routes > rendered.find(" mechanism: ").unwrap(),
5587 "{rendered}"
5588 );
5589 assert!(
5590 routes < rendered.find("\nRuntime directory\n").unwrap(),
5591 "{rendered}"
5592 );
5593 assert!(
5594 rendered.contains(" shell commands present landlock+seccomp\n"),
5595 "{rendered}"
5596 );
5597 assert!(
5598 rendered.contains(
5599 " WASM tools present host process, not confined by the class (fs-tool)\n \
5600 Runs inside the mur process"
5601 ),
5602 "{rendered}"
5603 );
5604 assert!(
5605 rendered
5606 .contains(" process harness absent host process, not confined by the class\n"),
5607 "{rendered}"
5608 );
5609 assert!(
5610 rendered.contains(" cgroup scope: not-probed\n"),
5611 "{rendered}"
5612 );
5613 }
5614
5615 #[test]
5618 fn the_uncovered_route_warning_names_exactly_the_present_uncovered_routes() {
5619 let inputs = RouteInputs {
5620 shell_allow: true,
5621 tools: vec![("fs-tool".to_string(), None)],
5622 drivers: vec!["driver-anthropic".to_string()],
5623 ..RouteInputs::default()
5624 };
5625 let routes = route_table(&inputs, "landlock+seccomp");
5626 assert_eq!(
5627 uncovered_route_warning(ContainmentClass::Advisory, &routes),
5628 None
5629 );
5630
5631 let covered_only = route_table(
5632 &RouteInputs {
5633 shell_allow: true,
5634 plan_submit: true,
5635 tools: vec![(
5636 "browser".to_string(),
5637 Some(murmur_artifact::ArtifactImplementation::Native),
5638 )],
5639 ..RouteInputs::default()
5640 },
5641 "landlock+seccomp",
5642 );
5643 assert_eq!(
5644 uncovered_route_warning(ContainmentClass::Scoped, &covered_only),
5645 None
5646 );
5647
5648 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5649 let message = uncovered_route_warning(declared, &routes).expect("fires");
5650 assert!(
5651 message.contains(&format!("the declared {declared} floor")),
5652 "{message}"
5653 );
5654 assert!(
5655 message.contains(
5656 "WASM tools (fs-tool) and drivers (driver-anthropic) run outside the class"
5657 ),
5658 "{message}"
5659 );
5660 assert!(message.contains("nothing is refused"), "{message}");
5661 for absent in ["hooks", "process harness", "harness tools"] {
5662 assert!(!message.contains(absent), "{absent}: {message}");
5663 }
5664 }
5665
5666 let process = route_table(
5667 &RouteInputs {
5668 process_transport: true,
5669 drivers: vec!["driver-claude-code".to_string()],
5670 ..RouteInputs::default()
5671 },
5672 "landlock+seccomp",
5673 );
5674 let message = uncovered_route_warning(ContainmentClass::Scoped, &process).unwrap();
5675 assert!(
5676 message.contains(
5677 "drivers (driver-claude-code), the process harness and harness tools run"
5678 ),
5679 "{message}"
5680 );
5681 }
5682
5683 #[test]
5685 fn the_uncovered_route_warning_reaches_the_bootstrap_log() {
5686 let temp = tempfile::tempdir().unwrap();
5687 let routes = route_table(
5688 &RouteInputs {
5689 drivers: vec!["d".to_string()],
5690 ..RouteInputs::default()
5691 },
5692 "landlock+seccomp",
5693 );
5694 warn_on_uncovered_routes(
5695 ContainmentClass::Sealed,
5696 &routes,
5697 Some(&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap()),
5698 );
5699 let log = std::fs::read_to_string(temp.path().join("logs").join("bootstrap.log"))
5700 .unwrap_or_default();
5701 assert_eq!(log.matches("warning[W-SEC-034]").count(), 1, "{log}");
5702 assert!(log.contains("drivers (d)"), "{log}");
5703 assert!(log.contains("#w-sec-034"), "{log}");
5704 }
5705
5706 #[test]
5709 fn an_advisory_session_says_home_is_relocated_not_protected() {
5710 for tier in [
5711 EnforcementTier::EnvironmentOnly,
5712 EnforcementTier::KernelSeccompOnly,
5713 ] {
5714 let boundary = filesystem_boundary(tier);
5715 let home = boundary
5716 .not_protected
5717 .iter()
5718 .find(|statement| statement.contains("relocated, not protected"))
5719 .unwrap_or_else(|| panic!("{tier:?}: {boundary:?}"));
5720 assert!(home.contains("session's home folder"), "{home}");
5721 assert!(
5722 home.contains("real home directory stays readable and writable by absolute path"),
5723 "{home}"
5724 );
5725 assert!(home.contains("no kernel mechanism"), "{home}");
5726 }
5727 assert!(!filesystem_boundary(EnforcementTier::KernelFull)
5728 .not_protected
5729 .iter()
5730 .any(|statement| statement.contains("relocated, not protected")));
5731 assert!(filesystem_boundary(EnforcementTier::KernelSealed)
5732 .not_protected
5733 .is_empty());
5734 }
5735
5736 #[test]
5738 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
5739 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
5740
5741 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5742 assert_eq!(
5743 report.filesystem_boundary.restriction,
5744 FilesystemRestriction::Advisory
5745 );
5746 assert_eq!(report.filesystem_boundary.not_protected.len(), 5);
5749 assert_eq!(
5750 report.filesystem_boundary.not_protected[2],
5751 ADVISORY_HOME_NOT_PROTECTED
5752 );
5753 assert_eq!(
5754 Some(&report.filesystem_boundary.not_protected[3]),
5755 report.runtime_dir.not_protected_because.as_ref()
5756 );
5757 assert_eq!(
5758 Some(&report.filesystem_boundary.not_protected[4]),
5759 report.records_dir.not_protected_because.as_ref()
5760 );
5761 assert!(report.filesystem_boundary.not_protected[4].contains("the records directory"));
5762
5763 let filesystem = &report.filesystem_boundary.not_protected[0];
5764 assert!(filesystem.contains("advisory"), "{filesystem}");
5765 assert!(
5766 filesystem.contains("denies a read or a write outside the work folder"),
5767 "{filesystem}"
5768 );
5769
5770 let rewriting = &report.filesystem_boundary.not_protected[1];
5771 assert!(rewriting.contains("not a boundary"), "{rewriting}");
5772 assert!(rewriting.contains("HOME"), "{rewriting}");
5773
5774 let rendered = report.render();
5775 let heading = rendered
5776 .find("\nNot protected here\n")
5777 .expect("a session that composes no root prints the block");
5778 assert!(
5779 heading > rendered.find("Containment\n").unwrap(),
5780 "the block belongs under Containment:\n{rendered}"
5781 );
5782 assert!(
5783 heading < rendered.find("\nEffective grants\n").unwrap(),
5784 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
5785 );
5786 for statement in &report.filesystem_boundary.not_protected {
5787 assert!(rendered.contains(statement.as_str()), "{rendered}");
5788 }
5789 }
5790
5791 fn inside_placement() -> RuntimeDirPlacement {
5792 RuntimeDirPlacement {
5793 sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
5794 records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
5795 workdir: std::path::PathBuf::from("/home/dev"),
5796 sessions_inside_workdir: true,
5797 records_inside_workdir: true,
5798 }
5799 }
5800
5801 #[test]
5803 fn runtime_dir_report_makes_one_claim_per_tier() {
5804 let outside = RuntimeDirPlacement::outside_for_test();
5805 for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
5806 let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
5807 assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
5808 assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
5809 assert_eq!(sealed.not_protected_because, None);
5810 }
5811
5812 let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
5813 assert!(full.read_only_to_commands);
5814 assert_eq!(full.enforced_by, Some("landlock"));
5815 assert_eq!(full.not_protected_because, None);
5816
5817 for abi in [Some(1), Some(2)] {
5818 let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
5819 assert!(!old.read_only_to_commands);
5820 assert_eq!(old.enforced_by, None);
5821 let reason = old.not_protected_because.unwrap();
5822 assert!(
5823 reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
5824 && reason.contains("truncate(2)")
5825 && reason.contains("6.2"),
5826 "{reason}"
5827 );
5828 }
5829
5830 let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
5831 assert!(!seccomp.read_only_to_commands);
5832 assert!(seccomp
5833 .not_protected_because
5834 .unwrap()
5835 .contains("no Landlock"));
5836
5837 let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
5838 assert!(!none.read_only_to_commands);
5839 let reason = none.not_protected_because.unwrap();
5840 assert!(
5841 reason.contains("no kernel containment") && reason.contains("macOS"),
5842 "{reason}"
5843 );
5844
5845 assert_eq!(
5846 full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
5847 "the path carries the literal session-id segment"
5848 );
5849 }
5850
5851 #[test]
5853 fn runtime_dir_report_inside_the_workdir_is_never_protected() {
5854 for tier in ALL_TIERS {
5855 let report = runtime_dir_report(*tier, Some(6), &inside_placement());
5856 assert!(!report.read_only_to_commands, "{tier:?}");
5857 assert_eq!(report.enforced_by, None);
5858 }
5859 let reason =
5860 runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
5861 .not_protected_because
5862 .unwrap();
5863 assert!(
5864 reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
5865 "{reason}"
5866 );
5867 assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
5868 }
5869
5870 #[test]
5873 fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
5874 let inside = inside_placement();
5875 assert_eq!(
5876 achieved_containment_class(EnforcementTier::KernelSealed, false, true),
5877 ContainmentClass::Advisory
5878 );
5879 assert_eq!(
5880 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
5881 ContainmentClass::Sealed
5882 );
5883 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5884 match check_containment_floor(
5885 declared,
5886 ContainmentClass::Advisory,
5887 None,
5888 false,
5889 inside.inside(),
5890 ) {
5891 Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
5892 assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
5893 assert!(reason.contains("the workdir /home/dev"), "{reason}");
5894 assert!(
5895 reason.contains("run with a --workdir that does not contain ~/.murmur"),
5896 "{reason}"
5897 );
5898 }
5899 other => panic!("{declared:?}: {other:?}"),
5900 }
5901 }
5902 assert!(check_containment_floor(
5903 ContainmentClass::Advisory,
5904 ContainmentClass::Advisory,
5905 None,
5906 false,
5907 inside.inside()
5908 )
5909 .is_ok());
5910
5911 let report = report_for(
5912 &sample_policy(),
5913 ContainmentClass::Advisory,
5914 EnforcementTier::KernelFull,
5915 None,
5916 None,
5917 Some(6),
5918 None,
5919 Vec::new(),
5920 Vec::new(),
5921 Vec::new(),
5922 &inside,
5923 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5924 EnforcementTier::KernelFull,
5925 ContainmentClass::Advisory,
5926 )),
5927 IoMaxReport::default(),
5928 );
5929 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5930 let reason = report.runtime_dir.not_protected_because.clone().unwrap();
5931 assert!(report.filesystem_boundary.not_protected.contains(&reason));
5932 let rendered = report.render();
5933 assert!(
5934 rendered.contains(&format!(" commands: can change it — {reason}\n")),
5935 "{rendered}"
5936 );
5937 }
5938
5939 #[test]
5941 fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
5942 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5943 let rendered = report.render();
5944 let block =
5945 "Runtime directory\n path: /home/test/.murmur/sessions/<session-id>/runtime\n \
5946 commands: read only (landlock)\n";
5947 let at = rendered.find(block).expect(&rendered);
5948 assert!(at > rendered.find("Containment\n").unwrap());
5949 assert!(at < rendered.find("Effective grants").unwrap());
5950
5951 let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
5952 assert!(sealed
5953 .render()
5954 .contains(" commands: read only (mountns+landlock)\n"));
5955
5956 let json = serde_json::to_string(&report.runtime_dir).unwrap();
5957 assert_eq!(
5958 json,
5959 r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
5960 );
5961 }
5962
5963 #[test]
5966 fn a_landlock_session_reports_denial_rather_than_absence() {
5967 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
5968
5969 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
5970 assert_eq!(
5971 report.filesystem_boundary.restriction,
5972 FilesystemRestriction::Enforced
5973 );
5974 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
5975
5976 let filesystem = &report.filesystem_boundary.not_protected[0];
5977 assert!(
5978 !filesystem.contains("advisory"),
5979 "Landlock mediates the filesystem here: {filesystem}"
5980 );
5981 assert!(
5982 filesystem.contains("denied rather than absent"),
5983 "{filesystem}"
5984 );
5985 for probe in ["stat", "access", "readlink"] {
5986 assert!(filesystem.contains(probe), "{filesystem}");
5987 }
5988 }
5989
5990 #[test]
5994 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
5995 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
5996
5997 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
5998 assert_eq!(report.enforcement_tier, "landlock+seccomp");
5999 assert_eq!(
6000 report.host_containment,
6001 ContainmentClass::Sealed,
6002 "the host can back sealed, which only `host_containment` says"
6003 );
6004 assert_eq!(
6005 report.filesystem_boundary.restriction,
6006 FilesystemRestriction::Enforced,
6007 "no composed root is installed for a capsule that did not declare one"
6008 );
6009 assert_eq!(
6010 report.filesystem_boundary.not_protected,
6011 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
6012 .filesystem_boundary
6013 .not_protected,
6014 "the same mechanism gets the same statements on either host"
6015 );
6016 assert!(report.render().contains("Not protected here"));
6017 }
6018
6019 #[test]
6022 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
6023 let advisory =
6024 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
6025 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
6026
6027 assert_eq!(
6028 advisory.filesystem_boundary.not_protected[1],
6029 enforced.filesystem_boundary.not_protected[1]
6030 );
6031 assert!(
6032 advisory.filesystem_boundary.not_protected[1]
6033 .contains("home/ in its records directory"),
6034 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
6035 );
6036 }
6037
6038 #[test]
6041 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
6042 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
6043
6044 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
6045 assert_eq!(
6046 report.filesystem_boundary.restriction,
6047 FilesystemRestriction::Absent
6048 );
6049 assert!(report.filesystem_boundary.not_protected.is_empty());
6050 assert!(
6051 !report.render().contains("Not protected here"),
6052 "{}",
6053 report.render()
6054 );
6055
6056 let json = serde_json::to_value(&report).unwrap();
6057 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
6058 assert_eq!(
6059 json["filesystem_boundary"]["not_protected"],
6060 serde_json::json!([]),
6061 "the key is present and empty at absent, never missing"
6062 );
6063 }
6064
6065 #[test]
6069 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
6070 let policy = CapabilityPolicy {
6071 workdir_exec_allowed: true,
6072 ..CapabilityPolicy::default()
6073 };
6074
6075 let report = report_for(
6076 &policy,
6077 ContainmentClass::Advisory,
6078 EnforcementTier::KernelSealed,
6079 None,
6080 None,
6081 Some(6),
6082 None,
6083 Vec::new(),
6084 Vec::new(),
6085 Vec::new(),
6086 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6087 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6088 EnforcementTier::KernelSealed,
6089 ContainmentClass::Advisory,
6090 )),
6091 IoMaxReport::default(),
6092 );
6093
6094 assert_eq!(report.enforcement_tier, "landlock+seccomp");
6095 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
6096 assert_eq!(
6097 report.filesystem_boundary.restriction,
6098 FilesystemRestriction::Enforced
6099 );
6100 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
6101 assert!(report.render().contains("Not protected here"));
6102 }
6103
6104 #[test]
6107 fn the_restriction_follows_the_tier_the_session_would_install() {
6108 for tier in ALL_TIERS {
6109 for declared in [
6110 ContainmentClass::Advisory,
6111 ContainmentClass::Scoped,
6112 ContainmentClass::Sealed,
6113 ] {
6114 let report = boundary_report(declared, *tier);
6115 let expected = match crate::sandbox::applied_tier(*tier, declared) {
6116 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
6117 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
6118 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
6119 FilesystemRestriction::Advisory
6120 }
6121 EnforcementTier::Unconfined => FilesystemRestriction::None,
6122 };
6123 assert_eq!(
6124 report.filesystem_boundary.restriction, expected,
6125 "{tier:?} / {declared:?}"
6126 );
6127 assert_eq!(
6128 report.filesystem_boundary.not_protected.is_empty(),
6129 expected == FilesystemRestriction::Absent,
6130 "{tier:?} / {declared:?}"
6131 );
6132 let wire = match expected {
6133 FilesystemRestriction::Advisory => "advisory",
6134 FilesystemRestriction::Enforced => "enforced",
6135 FilesystemRestriction::Absent => "absent",
6136 FilesystemRestriction::None => "none",
6137 };
6138 assert_eq!(
6139 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
6140 serde_json::Value::String(wire.to_string())
6141 );
6142 }
6143 }
6144 }
6145
6146 #[test]
6159 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
6160 if crate::network_namespace::skip_without_egress_namespace(
6161 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
6162 ) {
6163 return;
6164 }
6165 if crate::sandbox::find_on_path("bash").is_none() {
6166 eprintln!(
6167 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
6168 no bash on PATH"
6169 );
6170 return;
6171 }
6172 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
6173 let secret = outside.path().join("host-file");
6174 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
6175 let secret = secret.to_str().unwrap().to_string();
6176
6177 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
6178 let workdir = tempfile::tempdir().unwrap();
6179 let policy = CapabilityPolicy {
6180 shell_allow: vec!["bash".to_string()],
6181 ..CapabilityPolicy::default()
6182 };
6183 let probe = crate::sandbox::HostProbe::probe();
6184 let host_tier = probe.tier();
6185 let runtime = tempfile::tempdir().unwrap();
6186 let enforcement =
6187 crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
6188 .unwrap()
6189 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
6190 let report = report_for(
6191 &policy,
6192 declared,
6193 host_tier,
6194 None,
6195 None,
6196 None,
6197 None,
6198 Vec::new(),
6199 Vec::new(),
6200 Vec::new(),
6201 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6202 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6203 host_tier, declared,
6204 )),
6205 IoMaxReport::default(),
6206 );
6207
6208 let command = format!(
6209 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
6210 else echo read=refused; fi; \
6211 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
6212 );
6213 let result = crate::shell::execute_shell(
6214 "bash",
6215 &["-c", &command],
6216 &[],
6217 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
6218 &policy,
6219 &enforcement,
6220 )
6221 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
6222 let seen = result.stdout;
6223
6224 match report.filesystem_boundary.restriction {
6225 FilesystemRestriction::Advisory => assert!(
6226 seen.contains("read=PROBE-CONTENTS"),
6227 "reported advisory, so nothing denies the read: {seen}"
6228 ),
6229 FilesystemRestriction::Enforced => assert!(
6230 seen.contains("read=refused") && seen.contains("exists=yes"),
6231 "reported enforced, so the read is denied and the path still exists: {seen}"
6232 ),
6233 FilesystemRestriction::Absent => assert!(
6234 seen.contains("read=refused") && seen.contains("exists=no"),
6235 "reported absent, so the path is not in the composed root: {seen}"
6236 ),
6237 FilesystemRestriction::None => assert!(
6238 seen.contains("read=PROBE-CONTENTS"),
6239 "reported none, so nothing denies the read: {seen}"
6240 ),
6241 }
6242 }
6243 }
6244
6245 fn capability_policy_field_names(source: &str) -> Vec<String> {
6247 source
6248 .split_once("pub struct CapabilityPolicy {")
6249 .expect("types.rs declares CapabilityPolicy")
6250 .1
6251 .split_once("\n}")
6252 .expect("CapabilityPolicy's declaration is closed at column 0")
6253 .0
6254 .lines()
6255 .filter_map(|line| {
6256 line.strip_prefix(" pub ")
6257 .and_then(|rest| rest.split_once(':'))
6258 .map(|(name, _)| name.to_string())
6259 })
6260 .collect()
6261 }
6262
6263 #[test]
6268 fn the_temp_dir_report_follows_the_per_tier_table() {
6269 use crate::temp_dir::temp_mapping;
6270
6271 let placement = RuntimeDirPlacement::outside_for_test();
6272 let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
6273 let tmp = crate::temp_dir::canonical_tmp();
6274 let elsewhere = std::path::Path::new("/work");
6275 let rows = [
6277 (
6278 EnforcementTier::KernelSealed,
6279 ContainmentClass::Sealed,
6280 true,
6281 elsewhere,
6282 true,
6283 Some("composed-root"),
6284 ),
6285 (
6286 EnforcementTier::KernelSealed,
6287 ContainmentClass::Scoped,
6288 true,
6289 elsewhere,
6290 true,
6291 Some("mount-namespace"),
6292 ),
6293 (
6294 EnforcementTier::KernelSealed,
6295 ContainmentClass::Advisory,
6296 true,
6297 elsewhere,
6298 true,
6299 Some("mount-namespace"),
6300 ),
6301 (
6302 EnforcementTier::KernelFull,
6303 ContainmentClass::Scoped,
6304 true,
6305 elsewhere,
6306 true,
6307 Some("mount-namespace"),
6308 ),
6309 (
6310 EnforcementTier::KernelFull,
6311 ContainmentClass::Scoped,
6312 false,
6313 elsewhere,
6314 false,
6315 None,
6316 ),
6317 (
6318 EnforcementTier::KernelSeccompOnly,
6319 ContainmentClass::Advisory,
6320 true,
6321 elsewhere,
6322 true,
6323 Some("mount-namespace"),
6324 ),
6325 (
6326 EnforcementTier::KernelSeccompOnly,
6327 ContainmentClass::Advisory,
6328 false,
6329 elsewhere,
6330 false,
6331 None,
6332 ),
6333 (
6334 EnforcementTier::EnvironmentOnly,
6335 ContainmentClass::Advisory,
6336 false,
6337 elsewhere,
6338 false,
6339 None,
6340 ),
6341 (
6342 EnforcementTier::KernelFull,
6343 ContainmentClass::Scoped,
6344 true,
6345 tmp.as_path(),
6346 false,
6347 None,
6348 ),
6349 (
6350 EnforcementTier::KernelSeccompOnly,
6351 ContainmentClass::Advisory,
6352 true,
6353 tmp.as_path(),
6354 false,
6355 None,
6356 ),
6357 ];
6358 for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
6359 let applied = crate::sandbox::applied_tier(tier, declared);
6360 let mapping = temp_mapping(applied, usable, workdir);
6361 let report = temp_dir_report(mapping, &placement);
6362 let row = format!(
6363 "{tier:?} {declared:?} usable={usable} workdir={}",
6364 workdir.display()
6365 );
6366 assert_eq!(report.path, folder, "{row}");
6367 assert_eq!(report.tmp_mapped, mapped, "{row}");
6368 assert_eq!(report.mapped_by, mapped_by, "{row}");
6369 assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
6370 assert_eq!(
6371 report
6372 .not_mapped_because
6373 .as_deref()
6374 .is_some_and(|reason| !reason.is_empty()),
6375 !mapped,
6376 "{row}"
6377 );
6378
6379 let scope = report_for(
6380 &CapabilityPolicy::default(),
6381 declared,
6382 tier,
6383 None,
6384 None,
6385 None,
6386 None,
6387 Vec::new(),
6388 Vec::new(),
6389 Vec::new(),
6390 &placement,
6391 mapping,
6392 IoMaxReport::default(),
6393 );
6394 assert_eq!(scope.temp_dir, report, "{row}");
6395 }
6396
6397 let reason = |tier, usable, workdir| {
6398 temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
6399 .not_mapped_because
6400 .unwrap()
6401 };
6402 assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
6403 .starts_with("this host has no kernel containment for capsule commands"));
6404 assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
6405 .contains("neither a private mount namespace nor Landlock"));
6406 assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
6407 .contains("a literal /tmp path is refused"));
6408 assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
6409 .starts_with("the workdir is /tmp itself"));
6410 }
6411
6412 #[test]
6415 fn the_temp_folder_block_renders_after_the_records_directory() {
6416 use crate::temp_dir::{NotMappedReason, TempMapping};
6417
6418 let placement = RuntimeDirPlacement::outside_for_test();
6419 let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
6420 assert_eq!(
6421 render_temp_dir(&mapped),
6422 "Temporary folder\n path: /home/test/.murmur/records/work/<session-id>/tmp\n \
6423 TMPDIR: /tmp\n /tmp: mapped (mount-namespace)\n"
6424 );
6425 let unmapped = temp_dir_report(
6426 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
6427 &placement,
6428 );
6429 assert_eq!(
6430 render_temp_dir(&unmapped),
6431 format!(
6432 "Temporary folder\n path: {path}\n TMPDIR: {path}\n /tmp: not \
6433 mapped — {}\n",
6434 NotMappedReason::NoMountNamespace.describe(),
6435 path = "/home/test/.murmur/records/work/<session-id>/tmp"
6436 )
6437 );
6438 assert_eq!(
6439 serde_json::to_value(&mapped).unwrap(),
6440 serde_json::json!({
6441 "path": "/home/test/.murmur/records/work/<session-id>/tmp",
6442 "tmpdir": "/tmp",
6443 "tmp_mapped": true,
6444 "mapped_by": "mount-namespace",
6445 "not_mapped_because": null,
6446 })
6447 );
6448
6449 let scope = report_for(
6450 &CapabilityPolicy::default(),
6451 ContainmentClass::Scoped,
6452 EnforcementTier::KernelFull,
6453 None,
6454 None,
6455 None,
6456 None,
6457 Vec::new(),
6458 Vec::new(),
6459 Vec::new(),
6460 &placement,
6461 TempMapping::MountNamespace,
6462 IoMaxReport::default(),
6463 );
6464 let rendered = scope.render();
6465 let records = rendered.find("Records directory\n").expect("records block");
6466 let temp = rendered.find("Temporary folder\n").expect("temp block");
6467 assert!(records < temp, "{rendered}");
6468 assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
6469 assert!(serde_json::to_value(&scope)
6470 .unwrap()
6471 .get("temp_dir")
6472 .is_some());
6473 }
6474}