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>,
470 pub extra_roots: Vec<ExtraRootReport>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
479pub struct ExtraRootReport {
480 pub guest_path: String,
482 pub path: String,
484 pub access: crate::host_paths::HostPathAccess,
486 pub source: crate::guest_roots::GuestRootSource,
488}
489
490impl From<&crate::guest_roots::GuestRoot> for ExtraRootReport {
491 fn from(root: &crate::guest_roots::GuestRoot) -> Self {
492 Self {
493 guest_path: root.guest_path.clone(),
494 path: root.host_path.display().to_string(),
495 access: root.access,
496 source: root.source,
497 }
498 }
499}
500
501pub fn attach_extra_roots(reports: &mut [PreopenReport], roots: &[crate::guest_roots::GuestRoot]) {
505 for report in reports {
506 report.extra_roots = if report.runtime_dir_reach.is_some() {
507 roots.iter().map(ExtraRootReport::from).collect()
508 } else {
509 Vec::new()
510 };
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
517#[serde(rename_all = "kebab-case")]
518pub enum RuntimeDirReach {
519 MurmurRuntime,
521}
522
523impl PreopenReport {
524 pub fn render(&self) -> String {
527 let artifact = &self.artifact;
528 let role = &self.role;
529 let surface = self.surface.summary();
530 let guest = crate::runtime_dir::RUNTIME_DIR_GUEST_PATH;
531 let runtime_dir = match self.runtime_dir_reach {
532 None => String::new(),
533 Some(RuntimeDirReach::MurmurRuntime) => {
534 format!("; runtime directory read-only at {guest}/")
535 }
536 };
537 let extra_roots = self.render_extra_roots();
538 match &self.scope {
539 Some(scope) => format!(
540 "{artifact} ({role}): {surface} — capabilities.filesystem.scope: \
541 {scope}{runtime_dir}{extra_roots}"
542 ),
543 None => format!(
544 "{artifact} ({role}): {surface} — no capabilities.filesystem.scope \
545 declared{runtime_dir}{extra_roots}"
546 ),
547 }
548 }
549
550 fn render_extra_roots(&self) -> String {
552 if self.extra_roots.is_empty() {
553 return String::new();
554 }
555 let guest = crate::guest_roots::HOST_ROOT_GUEST_PATH;
556 let whole_host = self
557 .extra_roots
558 .iter()
559 .any(|root| root.source == crate::guest_roots::GuestRootSource::Unconfined);
560 if whole_host {
561 return format!("; whole host read-write under {guest}/");
562 }
563 let folders: Vec<String> = self
564 .extra_roots
565 .iter()
566 .map(|root| format!("{} ({})", root.path, root.access))
567 .collect();
568 format!("; host folders under {guest}/: {}", folders.join(", "))
569 }
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
580#[serde(rename_all = "kebab-case")]
581pub enum FilesystemRestriction {
582 Advisory,
584 Enforced,
586 Absent,
588 None,
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
603pub struct FilesystemBoundaryReport {
604 pub restriction: FilesystemRestriction,
606 pub not_protected: Vec<String>,
611}
612
613const ADVISORY_FILESYSTEM_NOT_PROTECTED: &str =
618 "Filesystem restriction is advisory on this host: no kernel mechanism denies a read or a write \
619 outside the work folder, so capabilities.filesystem.scope bounds what the runtime hands a \
620 subprocess rather than what that subprocess can reach. A Linux host with a usable Landlock \
621 ABI installs a Landlock domain instead, where the kernel refuses the open.";
622
623const ENFORCED_FILESYSTEM_NOT_PROTECTED: &str =
629 "A path outside the granted set is denied rather than absent: Landlock refuses the open, and \
630 stat, access and readlink on that path still succeed and still report the real file's \
631 metadata, because Landlock mediates the operations that touch a file and not path resolution. \
632 A probe that only asks whether a path exists therefore learns nothing about what was denied — \
633 read the file to see the refusal. Declaring capabilities.containment: sealed on a host that \
634 reaches the sealed tier composes a root the path is not in at all.";
635
636const ADVISORY_HOME_NOT_PROTECTED: &str =
641 "HOME is relocated, not protected: it points at the session's home folder, and the real home \
642 directory stays readable and writable by absolute path, because no kernel mechanism mediates \
643 the filesystem on this tier.";
644
645const PATH_REWRITING_NOT_PROTECTED: &str =
651 "Path rewriting is a convenience, not a boundary: HOME is set to the session's home folder, \
652 home/ in its records directory, on every tier, so `cat ~/.ssh/id_rsa` answers \"No such file \
653 or directory\" whether or not anything was denied — the tilde resolved into that folder and \
654 the host file was never looked for. A ~-based probe is therefore never evidence of containment; test with an absolute path \
655 to a real file outside the workdir instead.";
656
657pub(crate) const UNCONFINED_NOT_PROTECTED: &str =
659 "Nothing is contained: this session runs unconfined, so its shell commands, plan shell steps \
660 and process harness run as the operator, with the operator's HOME, environment \
661 (credential variables included), filesystem and network, and sudo works wherever the \
662 operator's sudoers allows it. The trace and the records directory are written as always but \
663 are not protected from those commands.";
664
665pub(crate) fn filesystem_boundary(applied: EnforcementTier) -> FilesystemBoundaryReport {
678 if applied == EnforcementTier::Unconfined {
682 return FilesystemBoundaryReport {
684 restriction: FilesystemRestriction::None,
685 not_protected: vec![UNCONFINED_NOT_PROTECTED.to_string()],
686 };
687 }
688 let not_protected: &[&str] = match applied {
691 EnforcementTier::KernelSealed => &[],
692 EnforcementTier::Unconfined => unreachable!("returned above"),
693 EnforcementTier::KernelFull => &[
694 ENFORCED_FILESYSTEM_NOT_PROTECTED,
695 PATH_REWRITING_NOT_PROTECTED,
696 ],
697 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => &[
698 ADVISORY_FILESYSTEM_NOT_PROTECTED,
699 PATH_REWRITING_NOT_PROTECTED,
700 ADVISORY_HOME_NOT_PROTECTED,
701 ],
702 };
703 let restriction = match applied {
704 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
705 EnforcementTier::Unconfined => unreachable!("returned above"),
706 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
707 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
708 FilesystemRestriction::Advisory
709 }
710 };
711 let not_protected = not_protected
712 .iter()
713 .map(|statement| (*statement).to_string())
714 .collect();
715
716 FilesystemBoundaryReport {
717 restriction,
718 not_protected,
719 }
720}
721
722#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
727pub struct RuntimeDirReport {
728 pub path: String,
732 pub read_only_to_commands: bool,
734 pub enforced_by: Option<&'static str>,
737 pub not_protected_because: Option<String>,
739}
740
741const RUNTIME_DIR_ENFORCED_SEALED: &str = "mountns+landlock";
743
744const RUNTIME_DIR_ENFORCED_LANDLOCK: &str = "landlock";
746
747const LANDLOCK_TRUNCATE_ABI: u8 = 3;
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753enum ProtectedDir {
754 Runtime,
755 Records,
756}
757
758impl ProtectedDir {
759 fn noun(self) -> &'static str {
760 match self {
761 Self::Runtime => "runtime directory",
762 Self::Records => "records directory",
763 }
764 }
765
766 fn file(self) -> &'static str {
768 match self {
769 Self::Runtime => "runtime file",
770 Self::Records => "record",
771 }
772 }
773
774 fn heading(self) -> &'static str {
775 match self {
776 Self::Runtime => "Runtime directory",
777 Self::Records => "Records directory",
778 }
779 }
780}
781
782#[must_use]
790pub(crate) fn runtime_dir_report(
791 applied: EnforcementTier,
792 landlock_abi: Option<u8>,
793 placement: &RuntimeDirPlacement,
794) -> RuntimeDirReport {
795 protected_dir_report(
796 ProtectedDir::Runtime,
797 applied,
798 landlock_abi,
799 placement.report_path(),
800 placement.sessions_inside_workdir,
801 &placement.workdir,
802 )
803}
804
805#[must_use]
808pub(crate) fn records_dir_report(
809 applied: EnforcementTier,
810 landlock_abi: Option<u8>,
811 placement: &RuntimeDirPlacement,
812) -> RuntimeDirReport {
813 protected_dir_report(
814 ProtectedDir::Records,
815 applied,
816 landlock_abi,
817 placement.records_report_path(),
818 placement.records_inside_workdir,
819 &placement.workdir,
820 )
821}
822
823fn protected_dir_report(
824 dir: ProtectedDir,
825 applied: EnforcementTier,
826 landlock_abi: Option<u8>,
827 path: String,
828 inside_workdir: bool,
829 workdir: &std::path::Path,
830) -> RuntimeDirReport {
831 let noun = dir.noun();
832 let not_protected = |reason: String| RuntimeDirReport {
833 path: path.clone(),
834 read_only_to_commands: false,
835 enforced_by: None,
836 not_protected_because: Some(reason),
837 };
838 match applied {
839 EnforcementTier::Unconfined => not_protected(format!(
840 "the capsule runs unconfined, so its commands run as the operator and can change the \
841 {noun}"
842 )),
843 EnforcementTier::EnvironmentOnly => not_protected(format!(
844 "this host has no kernel containment for capsule commands — macOS and every \
845 non-Linux host run them as the operator's own processes, so a command can change \
846 the {noun}"
847 )),
848 EnforcementTier::KernelSeccompOnly => not_protected(format!(
849 "this host has no Landlock, so no kernel mechanism keeps a command from changing the \
850 {noun}"
851 )),
852 _ if inside_workdir => not_protected(format!(
853 "the {noun} {path} lies inside the workdir {}, so the capsule's commands can write \
854 it; run with a --workdir that does not contain ~/.murmur",
855 workdir.display()
856 )),
857 EnforcementTier::KernelSealed => RuntimeDirReport {
858 path,
859 read_only_to_commands: true,
860 enforced_by: Some(RUNTIME_DIR_ENFORCED_SEALED),
861 not_protected_because: None,
862 },
863 EnforcementTier::KernelFull => {
864 let abi = landlock_abi.unwrap_or(1);
865 if abi < LANDLOCK_TRUNCATE_ABI {
866 not_protected(format!(
867 "Landlock ABI {abi} does not mediate truncate(2), so a command can empty a \
868 {} though not replace it; kernel 6.2 or later refuses it",
869 dir.file()
870 ))
871 } else {
872 RuntimeDirReport {
873 path,
874 read_only_to_commands: true,
875 enforced_by: Some(RUNTIME_DIR_ENFORCED_LANDLOCK),
876 not_protected_because: None,
877 }
878 }
879 }
880 }
881}
882
883#[must_use]
886pub fn render_runtime_dir(report: &RuntimeDirReport) -> String {
887 render_protected_dir(ProtectedDir::Runtime, report)
888}
889
890#[must_use]
893pub fn render_records_dir(report: &RuntimeDirReport) -> String {
894 render_protected_dir(ProtectedDir::Records, report)
895}
896
897fn render_protected_dir(dir: ProtectedDir, report: &RuntimeDirReport) -> String {
898 let commands = match (&report.not_protected_because, report.enforced_by) {
899 (Some(reason), _) => format!("can change it — {reason}"),
900 (None, Some(mechanism)) => format!("read only ({mechanism})"),
901 (None, None) => "read only".to_string(),
902 };
903 format!(
904 "{}\n path: {}\n commands: {commands}\n",
905 dir.heading(),
906 report.path
907 )
908}
909
910#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
914pub struct TempDirReport {
915 pub path: String,
918 pub tmpdir: String,
921 pub tmp_mapped: bool,
923 pub mapped_by: Option<&'static str>,
926 pub not_mapped_because: Option<String>,
929}
930
931#[must_use]
934pub(crate) fn temp_dir_report(
935 mapping: crate::temp_dir::TempMapping,
936 placement: &RuntimeDirPlacement,
937) -> TempDirReport {
938 let path = std::path::Path::new(&placement.records_report_path())
939 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
940 .display()
941 .to_string();
942 TempDirReport {
943 tmpdir: mapping.tmpdir_value(std::path::Path::new(&path)),
944 path,
945 tmp_mapped: mapping.is_mapped(),
946 mapped_by: mapping.mapped_by(),
947 not_mapped_because: match mapping {
948 crate::temp_dir::TempMapping::NotMapped(reason) => Some(reason.describe().to_string()),
949 _ => None,
950 },
951 }
952}
953
954#[must_use]
957pub fn render_temp_dir(report: &TempDirReport) -> String {
958 let tmp = match (report.mapped_by, &report.not_mapped_because) {
959 (Some(mechanism), _) => format!("mapped ({mechanism})"),
960 (None, Some(reason)) => format!("not mapped — {reason}"),
961 (None, None) => "not mapped".to_string(),
962 };
963 format!(
964 "Temporary folder\n path: {}\n TMPDIR: {}\n /tmp: {tmp}\n",
965 report.path, report.tmpdir
966 )
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
972pub struct DeclaredNeedsReport {
973 pub artifact: String,
975 pub program: String,
977 pub granted: Vec<GrantedNeedReport>,
978 pub missing: Vec<MissingNeedReport>,
979}
980
981#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
984pub struct GrantedNeedReport {
985 pub need: String,
986 pub paths: Vec<String>,
987}
988
989#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
991pub struct MissingNeedReport {
992 pub need: String,
993 pub reason: String,
994}
995
996impl From<&crate::declared_needs::ResolvedProgramNeeds> for DeclaredNeedsReport {
997 fn from(resolved: &crate::declared_needs::ResolvedProgramNeeds) -> Self {
998 Self {
999 artifact: resolved.artifact.clone(),
1000 program: resolved.program.clone(),
1001 granted: resolved
1002 .granted
1003 .iter()
1004 .map(|need| GrantedNeedReport {
1005 need: need.need.form.to_string(),
1006 paths: need
1007 .paths
1008 .iter()
1009 .map(crate::declared_needs::NeedPath::render)
1010 .collect(),
1011 })
1012 .collect(),
1013 missing: resolved
1014 .missing
1015 .iter()
1016 .map(|need| MissingNeedReport {
1017 need: need.need.form.to_string(),
1018 reason: need.reason.clone(),
1019 })
1020 .collect(),
1021 }
1022 }
1023}
1024
1025impl DeclaredNeedsReport {
1026 #[must_use]
1030 pub fn render_lines(&self) -> Vec<String> {
1031 let owner = format!("{} ({})", self.artifact, self.program);
1032 self.granted
1033 .iter()
1034 .map(|need| format!("{owner}: {} -> {}", need.need, need.paths.join(", ")))
1035 .chain(
1036 self.missing
1037 .iter()
1038 .map(|need| format!("{owner}: {} -> missing ({})", need.need, need.reason)),
1039 )
1040 .collect()
1041 }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1048pub struct PinnedProgramReport {
1049 pub artifact: String,
1051 pub program: String,
1053 pub platform: String,
1055 pub source: String,
1058 #[serde(skip_serializing_if = "Option::is_none")]
1060 pub path: Option<String>,
1061 #[serde(skip_serializing_if = "Option::is_none")]
1062 pub url: Option<String>,
1063 #[serde(skip_serializing_if = "Option::is_none")]
1064 pub sha256: Option<String>,
1065 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub reason: Option<String>,
1067}
1068
1069impl From<&crate::pinned_programs::ResolvedPinnedProgram> for PinnedProgramReport {
1070 fn from(resolved: &crate::pinned_programs::ResolvedPinnedProgram) -> Self {
1071 use crate::pinned_programs::PinnedSource;
1072 let mut report = Self {
1073 artifact: resolved.artifact.clone(),
1074 program: resolved.program.clone(),
1075 platform: resolved.platform.clone(),
1076 source: resolved.source.label().to_string(),
1077 path: None,
1078 url: None,
1079 sha256: None,
1080 reason: None,
1081 };
1082 match &resolved.source {
1083 PinnedSource::Pinned {
1084 path, url, sha256, ..
1085 } => {
1086 report.path = Some(path.display().to_string());
1087 report.url = Some(url.clone());
1088 report.sha256 = Some(sha256.clone());
1089 }
1090 PinnedSource::Host { path } => report.path = Some(path.display().to_string()),
1091 PinnedSource::Missing { reason } => report.reason = Some(reason.clone()),
1092 }
1093 report
1094 }
1095}
1096
1097impl PinnedProgramReport {
1098 #[must_use]
1102 pub fn render_line(&self) -> String {
1103 let owner = format!("{} ({})", self.artifact, self.program);
1104 let path = self.path.as_deref().unwrap_or_default();
1105 match self.source.as_str() {
1106 "pinned" => format!(
1107 "{owner}: pinned {path} (sha256 {}, from {})",
1108 self.sha256.as_deref().unwrap_or_default(),
1109 self.url.as_deref().unwrap_or_default()
1110 ),
1111 "host" => format!("{owner}: host {path}"),
1112 _ => format!(
1113 "{owner}: missing ({})",
1114 self.reason.as_deref().unwrap_or_default()
1115 ),
1116 }
1117 }
1118}
1119
1120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1126pub struct ScopeReport {
1127 pub declared_containment: ContainmentClass,
1129 pub achieved_containment: ContainmentClass,
1133 pub host_containment: ContainmentClass,
1137 pub floor_met: bool,
1139 #[serde(skip_serializing_if = "Option::is_none")]
1141 pub shortfall_reason: Option<String>,
1142 pub enforcement_tier: &'static str,
1145 pub routes: Vec<RouteReport>,
1154 pub userns_grant: Option<UsernsGrant>,
1164 pub landlock_abi: Option<u8>,
1174 pub filesystem_scope: Option<String>,
1176 pub workdir_exec: bool,
1184 pub read_only_paths: Vec<String>,
1194 pub read_only_advisory_for: Vec<String>,
1204 pub preopens: Vec<PreopenReport>,
1217 pub network_allow: Vec<String>,
1219 pub unix_sockets: bool,
1221 pub shell_allow: Vec<String>,
1223 pub spawn_allow: Vec<String>,
1225 pub spawn_workdir_snapshot: Vec<String>,
1229 pub parent_workdir: Option<crate::workdir_snapshot::ParentWorkdirReport>,
1237 pub images: Option<ImagesReport>,
1241 pub env_allow: Vec<String>,
1243 pub plan_submit: bool,
1251 pub install_skill: Vec<String>,
1259 pub install_tool: Vec<String>,
1263 pub install_from: Vec<String>,
1268 pub interpreter_runtime_grants: Vec<String>,
1272 pub derived_interpreter_runtime: Vec<String>,
1277 pub trust_store: Vec<String>,
1283 pub declared_needs: Vec<DeclaredNeedsReport>,
1288 pub pinned_programs: Vec<PinnedProgramReport>,
1292 pub name_resolution: Vec<String>,
1296 pub host_paths: Vec<crate::host_paths::HostPathReport>,
1301 pub exports_files: Option<ExportsFilesReport>,
1311 pub peer_files: Option<PeerFilesReport>,
1319 pub peer_tasks: bool,
1325 pub peer_fetch_allow: Vec<String>,
1332 pub state_stores: Vec<StateStoreReport>,
1342 pub configured_artifacts: Vec<String>,
1356 pub staged_runtime_grants: Vec<String>,
1365 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1380 pub manifest_files: Vec<crate::manifest_files::ManifestFileReport>,
1390 pub io_max: IoMaxReport,
1406 pub cgroup_scope: CgroupScopeReport,
1415 pub filesystem_boundary: FilesystemBoundaryReport,
1427 pub runtime_dir: RuntimeDirReport,
1432 pub records_dir: RuntimeDirReport,
1436 pub temp_dir: TempDirReport,
1440 pub process_view: crate::proc_view::ProcessViewReport,
1444 pub unconfined: Option<UnconfinedReport>,
1447 pub harness: Option<crate::harness_needs::HarnessReport>,
1454}
1455
1456#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1458pub struct UnconfinedReport {
1459 pub user: Option<String>,
1461 pub uid: u32,
1463 pub home: Option<String>,
1466 pub applies_to: Vec<&'static str>,
1468 pub resource_limits: Vec<&'static str>,
1471}
1472
1473pub const UNCONFINED_APPLIES_TO: &[&str] = &[
1476 "shell",
1477 "plan-shell-step",
1478 "process-harness",
1479 "harness-tools",
1480];
1481
1482impl UnconfinedReport {
1483 pub(crate) fn for_this_process(declared: &murmur_artifact::ResourceCapabilities) -> Self {
1486 let uid = current_uid();
1487 Self {
1488 user: account_name(uid),
1489 uid,
1490 home: std::env::var_os("HOME").map(|home| home.to_string_lossy().into_owned()),
1491 applies_to: UNCONFINED_APPLIES_TO.to_vec(),
1492 resource_limits: crate::resources::declared_resource_fields(declared),
1493 }
1494 }
1495
1496 #[must_use]
1498 pub fn render(&self) -> String {
1499 let applies_to = self
1500 .applies_to
1501 .iter()
1502 .map(|route| match *route {
1503 "shell" => "shell commands",
1504 "plan-shell-step" => "plan shell steps",
1505 "process-harness" => "process harness",
1506 "harness-tools" => "harness tools",
1507 other => other,
1508 })
1509 .collect::<Vec<_>>()
1510 .join(", ");
1511 let limits = if self.resource_limits.is_empty() {
1512 "none declared".to_string()
1513 } else {
1514 self.resource_limits.join(", ")
1515 };
1516 format!(
1517 "Unconfined\n user: {}\n uid: {}\n HOME: {}\n applies to: \
1518 {applies_to}\n resource limits: {limits}\n",
1519 self.user.as_deref().unwrap_or("<unknown>"),
1520 self.uid,
1521 self.home.as_deref().unwrap_or("<unset>"),
1522 )
1523 }
1524}
1525
1526pub(crate) fn unconfined_warning(report: &UnconfinedReport) -> String {
1528 let user = match &report.user {
1529 Some(user) => format!("{user} (uid {})", report.uid),
1530 None => format!("uid {}", report.uid),
1531 };
1532 let home = report.home.as_deref().unwrap_or("<unset>");
1533 format!(
1534 "this capsule runs unconfined: its shell commands, plan shell steps and process harness \
1535 run as {user} with HOME {home}, the whole environment mur run was \
1536 started with (credential variables included), the host filesystem and network as that \
1537 account has them, and no no_new_privs, so sudo works wherever that account's sudoers \
1538 allows it"
1539 )
1540}
1541
1542pub(crate) fn warn_unconfined_launch(
1545 records_root: &crate::workdir_fs::WorkdirRoot,
1546 report: &UnconfinedReport,
1547) {
1548 let code = murmur_artifact::security_warnings::W_SEC_036;
1549 let message = unconfined_warning(report);
1550 let link = murmur_artifact::security_warning_link(code);
1551 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
1552 crate::agent::append_bootstrap_log(
1553 records_root,
1554 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
1555 );
1556}
1557
1558#[cfg(unix)]
1560pub(crate) fn current_uid() -> u32 {
1561 rustix::process::getuid().as_raw()
1562}
1563
1564#[cfg(not(unix))]
1565pub(crate) fn current_uid() -> u32 {
1566 0
1567}
1568
1569#[cfg(unix)]
1571#[allow(unsafe_code)]
1572pub(crate) fn account_name(uid: u32) -> Option<String> {
1573 let mut buffer = vec![0 as libc::c_char; 16 * 1024];
1574 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
1577 let mut result: *mut libc::passwd = std::ptr::null_mut();
1578 let rc = unsafe {
1581 libc::getpwuid_r(
1582 uid,
1583 &mut entry,
1584 buffer.as_mut_ptr(),
1585 buffer.len(),
1586 &mut result,
1587 )
1588 };
1589 if rc != 0 || result.is_null() || entry.pw_name.is_null() {
1590 return None;
1591 }
1592 let name = unsafe { std::ffi::CStr::from_ptr(entry.pw_name) };
1595 Some(name.to_string_lossy().into_owned())
1596}
1597
1598#[cfg(not(unix))]
1599pub(crate) fn account_name(_uid: u32) -> Option<String> {
1600 None
1601}
1602
1603impl ScopeReport {
1604 pub fn render(&self) -> String {
1606 let mut out = String::new();
1607 out.push_str("Containment\n");
1608 out.push_str(&format!(" declared: {}\n", self.declared_containment));
1609 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
1610 out.push_str(&format!(" host can back: {}\n", self.host_containment));
1611 out.push_str(&format!(
1612 " floor met: {}\n",
1613 if self.floor_met { "yes" } else { "no" }
1614 ));
1615 if let Some(reason) = &self.shortfall_reason {
1616 out.push_str(&format!(" reason: {reason}\n"));
1617 }
1618 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
1619 out.push_str(&format!(
1620 " userns grant: {}\n",
1621 match self.userns_grant {
1622 Some(grant) => grant.wire_name(),
1623 None => "n/a",
1624 }
1625 ));
1626 out.push_str(&format!(
1627 " landlock ABI: {}\n",
1628 match self.landlock_abi {
1629 Some(abi) => abi.to_string(),
1630 None => "n/a".to_string(),
1631 }
1632 ));
1633 out.push_str(&format!(
1634 " {}\n",
1635 crate::cgroup::render_cgroup_scope(&self.cgroup_scope)
1636 ));
1637
1638 out.push('\n');
1639 out.push_str(&render_routes(&self.routes));
1640
1641 if let Some(harness) = &self.harness {
1642 out.push('\n');
1643 out.push_str(&crate::harness_needs::render_harness_block(harness));
1644 }
1645
1646 if let Some(unconfined) = &self.unconfined {
1647 out.push('\n');
1648 out.push_str(&unconfined.render());
1649 }
1650
1651 out.push('\n');
1652 out.push_str(&render_runtime_dir(&self.runtime_dir));
1653 out.push('\n');
1654 out.push_str(&render_records_dir(&self.records_dir));
1655 out.push('\n');
1656 out.push_str(&render_temp_dir(&self.temp_dir));
1657 out.push('\n');
1658 out.push_str(&crate::proc_view::render_process_view(&self.process_view));
1659
1660 out.push_str(&render_not_protected(&self.filesystem_boundary));
1661
1662 out.push_str("\nEffective grants\n");
1663 out.push_str(&format!(
1664 " filesystem scope: {}\n",
1665 self.filesystem_scope.as_deref().unwrap_or("<none>")
1666 ));
1667 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
1668 out.push_str(&render_read_only(
1669 &self.read_only_paths,
1670 &self.read_only_advisory_for,
1671 ));
1672 push_list(
1673 &mut out,
1674 "preopens",
1675 &self
1676 .preopens
1677 .iter()
1678 .map(PreopenReport::render)
1679 .collect::<Vec<_>>(),
1680 );
1681 push_list(&mut out, "network allow", &self.network_allow);
1682 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
1683 push_list(&mut out, "shell allow", &self.shell_allow);
1684 push_list(&mut out, "spawn allow", &self.spawn_allow);
1685 push_list(
1686 &mut out,
1687 "spawn workdir snapshot",
1688 &self.spawn_workdir_snapshot,
1689 );
1690 push_list(&mut out, "env allow", &self.env_allow);
1691 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
1692 out.push_str(&render_install(
1693 &self.install_skill,
1694 &self.install_tool,
1695 &self.install_from,
1696 ));
1697 push_list(
1698 &mut out,
1699 "interpreter runtime",
1700 &self.interpreter_runtime_grants,
1701 );
1702 push_list(
1703 &mut out,
1704 "derived interpreter runtime",
1705 &self.derived_interpreter_runtime,
1706 );
1707 push_list(&mut out, "trust store", &self.trust_store);
1708 push_list(
1709 &mut out,
1710 "declared needs",
1711 &self
1712 .declared_needs
1713 .iter()
1714 .flat_map(DeclaredNeedsReport::render_lines)
1715 .collect::<Vec<_>>(),
1716 );
1717 push_list(
1718 &mut out,
1719 "pinned programs",
1720 &self
1721 .pinned_programs
1722 .iter()
1723 .map(PinnedProgramReport::render_line)
1724 .collect::<Vec<_>>(),
1725 );
1726 push_list(&mut out, "name resolution", &self.name_resolution);
1727 push_list(
1728 &mut out,
1729 "host paths",
1730 &self
1731 .host_paths
1732 .iter()
1733 .map(crate::host_paths::HostPathReport::render)
1734 .collect::<Vec<_>>(),
1735 );
1736 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1737 push_list(
1738 &mut out,
1739 "state stores",
1740 &self
1741 .state_stores
1742 .iter()
1743 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1744 .collect::<Vec<_>>(),
1745 );
1746 push_list(&mut out, "artifact config", &self.configured_artifacts);
1747
1748 if let Some(parent) = &self.parent_workdir {
1749 out.push_str(&render_parent_workdir(parent));
1750 }
1751
1752 if let Some(images) = &self.images {
1753 out.push_str(&render_images(images));
1754 }
1755
1756 out.push_str("\nResource plane\n");
1757 match &self.exports_files {
1758 None => out.push_str(" exports.files: <none>\n"),
1759 Some(export) => {
1760 out.push_str(&format!(" exports.files root: {}\n", export.root));
1761 out.push_str(&format!(" mode: {}\n", export.mode));
1762 out.push_str(&format!(
1763 " max bytes: {} (per file)\n",
1764 export.max_bytes
1765 ));
1766 }
1767 }
1768
1769 out.push_str(&format!(
1770 " io.max: {} ({} bytes/s declared)\n",
1771 match self.io_max.status {
1772 IoMaxStatus::Enforced => "enforced",
1773 IoMaxStatus::Unavailable => "unavailable",
1774 IoMaxStatus::NotRequired => "not-required",
1775 IoMaxStatus::NotProbed => "not-probed",
1776 },
1777 self.io_max.declared_bytes_per_sec
1778 ));
1779 if let Some(reason) = &self.io_max.reason {
1780 out.push_str(&format!(" {reason}\n"));
1781 }
1782
1783 out.push_str("\nPeer handoff\n");
1784 match &self.peer_files {
1785 None => out.push_str(" exports.peer_files: <none>\n"),
1786 Some(peer) => {
1787 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
1788 out.push_str(&format!(
1789 " max handle ttl: {}s\n",
1790 peer.max_ttl_secs
1791 ));
1792 out.push_str(&format!(
1793 " max bytes: {} (per file)\n",
1794 peer.max_bytes
1795 ));
1796 }
1797 }
1798 out.push_str(if self.peer_tasks {
1799 " exports.peer_tasks: accept\n"
1800 } else {
1801 " exports.peer_tasks: refuse\n"
1802 });
1803 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1804
1805 out.push_str("\nRuntime writes\n");
1806 out.push_str(&crate::workdir_writes::render_runtime_writes(
1807 &self.runtime_writes,
1808 ));
1809 out.push_str(&crate::manifest_files::render_manifest_files(
1810 &self.manifest_files,
1811 ));
1812
1813 if !self.floor_met {
1814 out.push_str(
1815 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1816 launch here.\n",
1817 );
1818 } else if self.cgroup_scope.status == CgroupScopeStatus::Unavailable {
1819 out.push_str(
1820 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1821 launch here with E-RUN-012: this capsule can run a subprocess and no \
1822 cgroup scope can be created to bound it (see `cgroup scope:` above).\n",
1823 );
1824 }
1825 out
1826 }
1827}
1828
1829#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1832pub struct ImagesReport {
1833 pub keep: u32,
1835 pub max_bytes: u64,
1837 pub max_per_turn: u32,
1840 pub keep_applies: bool,
1843}
1844
1845impl ScopeReport {
1846 #[must_use]
1849 pub fn with_images(
1850 mut self,
1851 images: murmur_artifact::ContextImagesConfig,
1852 transport: Option<&str>,
1853 ) -> Self {
1854 self.images = Some(ImagesReport {
1855 keep: images.keep,
1856 max_bytes: images.max_bytes,
1857 max_per_turn: images.max_per_turn,
1858 keep_applies: transport != Some("process"),
1859 });
1860 self
1861 }
1862
1863 #[must_use]
1869 pub fn with_routes(mut self, transport: Option<&str>) -> Self {
1870 let inputs = RouteInputs::new(
1871 &self.shell_allow,
1872 self.plan_submit,
1873 &self.install_tool,
1874 &self.preopens,
1875 transport,
1876 );
1877 self.routes = route_table(&inputs, self.enforcement_tier);
1878 self
1879 }
1880
1881 #[must_use]
1884 pub fn with_harness(mut self, report: Option<crate::harness_needs::HarnessReport>) -> Self {
1885 self.harness = report;
1886 self
1887 }
1888
1889 #[must_use]
1892 pub fn with_declared_needs(
1893 mut self,
1894 needs: &[crate::declared_needs::ResolvedProgramNeeds],
1895 ) -> Self {
1896 self.declared_needs = needs.iter().map(DeclaredNeedsReport::from).collect();
1897 self
1898 }
1899
1900 #[must_use]
1903 pub fn with_pinned_programs(
1904 mut self,
1905 programs: &[crate::pinned_programs::ResolvedPinnedProgram],
1906 ) -> Self {
1907 self.pinned_programs = programs.iter().map(PinnedProgramReport::from).collect();
1908 self
1909 }
1910
1911 #[must_use]
1913 pub fn with_manifest_files(
1914 mut self,
1915 rows: Vec<crate::manifest_files::ManifestFileReport>,
1916 ) -> Self {
1917 self.manifest_files = rows;
1918 self
1919 }
1920
1921 #[must_use]
1924 pub fn with_process_view(mut self, view: &crate::proc_view::ProcView) -> Self {
1925 self.process_view = crate::proc_view::process_view_report(view);
1926 self
1927 }
1928
1929 #[must_use]
1931 pub fn with_cgroup_scope(mut self, cgroup_scope: CgroupScopeReport) -> Self {
1932 self.cgroup_scope = cgroup_scope;
1933 self
1934 }
1935
1936 #[must_use]
1940 pub fn with_parent_workdir(
1941 mut self,
1942 snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
1943 ) -> Self {
1944 let restriction = self.filesystem_boundary.restriction;
1945 self.parent_workdir =
1946 snapshot.map(|snapshot| crate::workdir_snapshot::ParentWorkdirReport {
1947 snapshot,
1948 restriction,
1949 });
1950 self
1951 }
1952}
1953
1954#[must_use]
1956pub fn render_images(images: &ImagesReport) -> String {
1957 let mut out = String::from("\nImages\n");
1958 out.push_str(&format!(" max_bytes: {}\n", images.max_bytes));
1959 if images.keep_applies {
1960 out.push_str(&format!(" keep: {}\n", images.keep));
1961 out.push_str(&format!(" max_per_turn: {}\n", images.max_per_turn));
1962 } else {
1963 out.push_str(&format!(
1964 " keep: {} (does nothing here: the harness holds the conversation)\n",
1965 images.keep
1966 ));
1967 out.push_str(&format!(
1968 " max_per_turn: {} (counts the images of one tool call here)\n",
1969 images.max_per_turn
1970 ));
1971 }
1972 out
1973}
1974
1975#[must_use]
1978pub fn render_parent_workdir(parent: &crate::workdir_snapshot::ParentWorkdirReport) -> String {
1979 let snapshot = &parent.snapshot;
1980 let mut out = String::from("\nParent work folder\n");
1981 out.push_str(&format!(" source: {}\n", snapshot.source));
1982 out.push_str(&format!(
1983 " copied: {} files, {} directories, {} symlinks ({} re-pointed), {} bytes\n",
1984 snapshot.files,
1985 snapshot.dirs,
1986 snapshot.symlinks,
1987 snapshot.repointed_symlinks,
1988 snapshot.bytes
1989 ));
1990 if snapshot.skipped_count == 0 {
1991 out.push_str(" skipped: <none>\n");
1992 } else {
1993 out.push_str(&format!(" skipped: {}\n", snapshot.skipped_count));
1994 for entry in &snapshot.skipped {
1995 let reason = serde_json::to_value(entry.reason)
1996 .ok()
1997 .and_then(|value| value.as_str().map(str::to_string))
1998 .unwrap_or_default();
1999 out.push_str(&format!(" - {} ({reason})\n", entry.path));
2000 }
2001 }
2002 out.push_str(&format!(
2003 " restriction: {}\n",
2004 parent.restriction_sentence()
2005 ));
2006 out
2007}
2008
2009#[must_use]
2017pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
2018 if read_only_paths.is_empty() {
2019 return Vec::new();
2020 }
2021 shell_allow
2022 .iter()
2023 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
2024 .cloned()
2025 .collect()
2026}
2027
2028#[must_use]
2036pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
2037 let mut out = String::new();
2038 push_list(&mut out, "read_only", read_only_paths);
2039 if read_only_paths.is_empty() {
2040 return out;
2041 }
2042 if advisory_for.is_empty() {
2043 out.push_str(
2044 " read_only enforcement: enforced for every tool call and every shell command the \
2045 dispatch check can read\n",
2046 );
2047 } else {
2048 out.push_str(&format!(
2049 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
2050 something the dispatch check can read; enforced for every tool call and for every \
2051 shell command it can read\n",
2052 advisory_for.join(", ")
2053 ));
2054 }
2055 out
2056}
2057
2058#[must_use]
2066pub fn render_install(
2067 install_skill: &[String],
2068 install_tool: &[String],
2069 install_from: &[String],
2070) -> String {
2071 let mut out = String::new();
2072 push_list(&mut out, "install skill", install_skill);
2073 push_list(&mut out, "install tool", install_tool);
2074 if !install_from.is_empty() {
2075 out.push_str(&format!(" install from: {}\n", install_from.join(", ")));
2076 }
2077 out
2078}
2079
2080#[must_use]
2082pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
2083 if !crate::install_grant::is_granted(policy) {
2084 return Vec::new();
2085 }
2086 let mut places = vec!["stores".to_string()];
2087 if policy.install_from_sources {
2088 places.push("sources".to_string());
2089 }
2090 places
2091}
2092
2093fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
2101 if boundary.not_protected.is_empty() {
2102 return String::new();
2103 }
2104 let mut out = String::from("\nNot protected here\n");
2105 for statement in &boundary.not_protected {
2106 out.push_str(&format!(" - {statement}\n"));
2107 }
2108 out
2109}
2110
2111fn push_list(out: &mut String, label: &str, values: &[String]) {
2112 if values.is_empty() {
2113 out.push_str(&format!(" {label}: <none>\n"));
2114 return;
2115 }
2116 out.push_str(&format!(" {label}:\n"));
2117 for value in values {
2118 out.push_str(&format!(" - {value}\n"));
2119 }
2120}
2121
2122#[allow(clippy::too_many_arguments)]
2138pub fn explain_scope(
2139 policy: &CapabilityPolicy,
2140 declared: ContainmentClass,
2141 exports: Option<&Exports>,
2142 state_stores: Vec<StateStoreReport>,
2143 configured_artifacts: Vec<String>,
2144 preopens: Vec<PreopenReport>,
2145 io_max: IoMaxReport,
2146 workdir: &std::path::Path,
2147) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
2148 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
2149 let probe = HostProbe::probe();
2150 let decision = ContainmentDecision::decide(
2151 probe.tier(),
2152 declared,
2153 policy.workdir_exec_allowed,
2154 placement.inside_workdir(),
2155 );
2156 let temp_mapping = crate::temp_dir::temp_mapping(
2157 decision.applied_tier(),
2158 probe.mount_namespace_usable(),
2159 &placement.workdir,
2160 );
2161 Ok(scope_report_for_tier(
2162 policy,
2163 &decision,
2164 probe.sealed_shortfall().as_ref(),
2165 probe.userns_grant(),
2166 probe.landlock_kernel_abi(),
2167 exports,
2168 state_stores,
2169 configured_artifacts,
2170 preopens,
2171 &placement,
2172 temp_mapping,
2173 io_max,
2174 )
2175 .with_process_view(&crate::proc_view::proc_view(
2176 decision.applied_tier(),
2177 probe.namespaces(),
2178 )))
2179}
2180
2181pub fn doctor_runtime_dir(
2189 probe: &HostProbe,
2190 declared: ContainmentClass,
2191 workdir: &std::path::Path,
2192) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
2193 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
2194 let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
2196 let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
2197 let temp_mapping =
2198 crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
2199 Ok(DoctorDirReports {
2200 runtime_dir: runtime_dir_report(applied, abi, &placement),
2201 records_dir: records_dir_report(applied, abi, &placement),
2202 temp_dir: temp_dir_report(temp_mapping, &placement),
2203 process_view: crate::proc_view::process_view_report(&crate::proc_view::proc_view(
2204 applied,
2205 probe.namespaces(),
2206 )),
2207 placement,
2208 })
2209}
2210
2211#[derive(Debug, Clone, PartialEq, Eq)]
2213pub struct DoctorDirReports {
2214 pub runtime_dir: RuntimeDirReport,
2215 pub records_dir: RuntimeDirReport,
2216 pub temp_dir: TempDirReport,
2217 pub process_view: crate::proc_view::ProcessViewReport,
2218 pub placement: RuntimeDirPlacement,
2219}
2220
2221#[allow(clippy::too_many_arguments)]
2228pub(crate) fn scope_report_for_tier(
2229 policy: &CapabilityPolicy,
2230 decision: &ContainmentDecision,
2231 sealed_shortfall: Option<&SealedShortfall>,
2232 userns_grant: Option<UsernsGrant>,
2233 landlock_kernel_abi: Option<u8>,
2234 exports: Option<&Exports>,
2235 state_stores: Vec<StateStoreReport>,
2236 configured_artifacts: Vec<String>,
2237 preopens: Vec<PreopenReport>,
2238 runtime_dir_placement: &RuntimeDirPlacement,
2239 temp_mapping: crate::temp_dir::TempMapping,
2240 io_max: IoMaxReport,
2241) -> ScopeReport {
2242 debug_assert_eq!(
2243 *decision,
2244 ContainmentDecision::decide(
2245 decision.host_tier,
2246 decision.declared,
2247 policy.workdir_exec_allowed,
2248 runtime_dir_placement.inside_workdir(),
2249 ),
2250 "the decision must be made from the policy and placement this report states"
2251 );
2252 let declared = decision.declared();
2253 let achieved = decision.achieved();
2254 let applied = decision.applied_tier();
2258 let shortfall_reason = containment_shortfall_reason(
2259 declared,
2260 achieved,
2261 sealed_shortfall,
2262 policy.workdir_exec_allowed,
2263 runtime_dir_placement.inside(),
2264 );
2265 let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
2266 let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
2267 let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
2268 let mut filesystem_boundary = filesystem_boundary(applied);
2269 let enforcement_tier = decision.enforcement_tier();
2270 let routes = route_table(
2272 &RouteInputs::new(
2273 &policy.shell_allow,
2274 policy.plan_submit,
2275 &policy.install_tool,
2276 &preopens,
2277 None,
2278 ),
2279 enforcement_tier,
2280 );
2281 if applied != EnforcementTier::Unconfined {
2283 for report in [&runtime_dir, &records_dir] {
2284 if let Some(reason) = &report.not_protected_because {
2285 filesystem_boundary.not_protected.push(reason.clone());
2286 }
2287 }
2288 }
2289 let unconfined = (applied == EnforcementTier::Unconfined)
2290 .then(|| UnconfinedReport::for_this_process(&policy.declared_resources));
2291
2292 ScopeReport {
2293 declared_containment: declared,
2294 achieved_containment: achieved,
2295 host_containment: decision.host_containment(),
2296 floor_met: shortfall_reason.is_none(),
2297 shortfall_reason,
2298 enforcement_tier,
2299 routes,
2300 userns_grant,
2301 landlock_abi,
2302 exports_files: exports
2305 .and_then(|exports| exports.files.as_ref())
2306 .map(ExportsFilesReport::from),
2307 peer_files: exports
2308 .and_then(|exports| exports.peer_files.as_ref())
2309 .map(PeerFilesReport::from),
2310 peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
2311 peer_fetch_allow: policy.peer_fetch_allow.clone(),
2312 state_stores,
2316 configured_artifacts,
2320 filesystem_scope: policy.filesystem_scope.clone(),
2321 workdir_exec: policy.workdir_exec_allowed,
2322 read_only_paths: policy.read_only_paths.clone(),
2323 read_only_advisory_for: read_only_advisory_for(
2324 &policy.read_only_paths,
2325 &policy.shell_allow,
2326 ),
2327 preopens,
2331 network_allow: policy.network_allow.clone(),
2332 unix_sockets: policy.unix_sockets_allowed,
2333 shell_allow: policy.shell_allow.clone(),
2334 spawn_allow: policy.spawn_allow.clone(),
2335 spawn_workdir_snapshot: policy.spawn_workdir_snapshot.clone(),
2336 parent_workdir: None,
2339 images: None,
2341 env_allow: policy.env_allow.clone(),
2342 plan_submit: policy.plan_submit,
2343 install_skill: policy.install_skill.clone(),
2344 install_tool: policy.install_tool.clone(),
2345 install_from: install_from_places(policy),
2346 interpreter_runtime_grants: policy
2347 .shell_interpreter_runtime
2348 .iter()
2349 .flat_map(|grant| {
2350 grant.dirs.iter().map(move |dir| {
2351 let listable = if dir.list_dir { " (list_dir)" } else { "" };
2352 format!("{}: {}{}", grant.binary, dir.path, listable)
2353 })
2354 })
2355 .collect(),
2356 derived_interpreter_runtime: policy
2357 .shell_derived_interpreter_runtime
2358 .iter()
2359 .flat_map(|runtime| {
2360 let binary = runtime.binary.display();
2361 runtime
2362 .entries()
2363 .map(|(path, is_dir)| {
2364 let listable = if is_dir { " (list_dir)" } else { "" };
2365 format!("{binary}: {}{listable}", path.display())
2366 })
2367 .chain(
2368 runtime
2369 .libraries
2370 .iter()
2371 .map(|library| format!("{binary}: {}", library.display())),
2372 )
2373 .collect::<Vec<_>>()
2374 })
2375 .collect(),
2376 name_resolution: match applied {
2379 EnforcementTier::KernelFull | EnforcementTier::KernelSealed => {
2380 crate::sandbox::SCOPED_NAME_RESOLUTION_PATHS
2381 .iter()
2382 .map(|path| (*path).to_string())
2383 .collect()
2384 }
2385 _ => Vec::new(),
2386 },
2387 declared_needs: policy
2388 .shell_declared_needs
2389 .iter()
2390 .map(DeclaredNeedsReport::from)
2391 .collect(),
2392 pinned_programs: policy
2393 .shell_pinned_programs
2394 .iter()
2395 .map(PinnedProgramReport::from)
2396 .collect(),
2397 trust_store: policy
2398 .shell_trust_store
2399 .iter()
2400 .map(crate::trust_store::TrustStorePath::render)
2401 .collect(),
2402 host_paths: crate::host_paths::host_path_reports(
2405 &policy.host_paths,
2406 crate::host_paths::HostPathMechanism::for_enforcement_tier(decision.enforcement_tier()),
2407 ),
2408 staged_runtime_grants: policy
2409 .shell_staged_runtime
2410 .iter()
2411 .map(|grant| {
2412 format!(
2413 "{}: {} (pin: {})",
2414 grant.binary, grant.source_path, grant.pin
2415 )
2416 })
2417 .collect(),
2418 runtime_writes: crate::workdir_writes::runtime_writes(),
2422 manifest_files: Vec::new(),
2425 io_max,
2429 cgroup_scope: CgroupScopeReport::default(),
2432 filesystem_boundary,
2436 runtime_dir,
2437 records_dir,
2438 temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
2439 process_view: crate::proc_view::process_view_report(&crate::proc_view::tier_proc_view(
2442 applied,
2443 )),
2444 unconfined,
2445 harness: None,
2448 }
2449}
2450
2451#[cfg(test)]
2454#[allow(clippy::too_many_arguments)]
2455pub(crate) fn report_for(
2456 policy: &CapabilityPolicy,
2457 declared: ContainmentClass,
2458 tier: EnforcementTier,
2459 sealed_shortfall: Option<&SealedShortfall>,
2460 userns_grant: Option<UsernsGrant>,
2461 landlock_kernel_abi: Option<u8>,
2462 exports: Option<&Exports>,
2463 state_stores: Vec<StateStoreReport>,
2464 configured_artifacts: Vec<String>,
2465 preopens: Vec<PreopenReport>,
2466 runtime_dir_placement: &RuntimeDirPlacement,
2467 temp_mapping: crate::temp_dir::TempMapping,
2468 io_max: IoMaxReport,
2469) -> ScopeReport {
2470 let decision = ContainmentDecision::decide(
2471 tier,
2472 declared,
2473 policy.workdir_exec_allowed,
2474 runtime_dir_placement.inside_workdir(),
2475 );
2476 scope_report_for_tier(
2477 policy,
2478 &decision,
2479 sealed_shortfall,
2480 userns_grant,
2481 landlock_kernel_abi,
2482 exports,
2483 state_stores,
2484 configured_artifacts,
2485 preopens,
2486 runtime_dir_placement,
2487 temp_mapping,
2488 io_max,
2489 )
2490}
2491
2492pub const HOST_PROCESS_MECHANISM: &str = "host-process";
2495
2496const UNCONFINED_ROUTE_NOTE: &str = "Runs as the operator, with their HOME, whole environment, \
2498 filesystem, network and sudo; nothing holds it.";
2499
2500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2502#[serde(rename_all = "kebab-case")]
2503pub enum Route {
2504 Shell,
2506 PlanShellSteps,
2508 WasmTools,
2510 Drivers,
2512 Hooks,
2514 ProcessHarness,
2516 HarnessTools,
2518}
2519
2520impl Route {
2521 pub const ALL: [Self; 7] = [
2523 Self::Shell,
2524 Self::PlanShellSteps,
2525 Self::WasmTools,
2526 Self::Drivers,
2527 Self::Hooks,
2528 Self::ProcessHarness,
2529 Self::HarnessTools,
2530 ];
2531
2532 #[must_use]
2534 pub fn label(self) -> &'static str {
2535 match self {
2536 Self::Shell => "shell commands",
2537 Self::PlanShellSteps => "plan shell steps",
2538 Self::WasmTools => "WASM tools",
2539 Self::Drivers => "drivers",
2540 Self::Hooks => "hooks",
2541 Self::ProcessHarness => "the process harness",
2542 Self::HarnessTools => "harness tools",
2543 }
2544 }
2545
2546 #[must_use]
2553 pub fn under_class(self) -> bool {
2554 match self {
2555 Self::Shell
2556 | Self::PlanShellSteps
2557 | Self::ProcessHarness
2558 | Self::HarnessTools
2559 | Self::WasmTools
2560 | Self::Drivers
2561 | Self::Hooks => true,
2562 }
2563 }
2564
2565 #[must_use]
2568 pub fn runs_unconfined(self) -> bool {
2569 match self {
2570 Self::Shell | Self::PlanShellSteps | Self::ProcessHarness | Self::HarnessTools => true,
2571 Self::WasmTools | Self::Drivers | Self::Hooks => false,
2572 }
2573 }
2574
2575 #[must_use]
2578 pub fn note(self) -> Option<&'static str> {
2579 match self {
2580 Self::Shell
2581 | Self::PlanShellSteps
2582 | Self::ProcessHarness
2583 | Self::HarnessTools
2584 | Self::WasmTools
2585 | Self::Drivers
2586 | Self::Hooks => None,
2587 }
2588 }
2589}
2590
2591#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2593pub struct RouteReport {
2594 pub route: Route,
2596 pub present: bool,
2598 pub under_class: bool,
2600 pub mechanism: &'static str,
2604 pub note: Option<&'static str>,
2608 pub artifacts: Vec<String>,
2611}
2612
2613#[derive(Debug, Clone, Default, PartialEq, Eq)]
2615pub struct RouteInputs {
2616 pub shell_allow: bool,
2618 pub plan_submit: bool,
2620 pub install_tool: bool,
2622 pub tools: Vec<String>,
2624 pub drivers: Vec<String>,
2626 pub hooks: Vec<String>,
2628 pub process_transport: bool,
2630}
2631
2632impl RouteInputs {
2633 #[must_use]
2636 pub fn new(
2637 shell_allow: &[String],
2638 plan_submit: bool,
2639 install_tool: &[String],
2640 preopens: &[PreopenReport],
2641 transport: Option<&str>,
2642 ) -> Self {
2643 let named = |role: murmur_artifact::ArtifactRuntime| {
2644 preopens
2645 .iter()
2646 .filter(|preopen| preopen.role == role.as_str())
2647 .map(|preopen| preopen.artifact.clone())
2648 .collect::<Vec<_>>()
2649 };
2650 Self {
2651 shell_allow: !shell_allow.is_empty(),
2652 plan_submit,
2653 install_tool: !install_tool.is_empty(),
2654 tools: named(murmur_artifact::ArtifactRuntime::Tool),
2655 drivers: named(murmur_artifact::ArtifactRuntime::Driver),
2656 hooks: named(murmur_artifact::ArtifactRuntime::Hook),
2657 process_transport: transport == Some("process"),
2658 }
2659 }
2660}
2661
2662#[must_use]
2669pub fn route_table(inputs: &RouteInputs, enforcement_tier: &'static str) -> Vec<RouteReport> {
2670 let unconfined = enforcement_tier == enforcement_tier_name(EnforcementTier::Unconfined);
2671 Route::ALL
2672 .into_iter()
2673 .map(|route| {
2674 let (present, artifacts) = match route {
2675 Route::Shell => (inputs.shell_allow, Vec::new()),
2676 Route::PlanShellSteps => (inputs.plan_submit && inputs.shell_allow, Vec::new()),
2678 Route::WasmTools => (
2679 !inputs.tools.is_empty() || inputs.install_tool,
2680 inputs.tools.clone(),
2681 ),
2682 Route::Drivers => (!inputs.drivers.is_empty(), inputs.drivers.clone()),
2683 Route::Hooks => (!inputs.hooks.is_empty(), inputs.hooks.clone()),
2684 Route::ProcessHarness | Route::HarnessTools => {
2685 (inputs.process_transport, Vec::new())
2686 }
2687 };
2688 if unconfined && route.runs_unconfined() {
2689 return RouteReport {
2690 route,
2691 present,
2692 under_class: true,
2693 mechanism: enforcement_tier,
2694 note: Some(UNCONFINED_ROUTE_NOTE),
2695 artifacts,
2696 };
2697 }
2698 let under_class = route.under_class();
2699 RouteReport {
2700 route,
2701 present,
2702 under_class,
2703 mechanism: if under_class {
2704 enforcement_tier
2705 } else {
2706 HOST_PROCESS_MECHANISM
2707 },
2708 note: route.note(),
2709 artifacts,
2710 }
2711 })
2712 .collect()
2713}
2714
2715#[must_use]
2719pub fn render_routes(routes: &[RouteReport]) -> String {
2720 let mut out = String::from("Routes\n");
2721 for row in routes {
2722 let label = match row.route {
2723 Route::ProcessHarness => "process harness",
2724 route => route.label(),
2725 };
2726 let presence = if row.present { "present" } else { "absent" };
2727 let mechanism = if row.under_class {
2728 row.mechanism
2729 } else {
2730 "host process, not confined by the class"
2731 };
2732 let artifacts = if row.artifacts.is_empty() {
2733 String::new()
2734 } else {
2735 format!(" ({})", row.artifacts.join(", "))
2736 };
2737 out.push_str(&format!(
2738 " {label:<17} {presence:<8} {mechanism}{artifacts}\n"
2739 ));
2740 if let (true, Some(note)) = (row.present, row.note) {
2741 out.push_str(&format!(" {note}\n"));
2742 }
2743 }
2744 out
2745}
2746
2747#[must_use]
2752pub fn uncovered_route_warning(
2753 declared: ContainmentClass,
2754 routes: &[RouteReport],
2755) -> Option<String> {
2756 if declared < ContainmentClass::Scoped {
2757 return None;
2758 }
2759 let uncovered: Vec<String> = routes
2760 .iter()
2761 .filter(|row| row.present && !row.under_class)
2762 .map(|row| {
2763 if row.artifacts.is_empty() {
2764 row.route.label().to_string()
2765 } else {
2766 format!("{} ({})", row.route.label(), row.artifacts.join(", "))
2767 }
2768 })
2769 .collect();
2770 let named = match uncovered.as_slice() {
2771 [] => return None,
2772 [only] => only.clone(),
2773 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2774 };
2775 Some(format!(
2776 "the declared {declared} floor does not confine every route this capsule runs code on: \
2777 {named} run outside the class, with none of its Landlock, seccomp or network namespace \
2778 around them (`mur run --explain-scope` lists what holds each under Routes). The class \
2779 confines shell commands, plan shell steps and the guest workers that run WASM tools, \
2780 drivers and hooks; nothing is refused"
2781 ))
2782}
2783
2784pub fn warn_on_uncovered_routes(
2788 declared: ContainmentClass,
2789 routes: &[RouteReport],
2790 bootstrap_log: Option<&crate::workdir_fs::WorkdirRoot>,
2791) {
2792 let Some(message) = uncovered_route_warning(declared, routes) else {
2793 return;
2794 };
2795 let code = murmur_artifact::W_SEC_034;
2796 let link = murmur_artifact::security_warning_link(code);
2797 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2798 if let Some(records) = bootstrap_log {
2799 crate::agent::append_bootstrap_log(
2800 records,
2801 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2802 );
2803 }
2804}
2805
2806pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
2811 ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
2813}
2814
2815pub(crate) fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
2818 match tier {
2819 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
2820 EnforcementTier::KernelFull => "landlock+seccomp",
2821 EnforcementTier::KernelSeccompOnly => "seccomp-only",
2822 EnforcementTier::EnvironmentOnly => "none",
2823 EnforcementTier::Unconfined => "unconfined",
2824 }
2825}
2826
2827#[cfg(test)]
2828#[allow(clippy::print_stdout, clippy::print_stderr)]
2829mod tests {
2830 use super::*;
2831 use crate::sealed::SealedBlocker;
2832
2833 fn blocked(blocker: SealedBlocker) -> SealedShortfall {
2835 SealedShortfall {
2836 blocker,
2837 failed_call: None,
2838 errno: None,
2839 userns_grant: UsernsGrant::Withheld,
2840 attachment: crate::sealed::ProfileAttachment::NotReported,
2841 profile_gap: crate::sealed::ProfileGap::Undetermined,
2842 }
2843 }
2844 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
2845
2846 const ALL_TIERS: &[EnforcementTier] = &[
2848 EnforcementTier::KernelSealed,
2849 EnforcementTier::KernelFull,
2850 EnforcementTier::KernelSeccompOnly,
2851 EnforcementTier::EnvironmentOnly,
2852 ];
2853
2854 #[test]
2855 fn kernel_full_achieves_scoped() {
2856 assert_eq!(
2857 achieved_class_for_tier(EnforcementTier::KernelFull),
2858 ContainmentClass::Scoped
2859 );
2860 }
2861
2862 #[test]
2864 fn only_the_sealed_tier_achieves_sealed() {
2865 assert_eq!(
2866 achieved_class_for_tier(EnforcementTier::KernelSealed),
2867 ContainmentClass::Sealed
2868 );
2869 for tier in ALL_TIERS
2870 .iter()
2871 .filter(|t| **t != EnforcementTier::KernelSealed)
2872 {
2873 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
2874 }
2875 }
2876
2877 #[test]
2878 fn tiers_without_landlock_achieve_only_advisory() {
2879 assert_eq!(
2880 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
2881 ContainmentClass::Advisory
2882 );
2883 assert_eq!(
2884 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
2885 ContainmentClass::Advisory
2886 );
2887 }
2888
2889 #[test]
2895 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
2896 assert_eq!(
2898 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
2899 ContainmentClass::Sealed
2900 );
2901 assert_eq!(
2902 achieved_containment_class(EnforcementTier::KernelFull, false, false),
2903 ContainmentClass::Scoped
2904 );
2905 assert_eq!(
2906 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
2907 ContainmentClass::Advisory
2908 );
2909 assert_eq!(
2910 achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
2911 ContainmentClass::Advisory
2912 );
2913
2914 for tier in ALL_TIERS {
2916 assert_eq!(
2917 achieved_containment_class(*tier, true, false),
2918 ContainmentClass::Advisory,
2919 "workdir_exec must cap tier {tier:?} at advisory"
2920 );
2921 }
2922 }
2923
2924 #[test]
2927 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
2928 for tier in ALL_TIERS {
2929 assert_eq!(
2930 achieved_class_for_tier(*tier),
2931 achieved_containment_class(*tier, false, false),
2932 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
2933 );
2934 }
2935 }
2936
2937 #[test]
2941 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
2942 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
2943 assert_eq!(achieved, ContainmentClass::Advisory);
2944
2945 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
2946 .expect_err("workdir_exec + scoped must refuse");
2947 match error {
2948 RuntimeError::ContainmentFloorUnmet {
2949 declared,
2950 achieved,
2951 reason,
2952 ..
2953 } => {
2954 assert_eq!(declared, ContainmentClass::Scoped);
2955 assert_eq!(achieved, ContainmentClass::Advisory);
2956 assert!(
2957 reason.contains("workdir_exec"),
2958 "the reason must name the manifest key, got: {reason}"
2959 );
2960 assert!(
2961 !reason.contains("this host provides no kernel filesystem mediation"),
2962 "the reason must not blame the host for a manifest declaration: {reason}"
2963 );
2964 }
2965 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2966 }
2967 }
2968
2969 #[test]
2973 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
2974 let reason = containment_shortfall_reason(
2975 ContainmentClass::Sealed,
2976 ContainmentClass::Advisory,
2977 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2978 true,
2979 None,
2980 )
2981 .expect("refusal");
2982 assert!(reason.contains("workdir_exec"));
2983 assert!(!reason.contains("apparmor_parser"));
2984 }
2985
2986 #[test]
2989 fn workdir_exec_alone_refuses_nothing() {
2990 for tier in ALL_TIERS {
2991 let achieved = achieved_containment_class(*tier, true, false);
2992 assert!(
2993 check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
2994 .is_ok(),
2995 "workdir_exec at an advisory floor must launch on tier {tier:?}"
2996 );
2997 }
2998 }
2999
3000 #[test]
3001 fn advisory_floor_is_met_on_every_tier() {
3002 for tier in ALL_TIERS {
3003 let achieved = achieved_class_for_tier(*tier);
3004 assert!(check_containment_floor(
3005 ContainmentClass::Advisory,
3006 achieved,
3007 None,
3008 false,
3009 None
3010 )
3011 .is_ok());
3012 }
3013 }
3014
3015 #[test]
3016 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
3017 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
3018 let achieved = achieved_class_for_tier(tier);
3019 assert!(
3020 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
3021 .is_ok()
3022 );
3023 }
3024
3025 for tier in [
3026 EnforcementTier::KernelSeccompOnly,
3027 EnforcementTier::EnvironmentOnly,
3028 ] {
3029 let achieved = achieved_class_for_tier(tier);
3030 let error =
3031 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
3032 .expect_err("scoped must refuse without Landlock");
3033 match error {
3034 RuntimeError::ContainmentFloorUnmet {
3035 declared,
3036 achieved,
3037 reason,
3038 ..
3039 } => {
3040 assert_eq!(declared, ContainmentClass::Scoped);
3041 assert_eq!(achieved, ContainmentClass::Advisory);
3042 assert!(
3043 reason.contains("Landlock"),
3044 "reason must name the missing mechanism, got: {reason}"
3045 );
3046 }
3047 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3048 }
3049 }
3050 }
3051
3052 #[test]
3056 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
3057 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
3058 let decision =
3059 ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
3060 assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
3061 assert_eq!(decision.achieved(), ContainmentClass::Scoped);
3062 assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
3063 assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
3064 assert!(
3065 check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
3066 );
3067 assert_eq!(
3068 containment_shortfall_reason(declared, decision.achieved(), None, false, None),
3069 None
3070 );
3071 }
3072 }
3073
3074 const ALL_CLASSES: &[ContainmentClass] = &[
3075 ContainmentClass::Advisory,
3076 ContainmentClass::Scoped,
3077 ContainmentClass::Sealed,
3078 ];
3079
3080 #[test]
3084 fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
3085 use ContainmentClass::{Advisory, Scoped, Sealed};
3086 use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
3087 let table = [
3089 (
3090 (KernelSealed, Advisory),
3091 (KernelFull, Scoped, "landlock+seccomp"),
3092 ),
3093 (
3094 (KernelSealed, Scoped),
3095 (KernelFull, Scoped, "landlock+seccomp"),
3096 ),
3097 (
3098 (KernelSealed, Sealed),
3099 (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
3100 ),
3101 (
3102 (KernelFull, Advisory),
3103 (KernelFull, Scoped, "landlock+seccomp"),
3104 ),
3105 (
3106 (KernelFull, Scoped),
3107 (KernelFull, Scoped, "landlock+seccomp"),
3108 ),
3109 (
3110 (KernelFull, Sealed),
3111 (KernelFull, Scoped, "landlock+seccomp"),
3112 ),
3113 (
3114 (KernelSeccompOnly, Advisory),
3115 (KernelSeccompOnly, Advisory, "seccomp-only"),
3116 ),
3117 (
3118 (KernelSeccompOnly, Scoped),
3119 (KernelSeccompOnly, Advisory, "seccomp-only"),
3120 ),
3121 (
3122 (KernelSeccompOnly, Sealed),
3123 (KernelSeccompOnly, Advisory, "seccomp-only"),
3124 ),
3125 (
3126 (EnvironmentOnly, Advisory),
3127 (EnvironmentOnly, Advisory, "none"),
3128 ),
3129 (
3130 (EnvironmentOnly, Scoped),
3131 (EnvironmentOnly, Advisory, "none"),
3132 ),
3133 (
3134 (EnvironmentOnly, Sealed),
3135 (EnvironmentOnly, Advisory, "none"),
3136 ),
3137 ];
3138 assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
3139 for ((host_tier, declared), (applied, class, wire)) in table {
3140 let host_class = match host_tier {
3141 KernelSealed => Sealed,
3142 KernelFull => Scoped,
3143 KernelSeccompOnly | EnvironmentOnly => Advisory,
3144 EnforcementTier::Unconfined => unreachable!("never a host tier"),
3145 };
3146 for workdir_exec in [false, true] {
3147 for inside in [false, true] {
3148 let decision =
3149 ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
3150 let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
3151 assert_eq!(decision.declared(), declared, "{what}");
3152 assert_eq!(decision.applied_tier(), applied, "{what}");
3153 assert_eq!(decision.enforcement_tier(), wire, "{what}");
3154 assert_eq!(decision.host_containment(), host_class, "{what}");
3155 let expected = if workdir_exec || inside {
3156 Advisory
3157 } else {
3158 class
3159 };
3160 assert_eq!(decision.achieved(), expected, "{what}");
3161 assert!(decision.achieved() <= decision.host_containment(), "{what}");
3164 assert!(
3165 decision.achieved() != Sealed || declared == Sealed,
3166 "{what}"
3167 );
3168 }
3169 }
3170 }
3171
3172 let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
3174 assert_eq!(
3175 (
3176 scoped.achieved(),
3177 scoped.enforcement_tier(),
3178 scoped.host_containment()
3179 ),
3180 (Scoped, "landlock+seccomp", Sealed)
3181 );
3182 let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
3183 assert_eq!(
3184 (sealed.achieved(), sealed.enforcement_tier()),
3185 (Sealed, "mountns+pivot_root+landlock+seccomp")
3186 );
3187 let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
3188 assert_eq!(
3189 (advisory.achieved(), advisory.enforcement_tier()),
3190 (Scoped, "landlock+seccomp")
3191 );
3192 }
3193
3194 #[test]
3198 fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
3199 for &host_tier in ALL_TIERS {
3200 for &declared in ALL_CLASSES {
3201 for workdir_exec in [false, true] {
3202 for inside in [None, Some(inside_placement())] {
3203 let in_workdir = inside.is_some();
3204 let decision = ContainmentDecision::decide(
3205 host_tier,
3206 declared,
3207 workdir_exec,
3208 in_workdir,
3209 );
3210 let host_class =
3211 achieved_containment_class(host_tier, workdir_exec, in_workdir);
3212 let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
3213 assert_eq!(
3214 containment_shortfall_reason(
3215 declared,
3216 decision.achieved(),
3217 Some(&shortfall),
3218 workdir_exec,
3219 inside.as_ref(),
3220 ),
3221 containment_shortfall_reason(
3222 declared,
3223 host_class,
3224 Some(&shortfall),
3225 workdir_exec,
3226 inside.as_ref(),
3227 ),
3228 "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
3229 );
3230 }
3231 }
3232 }
3233 }
3234 }
3235
3236 #[test]
3237 fn sealed_floor_is_met_only_on_the_sealed_tier() {
3238 assert!(check_containment_floor(
3239 ContainmentClass::Sealed,
3240 achieved_class_for_tier(EnforcementTier::KernelSealed),
3241 None,
3242 false,
3243 None
3244 )
3245 .is_ok());
3246
3247 for tier in ALL_TIERS
3248 .iter()
3249 .filter(|t| **t != EnforcementTier::KernelSealed)
3250 {
3251 let achieved = achieved_class_for_tier(*tier);
3252 let error = check_containment_floor(
3253 ContainmentClass::Sealed,
3254 achieved,
3255 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3256 false,
3257 None,
3258 )
3259 .expect_err("sealed must refuse on a host that cannot back it");
3260 match error {
3261 RuntimeError::ContainmentFloorUnmet {
3262 declared, reason, ..
3263 } => {
3264 assert_eq!(declared, ContainmentClass::Sealed);
3265 assert!(
3266 reason.contains("seccomp profile") && !reason.contains("--cap-add"),
3267 "reason must name the blocking mechanism's remediation, got: {reason}"
3268 );
3269 }
3270 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3271 }
3272 }
3273 }
3274
3275 #[test]
3278 fn the_sealed_refusal_names_the_specific_blocker() {
3279 let achieved = ContainmentClass::Scoped;
3280
3281 let apparmor = containment_shortfall_reason(
3282 ContainmentClass::Sealed,
3283 achieved,
3284 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3285 false,
3286 None,
3287 )
3288 .expect("refusal");
3289 assert!(apparmor.contains("mur-sealed"));
3290 assert!(apparmor.contains("apparmor_parser -r"));
3291 assert!(!apparmor.contains("--cap-add"));
3292
3293 let container = containment_shortfall_reason(
3294 ContainmentClass::Sealed,
3295 achieved,
3296 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3297 false,
3298 None,
3299 )
3300 .expect("refusal");
3301 assert!(!container.contains("--cap-add"), "{container}");
3302 assert!(container.contains("seccomp profile"), "{container}");
3303 assert!(container.contains("unshare(CLONE_NEWUSER)"), "{container}");
3304 assert!(container.contains("--security-opt seccomp="), "{container}");
3305 assert!(
3306 container.contains("seccompProfile: Localhost"),
3307 "{container}"
3308 );
3309 assert!(container.contains("outside the container"));
3310 assert!(!container.contains("apparmor_parser"));
3311
3312 let pivot = containment_shortfall_reason(
3315 ContainmentClass::Sealed,
3316 ContainmentClass::Advisory,
3317 Some(&blocked(SealedBlocker::PivotRootDenied)),
3318 false,
3319 None,
3320 )
3321 .expect("refusal");
3322 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
3323 assert_ne!(
3324 pivot,
3325 containment_shortfall_reason(
3326 ContainmentClass::Sealed,
3327 ContainmentClass::Advisory,
3328 Some(&blocked(SealedBlocker::MountDenied)),
3329 false,
3330 None,
3331 )
3332 .expect("refusal")
3333 );
3334
3335 for blocker in [
3337 SealedBlocker::NotLinux,
3338 SealedBlocker::AppArmorProfileMissing,
3339 SealedBlocker::NamespaceCreationDenied,
3340 SealedBlocker::MountDenied,
3341 SealedBlocker::PivotRootDenied,
3342 SealedBlocker::KernelUnsupported,
3343 SealedBlocker::LandlockUnavailable,
3344 ] {
3345 let reason = containment_shortfall_reason(
3346 ContainmentClass::Sealed,
3347 achieved,
3348 Some(&blocked(blocker)),
3349 false,
3350 None,
3351 )
3352 .expect("refusal");
3353 assert!(
3354 !reason.contains("no host can provide it today"),
3355 "got: {reason}"
3356 );
3357 }
3358 }
3359
3360 #[test]
3363 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
3364 let reason = containment_shortfall_reason(
3365 ContainmentClass::Sealed,
3366 ContainmentClass::Advisory,
3367 None,
3368 false,
3369 None,
3370 )
3371 .expect("refusal");
3372 assert!(reason.contains("pivot_root"));
3373 assert!(reason.contains("CLONE_NEWNS"));
3374 }
3375
3376 #[test]
3377 fn shortfall_reason_is_absent_when_the_floor_is_met() {
3378 assert_eq!(
3379 containment_shortfall_reason(
3380 ContainmentClass::Advisory,
3381 ContainmentClass::Scoped,
3382 None,
3383 false,
3384 None
3385 ),
3386 None
3387 );
3388 assert_eq!(
3389 containment_shortfall_reason(
3390 ContainmentClass::Scoped,
3391 ContainmentClass::Scoped,
3392 None,
3393 false,
3394 None
3395 ),
3396 None
3397 );
3398 assert_eq!(
3399 containment_shortfall_reason(
3400 ContainmentClass::Sealed,
3401 ContainmentClass::Sealed,
3402 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3403 false,
3404 None
3405 ),
3406 None,
3407 "a met floor must ignore a stale blocker rather than manufacture a refusal"
3408 );
3409 }
3410
3411 fn sample_policy() -> CapabilityPolicy {
3412 CapabilityPolicy {
3413 network_allow: vec!["https://api.example.com".to_string()],
3414 unix_sockets_allowed: false,
3415 filesystem_scope: Some("workdir".to_string()),
3416 shell_allow: vec!["python3".to_string()],
3417 spawn_allow: vec!["helper".to_string()],
3418 env_allow: vec!["TZ".to_string()],
3419 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3420 binary: "python3".to_string(),
3421 dirs: vec![InterpreterRuntimeDir {
3422 path: "/usr/lib/python3.11".to_string(),
3423 list_dir: true,
3424 }],
3425 }],
3426 ..CapabilityPolicy::default()
3427 }
3428 }
3429
3430 #[test]
3434 fn an_unconfined_report_names_no_protection_it_does_not_install() {
3435 for host_tier in [
3436 EnforcementTier::KernelSealed,
3437 EnforcementTier::KernelFull,
3438 EnforcementTier::KernelSeccompOnly,
3439 EnforcementTier::EnvironmentOnly,
3440 ] {
3441 let decision =
3442 ContainmentDecision::decide(host_tier, ContainmentClass::Unconfined, true, true);
3443 assert_eq!(decision.applied_tier(), EnforcementTier::Unconfined);
3444 assert_eq!(decision.achieved(), ContainmentClass::Unconfined);
3445 assert_eq!(decision.enforcement_tier(), "unconfined");
3446 assert_eq!(
3447 decision.host_containment(),
3448 achieved_class_for_tier(host_tier)
3449 );
3450 }
3451 let policy = CapabilityPolicy {
3452 containment_floor: ContainmentClass::Unconfined,
3453 declared_resources: murmur_artifact::ResourceCapabilities {
3454 max_open_files: Some(256),
3455 ..Default::default()
3456 },
3457 ..sample_policy()
3458 };
3459 let report = report_for(
3460 &policy,
3461 ContainmentClass::Unconfined,
3462 EnforcementTier::KernelFull,
3463 None,
3464 None,
3465 Some(4),
3466 None,
3467 Vec::new(),
3468 Vec::new(),
3469 Vec::new(),
3470 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3471 crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
3472 IoMaxReport::default(),
3473 );
3474 assert!(report.floor_met);
3475 assert_eq!(report.achieved_containment, ContainmentClass::Unconfined);
3476 assert_eq!(report.enforcement_tier, "unconfined");
3477 for row in &report.routes {
3479 if row.route.runs_unconfined() {
3480 assert!(row.under_class, "{row:?}");
3481 assert_eq!(row.mechanism, "unconfined", "{row:?}");
3482 assert_eq!(row.note, Some(UNCONFINED_ROUTE_NOTE), "{row:?}");
3483 } else {
3484 assert_eq!(row.note, row.route.note(), "{row:?}");
3485 }
3486 }
3487 assert_eq!(
3488 uncovered_route_warning(report.declared_containment, &report.routes),
3489 None,
3490 "W-SEC-034 keys on a scoped or sealed floor"
3491 );
3492 assert_eq!(report.landlock_abi, None);
3493 assert_eq!(
3494 report.filesystem_boundary.restriction,
3495 FilesystemRestriction::None
3496 );
3497 assert_eq!(
3498 report.filesystem_boundary.not_protected,
3499 vec![UNCONFINED_NOT_PROTECTED.to_string()]
3500 );
3501 for dir in [&report.runtime_dir, &report.records_dir] {
3502 assert!(!dir.read_only_to_commands);
3503 assert_eq!(dir.enforced_by, None);
3504 assert!(dir
3505 .not_protected_because
3506 .as_deref()
3507 .unwrap()
3508 .contains("unconfined"));
3509 }
3510 assert!(!report.temp_dir.tmp_mapped);
3511 assert_eq!(
3512 report.temp_dir.not_mapped_because.as_deref(),
3513 Some(crate::temp_dir::NotMappedReason::Unconfined.describe())
3514 );
3515 let unconfined = report.unconfined.as_ref().expect("an unconfined report");
3516 assert_eq!(unconfined.uid, current_uid());
3517 assert_eq!(unconfined.applies_to, UNCONFINED_APPLIES_TO.to_vec());
3518 assert_eq!(unconfined.resource_limits, vec!["max_open_files"]);
3519 let json = serde_json::to_value(&report).unwrap();
3520 assert_eq!(json["enforcement_tier"], "unconfined");
3521 assert_eq!(json["achieved_containment"], "unconfined");
3522 assert_eq!(json["filesystem_boundary"]["restriction"], "none");
3523 assert!(json["unconfined"].is_object(), "{json}");
3524 let rendered = report.render();
3525 assert!(rendered.contains(" achieved: unconfined\n"), "{rendered}");
3526 assert!(rendered.contains("\nUnconfined\n user:"), "{rendered}");
3527 assert!(rendered.contains(" uid: "), "{rendered}");
3528 assert!(rendered.contains(" HOME: "), "{rendered}");
3529 assert!(
3530 rendered.contains(
3531 " applies to: shell commands, plan shell steps, process harness, harness tools\n"
3532 ),
3533 "{rendered}"
3534 );
3535 assert!(unconfined_warning(unconfined).contains("sudo"));
3536
3537 let contained = report_for(
3539 &sample_policy(),
3540 ContainmentClass::Scoped,
3541 EnforcementTier::KernelFull,
3542 None,
3543 None,
3544 None,
3545 None,
3546 Vec::new(),
3547 Vec::new(),
3548 Vec::new(),
3549 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3550 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3551 IoMaxReport::default(),
3552 );
3553 let json = serde_json::to_value(&contained).unwrap();
3554 assert!(json["unconfined"].is_null(), "{json}");
3555 assert!(json.as_object().unwrap().contains_key("unconfined"));
3556 }
3557
3558 #[test]
3559 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
3560 let report = report_for(
3561 &sample_policy(),
3562 ContainmentClass::Scoped,
3563 EnforcementTier::KernelFull,
3564 None,
3565 None,
3566 None,
3567 None,
3568 Vec::new(),
3569 Vec::new(),
3570 Vec::new(),
3571 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3572 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3573 EnforcementTier::KernelFull,
3574 ContainmentClass::Scoped,
3575 )),
3576 IoMaxReport::default(),
3577 );
3578
3579 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
3580 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3581 assert!(report.floor_met);
3582 assert_eq!(report.shortfall_reason, None);
3583 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3584 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
3585 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
3586 assert!(!report.unix_sockets);
3587 assert_eq!(report.shell_allow, vec!["python3"]);
3588 assert_eq!(report.spawn_allow, vec!["helper"]);
3589 assert_eq!(report.env_allow, vec!["TZ"]);
3590 assert!(!report.workdir_exec);
3591 assert_eq!(
3592 report.interpreter_runtime_grants,
3593 vec!["python3: /usr/lib/python3.11 (list_dir)"]
3594 );
3595 }
3596
3597 #[test]
3601 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
3602 let policy = CapabilityPolicy {
3603 shell_allow: vec!["gcc".to_string()],
3604 workdir_exec_allowed: true,
3605 ..CapabilityPolicy::default()
3606 };
3607
3608 let report = report_for(
3609 &policy,
3610 ContainmentClass::Scoped,
3611 EnforcementTier::KernelFull,
3612 None,
3613 None,
3614 None,
3615 None,
3616 Vec::new(),
3617 Vec::new(),
3618 Vec::new(),
3619 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3620 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3621 EnforcementTier::KernelFull,
3622 ContainmentClass::Scoped,
3623 )),
3624 IoMaxReport::default(),
3625 );
3626
3627 assert!(report.workdir_exec);
3628 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3629 assert!(!report.floor_met);
3630 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3633 assert!(report
3634 .shortfall_reason
3635 .as_deref()
3636 .expect("refusal")
3637 .contains("workdir_exec"));
3638
3639 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3640 assert_eq!(value["workdir_exec"], true);
3641
3642 let rendered = report.render();
3643 assert!(rendered.contains("workdir exec: true"));
3644 assert!(rendered.contains("achieved: advisory"));
3645 }
3646
3647 #[test]
3651 fn scope_report_json_always_carries_io_max() {
3652 let unprobed: serde_json::Value = serde_json::to_value(report_for(
3653 &sample_policy(),
3654 ContainmentClass::Scoped,
3655 EnforcementTier::KernelFull,
3656 None,
3657 None,
3658 None,
3659 None,
3660 Vec::new(),
3661 Vec::new(),
3662 Vec::new(),
3663 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3664 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3665 EnforcementTier::KernelFull,
3666 ContainmentClass::Scoped,
3667 )),
3668 IoMaxReport::default(),
3669 ))
3670 .unwrap();
3671 assert_eq!(unprobed["io_max"]["status"], "not-probed");
3672 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
3673 assert!(unprobed["io_max"].get("reason").is_none());
3674
3675 let enforced: serde_json::Value = serde_json::to_value(report_for(
3676 &sample_policy(),
3677 ContainmentClass::Scoped,
3678 EnforcementTier::KernelFull,
3679 None,
3680 None,
3681 None,
3682 None,
3683 Vec::new(),
3684 Vec::new(),
3685 Vec::new(),
3686 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3687 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3688 EnforcementTier::KernelFull,
3689 ContainmentClass::Scoped,
3690 )),
3691 IoMaxReport {
3692 declared_bytes_per_sec: 52_428_800,
3693 status: IoMaxStatus::Enforced,
3694 reason: None,
3695 },
3696 ))
3697 .unwrap();
3698 assert_eq!(enforced["io_max"]["status"], "enforced");
3699 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
3700 }
3701
3702 #[test]
3706 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
3707 let rendered = report_for(
3708 &sample_policy(),
3709 ContainmentClass::Scoped,
3710 EnforcementTier::KernelFull,
3711 None,
3712 None,
3713 None,
3714 None,
3715 Vec::new(),
3716 Vec::new(),
3717 Vec::new(),
3718 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3719 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3720 EnforcementTier::KernelFull,
3721 ContainmentClass::Scoped,
3722 )),
3723 IoMaxReport {
3724 declared_bytes_per_sec: 52_428_800,
3725 status: IoMaxStatus::Unavailable,
3726 reason: Some("the io controller is not delegated".to_string()),
3727 },
3728 )
3729 .render();
3730
3731 assert!(rendered.contains("Resource plane"), "{rendered}");
3732 assert!(rendered.contains("io.max:"), "{rendered}");
3733 assert!(rendered.contains("unavailable"), "{rendered}");
3734 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
3735 assert!(
3736 rendered.contains("the io controller is not delegated"),
3737 "{rendered}"
3738 );
3739 }
3740
3741 #[test]
3744 fn scope_report_json_always_carries_workdir_exec() {
3745 let value: serde_json::Value = serde_json::to_value(report_for(
3746 &sample_policy(),
3747 ContainmentClass::Scoped,
3748 EnforcementTier::KernelFull,
3749 None,
3750 None,
3751 None,
3752 None,
3753 Vec::new(),
3754 Vec::new(),
3755 Vec::new(),
3756 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3757 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3758 EnforcementTier::KernelFull,
3759 ContainmentClass::Scoped,
3760 )),
3761 IoMaxReport::default(),
3762 ))
3763 .unwrap();
3764 assert_eq!(value["workdir_exec"], false);
3765 }
3766
3767 #[test]
3771 fn staged_runtime_grants_are_reported_on_every_tier() {
3772 let policy = CapabilityPolicy {
3773 shell_allow: vec!["python3".to_string()],
3774 shell_staged_runtime: vec![StagedRuntimeGrant {
3775 binary: "python3".to_string(),
3776 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3777 pin: "conda-4.10.3/python-3.9.19".to_string(),
3778 }],
3779 ..CapabilityPolicy::default()
3780 };
3781
3782 for tier in ALL_TIERS {
3783 let report = report_for(
3784 &policy,
3785 ContainmentClass::Sealed,
3786 *tier,
3787 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3788 None,
3789 None,
3790 None,
3791 Vec::new(),
3792 Vec::new(),
3793 Vec::new(),
3794 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3795 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3796 *tier,
3797 ContainmentClass::Sealed,
3798 )),
3799 IoMaxReport::default(),
3800 );
3801 assert_eq!(
3802 report.staged_runtime_grants,
3803 vec![
3804 "python3: /opt/testbed/conda/envs/django__django \
3805 (pin: conda-4.10.3/python-3.9.19)"
3806 ],
3807 "tier {tier:?} must still report the declared staged runtime"
3808 );
3809 assert!(
3810 report.render().contains("staged runtime"),
3811 "the rendered report must carry a staged runtime section on tier {tier:?}"
3812 );
3813 }
3814 }
3815
3816 #[test]
3819 fn absent_staged_runtime_reports_nothing() {
3820 let report = report_for(
3821 &sample_policy(),
3822 ContainmentClass::Scoped,
3823 EnforcementTier::KernelFull,
3824 None,
3825 None,
3826 None,
3827 None,
3828 Vec::new(),
3829 Vec::new(),
3830 Vec::new(),
3831 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3832 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3833 EnforcementTier::KernelFull,
3834 ContainmentClass::Scoped,
3835 )),
3836 IoMaxReport::default(),
3837 );
3838 assert!(report.staged_runtime_grants.is_empty());
3839 assert!(report.render().contains("staged runtime: <none>"));
3840 }
3841
3842 #[test]
3846 fn grant_set_never_raises_the_achieved_class() {
3847 let report = report_for(
3848 &sample_policy(),
3849 ContainmentClass::Sealed,
3850 EnforcementTier::KernelFull,
3851 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3852 None,
3853 None,
3854 None,
3855 Vec::new(),
3856 Vec::new(),
3857 Vec::new(),
3858 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3859 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3860 EnforcementTier::KernelFull,
3861 ContainmentClass::Sealed,
3862 )),
3863 IoMaxReport::default(),
3864 );
3865 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3866 assert!(!report.floor_met);
3867 }
3868
3869 #[test]
3870 fn scope_report_still_reports_when_the_floor_is_unmet() {
3871 let report = report_for(
3872 &sample_policy(),
3873 ContainmentClass::Sealed,
3874 EnforcementTier::EnvironmentOnly,
3875 Some(&blocked(SealedBlocker::NotLinux)),
3876 None,
3877 None,
3878 None,
3879 Vec::new(),
3880 Vec::new(),
3881 Vec::new(),
3882 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3883 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3884 EnforcementTier::EnvironmentOnly,
3885 ContainmentClass::Sealed,
3886 )),
3887 IoMaxReport::default(),
3888 );
3889
3890 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
3891 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3892 assert!(!report.floor_met);
3893 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
3894 assert_eq!(report.enforcement_tier, "none");
3895 }
3896
3897 #[test]
3898 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
3899 let report = report_for(
3900 &sample_policy(),
3901 ContainmentClass::Sealed,
3902 EnforcementTier::KernelSealed,
3903 None,
3904 None,
3905 None,
3906 None,
3907 Vec::new(),
3908 Vec::new(),
3909 Vec::new(),
3910 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3911 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3912 EnforcementTier::KernelSealed,
3913 ContainmentClass::Sealed,
3914 )),
3915 IoMaxReport::default(),
3916 );
3917 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
3918 assert_eq!(report.host_containment, ContainmentClass::Sealed);
3919 assert!(report.floor_met);
3920 assert_eq!(
3921 report.enforcement_tier,
3922 "mountns+pivot_root+landlock+seccomp"
3923 );
3924 assert_eq!(
3925 report.filesystem_boundary.restriction,
3926 FilesystemRestriction::Absent
3927 );
3928 }
3929
3930 #[test]
3934 fn every_report_field_follows_the_decision() {
3935 for &host_tier in ALL_TIERS {
3936 for &declared in ALL_CLASSES {
3937 let decision = ContainmentDecision::decide(host_tier, declared, false, false);
3938 let report = scope_report_for_tier(
3939 &sample_policy(),
3940 &decision,
3941 None,
3942 None,
3943 Some(6),
3944 None,
3945 Vec::new(),
3946 Vec::new(),
3947 Vec::new(),
3948 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3949 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3950 IoMaxReport::default(),
3951 );
3952 let what = format!("{host_tier:?} {declared}");
3953 assert_eq!(report.declared_containment, declared, "{what}");
3954 assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
3955 assert_eq!(
3956 report.enforcement_tier,
3957 decision.enforcement_tier(),
3958 "{what}"
3959 );
3960 assert_eq!(
3961 report.host_containment,
3962 decision.host_containment(),
3963 "{what}"
3964 );
3965 assert_eq!(
3966 report.landlock_abi,
3967 crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
3968 "{what}"
3969 );
3970 let restriction = filesystem_boundary(decision.applied_tier()).restriction;
3971 assert_eq!(
3972 report.filesystem_boundary.restriction, restriction,
3973 "{what}"
3974 );
3975 assert_eq!(
3976 restriction,
3977 match report.enforcement_tier {
3978 "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
3979 "landlock+seccomp" => FilesystemRestriction::Enforced,
3980 _ => FilesystemRestriction::Advisory,
3981 },
3982 "{what}"
3983 );
3984 assert_eq!(
3985 report.temp_dir,
3986 temp_dir_report(
3987 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3988 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3989 ),
3990 "{what}"
3991 );
3992 }
3993 }
3994 }
3995
3996 #[test]
3999 fn host_containment_is_reported_beside_the_achieved_class() {
4000 let report = report_for(
4001 &sample_policy(),
4002 ContainmentClass::Scoped,
4003 EnforcementTier::KernelSealed,
4004 None,
4005 None,
4006 None,
4007 None,
4008 Vec::new(),
4009 Vec::new(),
4010 Vec::new(),
4011 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4012 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
4013 IoMaxReport::default(),
4014 );
4015 let value = serde_json::to_value(&report).unwrap();
4016 assert_eq!(value["achieved_containment"], "scoped");
4017 assert_eq!(value["host_containment"], "sealed");
4018 assert_eq!(value["enforcement_tier"], "landlock+seccomp");
4019 assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
4020
4021 let rendered = report.render();
4022 assert!(
4023 rendered.contains(" achieved: scoped\n host can back: sealed\n floor met: yes\n"),
4024 "{rendered}"
4025 );
4026 assert!(
4027 rendered.contains(" mechanism: landlock+seccomp\n"),
4028 "{rendered}"
4029 );
4030 }
4031
4032 #[test]
4033 fn scope_report_json_uses_wire_names() {
4034 let report = report_for(
4035 &CapabilityPolicy::default(),
4036 ContainmentClass::Advisory,
4037 EnforcementTier::KernelSeccompOnly,
4038 None,
4039 None,
4040 None,
4041 None,
4042 Vec::new(),
4043 Vec::new(),
4044 Vec::new(),
4045 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4046 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4047 EnforcementTier::KernelSeccompOnly,
4048 ContainmentClass::Advisory,
4049 )),
4050 IoMaxReport::default(),
4051 );
4052 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
4053
4054 assert_eq!(value["declared_containment"], "advisory");
4055 assert_eq!(value["achieved_containment"], "advisory");
4056 assert_eq!(value["host_containment"], "advisory");
4057 assert_eq!(value["floor_met"], true);
4058 assert_eq!(value["enforcement_tier"], "seccomp-only");
4059 assert!(value.get("shortfall_reason").is_none());
4061 }
4062
4063 #[test]
4066 fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
4067 let report = |tier, kernel| {
4068 report_for(
4069 &sample_policy(),
4070 ContainmentClass::Scoped,
4071 tier,
4072 None,
4073 None,
4074 kernel,
4075 None,
4076 Vec::new(),
4077 Vec::new(),
4078 Vec::new(),
4079 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4080 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4081 tier,
4082 ContainmentClass::Scoped,
4083 )),
4084 IoMaxReport::default(),
4085 )
4086 };
4087
4088 for (tier, kernel, expected) in [
4089 (EnforcementTier::KernelFull, Some(8), Some(3)),
4090 (EnforcementTier::KernelSealed, Some(8), Some(3)),
4091 (EnforcementTier::KernelFull, Some(2), Some(2)),
4092 (EnforcementTier::KernelFull, Some(1), Some(1)),
4093 (EnforcementTier::KernelFull, None, None),
4094 (EnforcementTier::KernelSeccompOnly, Some(8), None),
4095 (EnforcementTier::EnvironmentOnly, None, None),
4096 ] {
4097 let scope = report(tier, kernel);
4098 assert_eq!(
4099 scope.landlock_abi, expected,
4100 "{tier:?} with kernel {kernel:?}"
4101 );
4102
4103 let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
4104 assert!(value.as_object().unwrap().contains_key("landlock_abi"));
4105 match expected {
4106 Some(abi) => assert_eq!(value["landlock_abi"], abi),
4107 None => assert!(value["landlock_abi"].is_null()),
4108 }
4109
4110 let rendered = scope.render();
4111 let line = match expected {
4112 Some(abi) => format!(" landlock ABI: {abi}\n"),
4113 None => " landlock ABI: n/a\n".to_string(),
4114 };
4115 assert!(rendered.contains(&line), "{rendered}");
4116 assert!(
4117 rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
4118 );
4119 }
4120 }
4121
4122 #[test]
4125 fn the_userns_grant_is_reported_without_changing_any_verdict() {
4126 let baseline = report_for(
4127 &sample_policy(),
4128 ContainmentClass::Advisory,
4129 EnforcementTier::KernelSealed,
4130 None,
4131 None,
4132 None,
4133 None,
4134 Vec::new(),
4135 Vec::new(),
4136 Vec::new(),
4137 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4138 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4139 EnforcementTier::KernelSealed,
4140 ContainmentClass::Advisory,
4141 )),
4142 IoMaxReport::default(),
4143 );
4144
4145 for grant in UsernsGrant::ALL {
4146 let report = report_for(
4147 &sample_policy(),
4148 ContainmentClass::Advisory,
4149 EnforcementTier::KernelSealed,
4150 None,
4151 Some(*grant),
4152 None,
4153 None,
4154 Vec::new(),
4155 Vec::new(),
4156 Vec::new(),
4157 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4158 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4159 EnforcementTier::KernelSealed,
4160 ContainmentClass::Advisory,
4161 )),
4162 IoMaxReport::default(),
4163 );
4164 assert_eq!(report.achieved_containment, baseline.achieved_containment);
4165 assert_eq!(report.floor_met, baseline.floor_met);
4166 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
4167 assert_eq!(report.userns_grant, Some(*grant));
4168
4169 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
4170 assert_eq!(value["userns_grant"], grant.wire_name());
4171 assert!(report
4172 .render()
4173 .contains(&format!("userns grant: {}", grant.wire_name())));
4174 }
4175
4176 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
4179 assert!(value["userns_grant"].is_null());
4180 assert!(baseline.render().contains("userns grant: n/a"));
4181 }
4182
4183 #[test]
4184 fn rendered_report_names_both_classes_and_the_refusal() {
4185 let rendered = report_for(
4186 &sample_policy(),
4187 ContainmentClass::Sealed,
4188 EnforcementTier::KernelFull,
4189 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
4190 None,
4191 None,
4192 None,
4193 Vec::new(),
4194 Vec::new(),
4195 Vec::new(),
4196 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4197 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4198 EnforcementTier::KernelFull,
4199 ContainmentClass::Sealed,
4200 )),
4201 IoMaxReport::default(),
4202 )
4203 .render();
4204
4205 assert!(rendered.contains("declared: sealed"));
4206 assert!(rendered.contains("achieved: scoped"));
4207 assert!(rendered.contains("floor met: no"));
4208 assert!(rendered.contains("mur-sealed"));
4209 assert!(rendered.contains("would refuse to launch"));
4210 }
4211
4212 #[test]
4216 fn an_export_never_changes_what_the_report_says_about_containment() {
4217 let export = FileExport {
4218 root: "out/".to_string(),
4219 mode: ExportMode::ReadOnly,
4220 max_bytes: 10 * 1024 * 1024,
4221 };
4222 for tier in ALL_TIERS {
4223 for workdir_exec in [false, true] {
4224 let policy = CapabilityPolicy {
4225 workdir_exec_allowed: workdir_exec,
4226 ..sample_policy()
4227 };
4228 let without = report_for(
4229 &policy,
4230 ContainmentClass::Scoped,
4231 *tier,
4232 None,
4233 None,
4234 None,
4235 None,
4236 Vec::new(),
4237 Vec::new(),
4238 Vec::new(),
4239 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4240 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4241 *tier,
4242 ContainmentClass::Scoped,
4243 )),
4244 IoMaxReport::default(),
4245 );
4246 let with = report_for(
4247 &policy,
4248 ContainmentClass::Scoped,
4249 *tier,
4250 None,
4251 None,
4252 None,
4253 Some(&Exports {
4254 files: Some(export.clone()),
4255 peer_files: None,
4256 peer_tasks: None,
4257 }),
4258 Vec::new(),
4259 Vec::new(),
4260 Vec::new(),
4261 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4262 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4263 *tier,
4264 ContainmentClass::Scoped,
4265 )),
4266 IoMaxReport::default(),
4267 );
4268 assert_eq!(
4269 with.achieved_containment, without.achieved_containment,
4270 "tier {tier:?}, workdir_exec {workdir_exec}"
4271 );
4272 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4273 assert_eq!(with.floor_met, without.floor_met);
4274 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4275 assert_eq!(
4278 ScopeReport {
4279 exports_files: None,
4280 ..with.clone()
4281 },
4282 without
4283 );
4284 assert_eq!(
4285 with.exports_files,
4286 Some(ExportsFilesReport {
4287 root: "out/".to_string(),
4288 mode: ExportMode::ReadOnly,
4289 max_bytes: 10 * 1024 * 1024,
4290 })
4291 );
4292 }
4293 }
4294 }
4295
4296 #[test]
4300 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
4301 let peer_files = PeerFilesExport {
4302 root: "out/".to_string(),
4303 max_ttl_secs: Some(900),
4304 max_bytes: 10 * 1024 * 1024,
4305 };
4306 for tier in ALL_TIERS {
4307 for workdir_exec in [false, true] {
4308 let without_policy = CapabilityPolicy {
4309 workdir_exec_allowed: workdir_exec,
4310 ..sample_policy()
4311 };
4312 let with_policy = CapabilityPolicy {
4313 peer_fetch_allow: vec!["localhost:41234".to_string()],
4314 ..without_policy.clone()
4315 };
4316 let without = report_for(
4317 &without_policy,
4318 ContainmentClass::Scoped,
4319 *tier,
4320 None,
4321 None,
4322 None,
4323 None,
4324 Vec::new(),
4325 Vec::new(),
4326 Vec::new(),
4327 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4328 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4329 *tier,
4330 ContainmentClass::Scoped,
4331 )),
4332 IoMaxReport::default(),
4333 );
4334 let with = report_for(
4335 &with_policy,
4336 ContainmentClass::Scoped,
4337 *tier,
4338 None,
4339 None,
4340 None,
4341 Some(&Exports {
4342 files: None,
4343 peer_files: Some(peer_files.clone()),
4344 peer_tasks: None,
4345 }),
4346 Vec::new(),
4347 Vec::new(),
4348 Vec::new(),
4349 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4350 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4351 *tier,
4352 ContainmentClass::Scoped,
4353 )),
4354 IoMaxReport::default(),
4355 );
4356 assert_eq!(
4357 with.achieved_containment, without.achieved_containment,
4358 "tier {tier:?}, workdir_exec {workdir_exec}"
4359 );
4360 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4361 assert_eq!(with.floor_met, without.floor_met);
4362 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4363 assert_eq!(
4364 ScopeReport {
4365 peer_files: None,
4366 peer_fetch_allow: Vec::new(),
4367 ..with.clone()
4368 },
4369 without
4370 );
4371 assert_eq!(
4372 with.peer_files,
4373 Some(PeerFilesReport {
4374 root: "out/".to_string(),
4375 max_ttl_secs: 900,
4376 max_bytes: 10 * 1024 * 1024,
4377 })
4378 );
4379 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
4380 assert_eq!(with.network_allow, without.network_allow);
4384 }
4385 }
4386 }
4387
4388 #[test]
4392 fn the_peer_fields_are_always_serialized() {
4393 let undeclared: serde_json::Value = serde_json::to_value(report_for(
4394 &sample_policy(),
4395 ContainmentClass::Advisory,
4396 EnforcementTier::KernelSealed,
4397 None,
4398 None,
4399 None,
4400 None,
4401 Vec::new(),
4402 Vec::new(),
4403 Vec::new(),
4404 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4405 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4406 EnforcementTier::KernelSealed,
4407 ContainmentClass::Advisory,
4408 )),
4409 IoMaxReport::default(),
4410 ))
4411 .unwrap();
4412 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
4413 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
4414
4415 let declared: serde_json::Value = serde_json::to_value(report_for(
4416 &CapabilityPolicy {
4417 peer_fetch_allow: vec!["localhost:41234".to_string()],
4418 ..sample_policy()
4419 },
4420 ContainmentClass::Advisory,
4421 EnforcementTier::KernelSealed,
4422 None,
4423 None,
4424 None,
4425 Some(&Exports {
4426 files: None,
4427 peer_files: Some(PeerFilesExport {
4428 root: "out/".to_string(),
4429 max_ttl_secs: None,
4430 max_bytes: 10 * 1024 * 1024,
4431 }),
4432 peer_tasks: None,
4433 }),
4434 Vec::new(),
4435 Vec::new(),
4436 Vec::new(),
4437 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4438 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4439 EnforcementTier::KernelSealed,
4440 ContainmentClass::Advisory,
4441 )),
4442 IoMaxReport::default(),
4443 ))
4444 .unwrap();
4445 assert_eq!(
4446 declared["peer_files"],
4447 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
4448 );
4449 assert_eq!(
4450 declared["peer_fetch_allow"],
4451 serde_json::json!(["localhost:41234"])
4452 );
4453 }
4454
4455 #[test]
4456 fn render_names_peer_handoff_in_both_directions() {
4457 let undeclared = report_for(
4458 &sample_policy(),
4459 ContainmentClass::Advisory,
4460 EnforcementTier::KernelSealed,
4461 None,
4462 None,
4463 None,
4464 None,
4465 Vec::new(),
4466 Vec::new(),
4467 Vec::new(),
4468 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4469 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4470 EnforcementTier::KernelSealed,
4471 ContainmentClass::Advisory,
4472 )),
4473 IoMaxReport::default(),
4474 )
4475 .render();
4476 assert!(undeclared.contains("Peer handoff"));
4477 assert!(undeclared.contains("exports.peer_files: <none>"));
4478 assert!(undeclared.contains("peer_fetch allow: <none>"));
4479
4480 let declared = report_for(
4481 &CapabilityPolicy {
4482 peer_fetch_allow: vec!["localhost:41234".to_string()],
4483 ..sample_policy()
4484 },
4485 ContainmentClass::Advisory,
4486 EnforcementTier::KernelSealed,
4487 None,
4488 None,
4489 None,
4490 Some(&Exports {
4491 files: None,
4492 peer_files: Some(PeerFilesExport {
4493 root: "out/".to_string(),
4494 max_ttl_secs: Some(900),
4495 max_bytes: 4096,
4496 }),
4497 peer_tasks: None,
4498 }),
4499 Vec::new(),
4500 Vec::new(),
4501 Vec::new(),
4502 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4503 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4504 EnforcementTier::KernelSealed,
4505 ContainmentClass::Advisory,
4506 )),
4507 IoMaxReport::default(),
4508 )
4509 .render();
4510 assert!(declared.contains("exports.peer_files root: out/"));
4511 assert!(declared.contains("max handle ttl: 900s"));
4512 assert!(declared.contains("max bytes: 4096 (per file)"));
4513 assert!(declared.contains("- localhost:41234"));
4514 }
4515
4516 fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
4517 report_for(
4518 &sample_policy(),
4519 ContainmentClass::Scoped,
4520 tier,
4521 None,
4522 None,
4523 None,
4524 peer_tasks
4525 .map(|accept| Exports {
4526 files: None,
4527 peer_files: None,
4528 peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
4529 })
4530 .as_ref(),
4531 Vec::new(),
4532 Vec::new(),
4533 Vec::new(),
4534 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4535 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4536 tier,
4537 ContainmentClass::Scoped,
4538 )),
4539 IoMaxReport::default(),
4540 )
4541 }
4542
4543 #[test]
4546 fn peer_tasks_is_reported_and_moves_no_other_field() {
4547 for tier in ALL_TIERS {
4548 let absent = report_with_peer_tasks(*tier, None);
4549 let refused = report_with_peer_tasks(*tier, Some(false));
4550 let accepted = report_with_peer_tasks(*tier, Some(true));
4551 assert!(!absent.peer_tasks);
4552 assert_eq!(refused, absent, "tier {tier:?}");
4553 assert!(accepted.peer_tasks);
4554 assert_eq!(accepted.achieved_containment, absent.achieved_containment);
4555 assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
4556 assert_eq!(accepted.floor_met, absent.floor_met);
4557 assert_eq!(
4558 ScopeReport {
4559 peer_tasks: false,
4560 ..accepted.clone()
4561 },
4562 absent
4563 );
4564 }
4565 }
4566
4567 #[test]
4569 fn peer_tasks_is_always_serialized() {
4570 let tier = EnforcementTier::KernelFull;
4571 let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
4572 assert_eq!(absent["peer_tasks"], serde_json::json!(false));
4573 let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
4574 assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
4575 }
4576
4577 #[test]
4578 fn render_names_the_peer_task_posture_under_peer_handoff() {
4579 let tier = EnforcementTier::KernelFull;
4580 for (declared, line) in [
4581 (None, " exports.peer_tasks: refuse\n"),
4582 (Some(false), " exports.peer_tasks: refuse\n"),
4583 (Some(true), " exports.peer_tasks: accept\n"),
4584 ] {
4585 let rendered = report_with_peer_tasks(tier, declared).render();
4586 let section = rendered
4587 .split("\nPeer handoff\n")
4588 .nth(1)
4589 .and_then(|rest| rest.split("\n\n").next())
4590 .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
4591 assert!(
4592 section.contains(line.trim_start_matches(' ').trim_end()),
4593 "{declared:?}:\n{section}"
4594 );
4595 assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
4596 }
4597 }
4598
4599 #[test]
4603 fn a_state_store_is_reported_and_moves_no_other_field() {
4604 let policy = sample_policy();
4605 let stores = vec![StateStoreReport {
4606 artifact: "murmur/tool-corpus".to_string(),
4607 store: "shey".to_string(),
4608 host_path: "/home/dev/.murmur/state/shey".to_string(),
4609 }];
4610
4611 let without = report_for(
4612 &policy,
4613 ContainmentClass::Scoped,
4614 EnforcementTier::KernelFull,
4615 None,
4616 None,
4617 None,
4618 None,
4619 Vec::new(),
4620 Vec::new(),
4621 Vec::new(),
4622 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4623 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4624 EnforcementTier::KernelFull,
4625 ContainmentClass::Scoped,
4626 )),
4627 IoMaxReport::default(),
4628 );
4629 let with = report_for(
4630 &policy,
4631 ContainmentClass::Scoped,
4632 EnforcementTier::KernelFull,
4633 None,
4634 None,
4635 None,
4636 None,
4637 stores.clone(),
4638 Vec::new(),
4639 Vec::new(),
4640 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4641 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4642 EnforcementTier::KernelFull,
4643 ContainmentClass::Scoped,
4644 )),
4645 IoMaxReport::default(),
4646 );
4647
4648 assert_eq!(with.state_stores, stores);
4649 assert!(without.state_stores.is_empty());
4650 assert_eq!(with.achieved_containment, without.achieved_containment);
4651 assert_eq!(with.floor_met, without.floor_met);
4652 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4653 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4654 assert_eq!(
4656 ScopeReport {
4657 state_stores: Vec::new(),
4658 ..with.clone()
4659 },
4660 without
4661 );
4662
4663 assert!(with
4665 .render()
4666 .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
4667 assert!(without.render().contains("state stores: <none>"));
4668
4669 let json = serde_json::to_value(&without).unwrap();
4672 assert_eq!(json["state_stores"], serde_json::json!([]));
4673 assert_eq!(
4674 serde_json::to_value(&with).unwrap()["state_stores"],
4675 serde_json::json!([{
4676 "artifact": "murmur/tool-corpus",
4677 "store": "shey",
4678 "host_path": "/home/dev/.murmur/state/shey",
4679 }])
4680 );
4681 }
4682
4683 fn extra_roots_preopen(artifact: &str, role: &str, scope: Option<&str>) -> PreopenReport {
4684 PreopenReport {
4685 artifact: artifact.to_string(),
4686 role: role.to_string(),
4687 scope: scope.map(str::to_string),
4688 surface: if scope.is_some() {
4689 PreopenSurface::ScopedSubtree
4690 } else {
4691 PreopenSurface::WholeWorkdir
4692 },
4693 runtime_dir_reach: (role == "tool" && scope.is_none())
4694 .then_some(RuntimeDirReach::MurmurRuntime),
4695 extra_roots: Vec::new(),
4696 }
4697 }
4698
4699 fn two_host_folders() -> Vec<crate::guest_roots::GuestRoot> {
4700 let host_path = |path: &str, access| crate::host_paths::HostPath {
4701 declared: path.to_string(),
4702 path: std::path::PathBuf::from(path),
4703 access,
4704 credential_locations: Vec::new(),
4705 };
4706 crate::guest_roots::extra_guest_roots_for(
4707 ContainmentClass::Advisory,
4708 &[
4709 host_path("/data/ref", crate::host_paths::HostPathAccess::Read),
4710 host_path("/data/out", crate::host_paths::HostPathAccess::ReadWrite),
4711 ],
4712 )
4713 }
4714
4715 #[test]
4717 fn extra_roots_serialize_with_four_keys_always_present() {
4718 let mut reports = vec![
4719 extra_roots_preopen("probe", "tool", None),
4720 extra_roots_preopen("cache-tool", "tool", Some("cache")),
4721 extra_roots_preopen("driver", "driver", None),
4722 ];
4723 attach_extra_roots(&mut reports, &two_host_folders());
4724 let json = serde_json::to_value(&reports).unwrap();
4725 assert_eq!(
4726 json[0]["extra_roots"],
4727 serde_json::json!([
4728 {
4729 "guest_path": "murmur-host/data/ref",
4730 "path": "/data/ref",
4731 "access": "read",
4732 "source": "host_paths",
4733 },
4734 {
4735 "guest_path": "murmur-host/data/out",
4736 "path": "/data/out",
4737 "access": "read-write",
4738 "source": "host_paths",
4739 },
4740 ])
4741 );
4742 assert_eq!(json[1]["extra_roots"], serde_json::json!([]));
4743 assert_eq!(json[2]["extra_roots"], serde_json::json!([]));
4744
4745 let mut reports = vec![extra_roots_preopen("probe", "tool", None)];
4746 attach_extra_roots(
4747 &mut reports,
4748 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4749 );
4750 assert_eq!(
4751 serde_json::to_value(&reports[0].extra_roots).unwrap(),
4752 serde_json::json!([{
4753 "guest_path": "murmur-host",
4754 "path": "/",
4755 "access": "read-write",
4756 "source": "unconfined",
4757 }])
4758 );
4759 }
4760
4761 #[test]
4764 fn extra_roots_render_after_the_runtime_directory() {
4765 let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4766 attach_extra_roots(&mut reports, &two_host_folders());
4767 assert_eq!(reports[0].extra_roots.len(), 2);
4768 assert!(
4769 reports[0].render().ends_with(
4770 "; runtime directory read-only at murmur-runtime/; host folders under \
4771 murmur-host/: /data/ref (read), /data/out (read-write)"
4772 ),
4773 "{}",
4774 reports[0].render()
4775 );
4776
4777 let mut whole = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4778 attach_extra_roots(
4779 &mut whole,
4780 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4781 );
4782 assert!(whole[0].render().ends_with(
4783 "; runtime directory read-only at murmur-runtime/; whole host read-write \
4784 under murmur-host/"
4785 ));
4786 }
4787
4788 #[test]
4790 fn extra_roots_absent_leave_the_line_unchanged() {
4791 let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4792 attach_extra_roots(&mut reports, &[]);
4793 assert_eq!(
4794 reports[0].render(),
4795 "wasm-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4796 declared; runtime directory read-only at murmur-runtime/"
4797 );
4798 }
4799
4800 #[test]
4805 fn preopens_are_reported_and_move_no_other_field() {
4806 let policy = sample_policy();
4807 let preopens = vec![
4808 PreopenReport {
4809 artifact: "notes-tool".to_string(),
4810 role: "tool".to_string(),
4811 scope: None,
4812 surface: PreopenSurface::WholeWorkdir,
4813 runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
4814 extra_roots: Vec::new(),
4815 },
4816 PreopenReport {
4817 artifact: "cache-tool".to_string(),
4818 role: "tool".to_string(),
4819 scope: Some("cache".to_string()),
4820 surface: PreopenSurface::ScopedSubtree,
4821 runtime_dir_reach: None,
4822 extra_roots: Vec::new(),
4823 },
4824 PreopenReport {
4825 artifact: "telemetry-hook".to_string(),
4826 role: "hook".to_string(),
4827 scope: None,
4828 surface: PreopenSurface::Nothing,
4829 runtime_dir_reach: None,
4830 extra_roots: Vec::new(),
4831 },
4832 ];
4833
4834 let without = report_for(
4835 &policy,
4836 ContainmentClass::Scoped,
4837 EnforcementTier::KernelFull,
4838 None,
4839 None,
4840 None,
4841 None,
4842 Vec::new(),
4843 Vec::new(),
4844 Vec::new(),
4845 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4846 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4847 EnforcementTier::KernelFull,
4848 ContainmentClass::Scoped,
4849 )),
4850 IoMaxReport::default(),
4851 );
4852 let with = report_for(
4853 &policy,
4854 ContainmentClass::Scoped,
4855 EnforcementTier::KernelFull,
4856 None,
4857 None,
4858 None,
4859 None,
4860 Vec::new(),
4861 Vec::new(),
4862 preopens.clone(),
4863 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4864 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4865 EnforcementTier::KernelFull,
4866 ContainmentClass::Scoped,
4867 )),
4868 IoMaxReport::default(),
4869 );
4870
4871 assert_eq!(with.preopens, preopens);
4872 assert!(without.preopens.is_empty());
4873 assert_eq!(
4876 ScopeReport {
4877 preopens: Vec::new(),
4878 routes: without.routes.clone(),
4879 ..with.clone()
4880 },
4881 without
4882 );
4883
4884 let rendered = with.render();
4886 assert!(rendered.contains(
4887 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4888 declared; runtime directory read-only at murmur-runtime/"
4889 ));
4890 assert!(rendered.contains(
4891 "- cache-tool (tool): one subtree of the accessible workdir — \
4892 capabilities.filesystem.scope: cache"
4893 ));
4894 assert!(rendered.contains(
4895 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
4896 ));
4897 assert!(without.render().contains("preopens: <none>"));
4898
4899 assert_eq!(
4901 serde_json::to_value(&without).unwrap()["preopens"],
4902 serde_json::json!([])
4903 );
4904 assert_eq!(
4905 serde_json::to_value(&with).unwrap()["preopens"],
4906 serde_json::json!([
4907 {
4908 "artifact": "notes-tool",
4909 "role": "tool",
4910 "scope": null,
4911 "surface": "whole-workdir",
4912 "runtime_dir_reach": "murmur-runtime",
4913 "extra_roots": [],
4914 },
4915 {
4916 "artifact": "cache-tool",
4917 "role": "tool",
4918 "scope": "cache",
4919 "surface": "scoped-subtree",
4920 "runtime_dir_reach": null,
4921 "extra_roots": [],
4922 },
4923 {
4924 "artifact": "telemetry-hook",
4925 "role": "hook",
4926 "scope": null,
4927 "surface": "nothing",
4928 "runtime_dir_reach": null,
4929 "extra_roots": [],
4930 },
4931 ])
4932 );
4933 }
4934
4935 #[test]
4939 fn exports_files_is_always_serialized() {
4940 let undeclared = serde_json::to_value(report_for(
4941 &sample_policy(),
4942 ContainmentClass::Advisory,
4943 EnforcementTier::KernelFull,
4944 None,
4945 None,
4946 None,
4947 None,
4948 Vec::new(),
4949 Vec::new(),
4950 Vec::new(),
4951 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4952 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4953 EnforcementTier::KernelFull,
4954 ContainmentClass::Advisory,
4955 )),
4956 IoMaxReport::default(),
4957 ))
4958 .unwrap();
4959 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
4960
4961 let declared = serde_json::to_value(report_for(
4962 &sample_policy(),
4963 ContainmentClass::Advisory,
4964 EnforcementTier::KernelFull,
4965 None,
4966 None,
4967 None,
4968 Some(&Exports {
4969 files: Some(FileExport {
4970 root: "out/".to_string(),
4971 mode: ExportMode::ReadOnly,
4972 max_bytes: 10_485_760,
4973 }),
4974 peer_files: None,
4975 peer_tasks: None,
4976 }),
4977 Vec::new(),
4978 Vec::new(),
4979 Vec::new(),
4980 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4981 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4982 EnforcementTier::KernelFull,
4983 ContainmentClass::Advisory,
4984 )),
4985 IoMaxReport::default(),
4986 ))
4987 .unwrap();
4988 assert_eq!(
4989 declared["exports_files"],
4990 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
4991 );
4992 }
4993
4994 #[test]
4995 fn render_names_the_resource_plane_in_both_directions() {
4996 let undeclared = report_for(
4997 &sample_policy(),
4998 ContainmentClass::Advisory,
4999 EnforcementTier::KernelFull,
5000 None,
5001 None,
5002 None,
5003 None,
5004 Vec::new(),
5005 Vec::new(),
5006 Vec::new(),
5007 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5008 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5009 EnforcementTier::KernelFull,
5010 ContainmentClass::Advisory,
5011 )),
5012 IoMaxReport::default(),
5013 )
5014 .render();
5015 assert!(
5016 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
5017 "{undeclared}"
5018 );
5019
5020 let declared = report_for(
5021 &sample_policy(),
5022 ContainmentClass::Advisory,
5023 EnforcementTier::KernelFull,
5024 None,
5025 None,
5026 None,
5027 Some(&Exports {
5028 files: Some(FileExport {
5029 root: "out/".to_string(),
5030 mode: ExportMode::ReadOnly,
5031 max_bytes: 10_485_760,
5032 }),
5033 peer_files: None,
5034 peer_tasks: None,
5035 }),
5036 Vec::new(),
5037 Vec::new(),
5038 Vec::new(),
5039 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5040 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5041 EnforcementTier::KernelFull,
5042 ContainmentClass::Advisory,
5043 )),
5044 IoMaxReport::default(),
5045 )
5046 .render();
5047 assert!(declared.contains("exports.files root: out/"), "{declared}");
5048 assert!(
5049 declared.contains("mode: read-only"),
5050 "{declared}"
5051 );
5052 assert!(
5053 declared.contains("max bytes: 10485760 (per file)"),
5054 "{declared}"
5055 );
5056 }
5057
5058 fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
5061 let policy = CapabilityPolicy {
5062 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
5063 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
5064 ..CapabilityPolicy::default()
5065 };
5066 report_for(
5067 &policy,
5068 ContainmentClass::Scoped,
5069 EnforcementTier::KernelFull,
5070 None,
5071 None,
5072 None,
5073 None,
5074 Vec::new(),
5075 Vec::new(),
5076 Vec::new(),
5077 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5078 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5079 EnforcementTier::KernelFull,
5080 ContainmentClass::Scoped,
5081 )),
5082 IoMaxReport::default(),
5083 )
5084 }
5085
5086 #[test]
5089 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
5090 let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
5091 assert_eq!(
5092 report.read_only_paths,
5093 vec!["tests".to_string(), "bench/fixtures".to_string()]
5094 );
5095 assert!(report.read_only_advisory_for.is_empty());
5096
5097 let rendered = report.render();
5098 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
5099 assert!(rendered.contains(
5100 "read_only enforcement: enforced for every tool call and every shell command the \
5101 dispatch check can read"
5102 ));
5103 assert!(!rendered.contains("advisory against"));
5104 }
5105
5106 #[test]
5109 fn an_allowlisted_interpreter_is_named_as_advisory() {
5110 let report = read_only_report(&["tests"], &["git", "python3"]);
5111 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
5112
5113 let rendered = report.render();
5114 assert!(rendered.contains("read_only enforcement: advisory against python3"));
5115 assert!(rendered
5116 .contains("enforced for every tool call and for every shell command it can read"));
5117 }
5118
5119 #[test]
5123 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
5124 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
5125 .iter()
5126 .map(|b| (*b).to_string())
5127 .collect();
5128 let declared = vec!["tests".to_string()];
5129 assert_eq!(
5130 read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
5131 .read_only_advisory_for,
5132 read_only_advisory_for(&declared, &shell_allow)
5133 );
5134 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
5136 }
5137
5138 #[test]
5142 fn both_read_only_keys_are_always_serialized_arrays() {
5143 let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
5144 assert_eq!(json["read_only_paths"], serde_json::json!([]));
5145 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
5146
5147 let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
5148 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
5149 assert_eq!(
5150 json["read_only_advisory_for"],
5151 serde_json::json!(["python3"])
5152 );
5153 }
5154
5155 #[test]
5158 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
5159 let rendered = read_only_report(&[], &["python3"]).render();
5160 assert!(rendered.contains(" read_only: <none>\n"));
5161 assert!(!rendered.contains("read_only enforcement:"));
5162 }
5163
5164 #[test]
5176 fn every_capability_policy_field_is_reported_or_exempted() {
5177 const EXEMPT: &[(&str, &str)] = &[
5179 (
5180 "shell_strip_env",
5181 "Names host variables removed from the shell child's environment on top of the \
5182 credential patterns the runtime always strips. It subtracts from what the \
5183 capsule sees and can open nothing, so reporting it beside the grants would pad \
5184 the list an operator audits for reach.",
5185 ),
5186 (
5187 "shell_baseline_env",
5188 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
5189 environment of a subprocess the capsule was already granted, and names no host \
5190 resource `shell allow` and `env allow` do not already account for.",
5191 ),
5192 (
5193 "limits",
5194 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
5195 deadline. A bound on how much the capsule may consume, never a reach it gains.",
5196 ),
5197 (
5198 "resources",
5199 "The same, for subprocesses: rlimits, the Linux cgroup v2 scope and the \
5200 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
5201 ),
5202 (
5203 "guest_workers",
5204 "Derived from the artifacts and `install.tool` the report already shows as the \
5205 `wasm-tools`, `drivers` and `hooks` route rows, and grants nothing: it only makes \
5206 such a capsule need the namespace and cgroup scope the report's `cgroup_scope` \
5207 states.",
5208 ),
5209 (
5210 "declared_deadline_seconds",
5211 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
5212 can apply a lower default. Reporting it would report one ceiling twice.",
5213 ),
5214 (
5215 "process_transport",
5216 "Records that `inference.transport` is `process`, which decides the subprocess \
5217 route and that `capabilities.harness.paths` is valid. Not a grant: the harness's \
5218 reach is in the `harness` key, whose presence already says the transport is \
5219 process.",
5220 ),
5221 (
5222 "state_declared",
5223 "Records that a capsule-wide `capabilities.state` block was declared where \
5224 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
5225 exist are reported per artifact in `state_stores`.",
5226 ),
5227 (
5228 "conversation_declared",
5229 "Records a capsule-wide `capabilities.conversation` block that reaches no \
5230 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
5231 and the grant itself is per artifact.",
5232 ),
5233 ];
5234
5235 const REPORTED: &[(&str, &str)] = &[
5237 ("network_allow", "network_allow"),
5238 ("peer_fetch_allow", "peer_fetch_allow"),
5239 ("unix_sockets_allowed", "unix_sockets"),
5240 ("filesystem_scope", "filesystem_scope"),
5241 ("workdir_exec_allowed", "workdir_exec"),
5242 ("read_only_paths", "read_only_paths"),
5243 ("shell_allow", "shell_allow"),
5244 ("spawn_allow", "spawn_allow"),
5245 ("spawn_workdir_snapshot", "spawn_workdir_snapshot"),
5246 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
5247 (
5248 "shell_derived_interpreter_runtime",
5249 "derived_interpreter_runtime",
5250 ),
5251 ("shell_trust_store", "trust_store"),
5252 ("shell_declared_needs", "declared_needs"),
5253 ("shell_pinned_programs", "pinned_programs"),
5254 ("host_paths", "host_paths"),
5255 ("harness_paths", "harness"),
5256 ("shell_staged_runtime", "staged_runtime_grants"),
5257 ("env_allow", "env_allow"),
5258 ("plan_submit", "plan_submit"),
5259 ("install_skill", "install_skill"),
5260 ("install_tool", "install_tool"),
5261 ("install_from_sources", "install_from"),
5262 ("containment_floor", "declared_containment"),
5263 ("declared_resources", "unconfined"),
5264 ];
5265
5266 let declared = capability_policy_field_names(include_str!("types.rs"));
5267 assert!(
5268 declared.len() > 10,
5269 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
5270 );
5271
5272 for field in &declared {
5273 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
5274 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
5275 assert!(
5276 reported || exempt,
5277 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
5278 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
5279 with a written reason for why an operator does not need to see it."
5280 );
5281 assert!(
5282 !(reported && exempt),
5283 "CapabilityPolicy::{field} is listed as both reported and exempt"
5284 );
5285 }
5286
5287 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
5288 assert!(
5289 declared.iter().any(|field| field == policy_field),
5290 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
5291 declares — remove the stale row"
5292 );
5293 }
5294
5295 let json = serde_json::to_value(report_for(
5298 &sample_policy(),
5299 ContainmentClass::Scoped,
5300 EnforcementTier::KernelFull,
5301 None,
5302 None,
5303 None,
5304 None,
5305 Vec::new(),
5306 Vec::new(),
5307 Vec::new(),
5308 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5309 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5310 EnforcementTier::KernelFull,
5311 ContainmentClass::Scoped,
5312 )),
5313 IoMaxReport::default(),
5314 ))
5315 .unwrap();
5316 let object = json.as_object().expect("a scope report is a JSON object");
5317 for (policy_field, report_key) in REPORTED {
5318 assert!(
5319 object.contains_key(*report_key),
5320 "the table maps CapabilityPolicy::{policy_field} to a report key \
5321 '{report_key}' that a serialized ScopeReport does not carry"
5322 );
5323 }
5324 }
5325
5326 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
5329 report_for(
5330 &sample_policy(),
5331 declared,
5332 tier,
5333 None,
5334 None,
5335 Some(6),
5338 None,
5339 Vec::new(),
5340 Vec::new(),
5341 Vec::new(),
5342 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5343 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5344 IoMaxReport::default(),
5345 )
5346 }
5347
5348 fn sample_snapshot() -> crate::workdir_snapshot::WorkdirSnapshot {
5349 crate::workdir_snapshot::WorkdirSnapshot {
5350 source: "/home/you/project".to_string(),
5351 files: 12,
5352 dirs: 3,
5353 symlinks: 1,
5354 repointed_symlinks: 0,
5355 bytes: 40960,
5356 skipped_count: 1,
5357 skipped: vec![crate::workdir_snapshot::SkippedEntry {
5358 path: "run/pipe".to_string(),
5359 reason: crate::workdir_snapshot::SkipReason::Fifo,
5360 }],
5361 }
5362 }
5363
5364 #[test]
5367 fn spawn_workdir_snapshot_and_parent_workdir_are_always_reported() {
5368 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5369 let json = serde_json::to_value(&report).unwrap();
5370 assert_eq!(json["spawn_workdir_snapshot"], serde_json::json!([]));
5371 assert!(json.as_object().unwrap().contains_key("parent_workdir"));
5372 assert_eq!(json["parent_workdir"], serde_json::Value::Null);
5373 let rendered = report.render();
5374 let snapshot_line = rendered
5375 .find("\n spawn workdir snapshot: <none>\n")
5376 .unwrap_or_else(|| panic!("{rendered}"));
5377 assert!(
5378 rendered.find("\n spawn allow:").unwrap() < snapshot_line,
5379 "{rendered}"
5380 );
5381
5382 assert!(!rendered.contains("Parent work folder"), "{rendered}");
5383
5384 let mut policy = sample_policy();
5385 policy.spawn_allow = vec!["local/reviewer".to_string(), "local/worker".to_string()];
5386 policy.spawn_workdir_snapshot = vec!["local/reviewer".to_string()];
5387 let report = report_for(
5388 &policy,
5389 ContainmentClass::Scoped,
5390 EnforcementTier::KernelFull,
5391 None,
5392 None,
5393 Some(6),
5394 None,
5395 Vec::new(),
5396 Vec::new(),
5397 Vec::new(),
5398 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5399 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5400 EnforcementTier::KernelFull,
5401 ContainmentClass::Scoped,
5402 )),
5403 IoMaxReport::default(),
5404 );
5405 assert_eq!(report.spawn_workdir_snapshot, vec!["local/reviewer"]);
5406 assert_eq!(
5407 serde_json::to_value(&report).unwrap()["spawn_workdir_snapshot"],
5408 serde_json::json!(["local/reviewer"])
5409 );
5410 assert!(
5411 report
5412 .render()
5413 .contains(" spawn workdir snapshot:\n - local/reviewer\n"),
5414 "{}",
5415 report.render()
5416 );
5417 }
5418
5419 #[test]
5422 fn parent_workdir_restriction_follows_the_applied_tier() {
5423 for (declared, tier, restriction) in [
5424 (
5425 ContainmentClass::Sealed,
5426 EnforcementTier::KernelSealed,
5427 "absent",
5428 ),
5429 (
5430 ContainmentClass::Scoped,
5431 EnforcementTier::KernelFull,
5432 "enforced",
5433 ),
5434 (
5435 ContainmentClass::Advisory,
5436 EnforcementTier::KernelSeccompOnly,
5437 "advisory",
5438 ),
5439 (
5440 ContainmentClass::Advisory,
5441 EnforcementTier::EnvironmentOnly,
5442 "advisory",
5443 ),
5444 ] {
5445 let report =
5446 boundary_report(declared, tier).with_parent_workdir(Some(sample_snapshot()));
5447 let json = serde_json::to_value(&report).unwrap();
5448 let parent = &json["parent_workdir"];
5449 assert_eq!(parent["restriction"], restriction, "{tier:?}");
5450 assert_eq!(
5451 parent["restriction"],
5452 json["filesystem_boundary"]["restriction"]
5453 );
5454 assert_eq!(parent["source"], "/home/you/project");
5455 assert_eq!(parent["files"], 12);
5456 assert_eq!(parent["skipped"][0]["reason"], "fifo");
5457
5458 let rendered = report.render();
5459 assert!(
5460 rendered.contains(
5461 "\nParent work folder\n source: /home/you/project\n copied: \
5462 12 files, 3 directories, 1 symlinks (0 re-pointed), 40960 bytes\n \
5463 skipped: 1\n - run/pipe (fifo)\n restriction: "
5464 ),
5465 "{rendered}"
5466 );
5467 assert!(
5468 rendered.contains(&format!(" restriction: {restriction}: ")),
5469 "{rendered}"
5470 );
5471 }
5472 assert_eq!(
5473 boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull)
5474 .with_parent_workdir(None)
5475 .parent_workdir,
5476 None
5477 );
5478 }
5479
5480 fn presence_cases() -> Vec<(&'static str, RouteInputs, Vec<Route>)> {
5482 vec![
5483 ("nothing", RouteInputs::default(), vec![]),
5484 (
5485 "one tool",
5486 RouteInputs {
5487 tools: vec!["t".to_string()],
5488 ..RouteInputs::default()
5489 },
5490 vec![Route::WasmTools],
5491 ),
5492 (
5493 "a driver",
5494 RouteInputs {
5495 drivers: vec!["d".to_string()],
5496 ..RouteInputs::default()
5497 },
5498 vec![Route::Drivers],
5499 ),
5500 (
5501 "a hook",
5502 RouteInputs {
5503 hooks: vec!["h".to_string()],
5504 ..RouteInputs::default()
5505 },
5506 vec![Route::Hooks],
5507 ),
5508 (
5509 "install.tool",
5510 RouteInputs {
5511 install_tool: true,
5512 ..RouteInputs::default()
5513 },
5514 vec![Route::WasmTools],
5515 ),
5516 (
5517 "transport: process",
5518 RouteInputs {
5519 process_transport: true,
5520 ..RouteInputs::default()
5521 },
5522 vec![Route::ProcessHarness, Route::HarnessTools],
5523 ),
5524 (
5525 "plan.submit without shell.allow",
5526 RouteInputs {
5527 plan_submit: true,
5528 ..RouteInputs::default()
5529 },
5530 vec![],
5531 ),
5532 (
5533 "plan.submit with shell.allow",
5534 RouteInputs {
5535 plan_submit: true,
5536 shell_allow: true,
5537 ..RouteInputs::default()
5538 },
5539 vec![Route::Shell, Route::PlanShellSteps],
5540 ),
5541 (
5542 "shell.allow alone",
5543 RouteInputs {
5544 shell_allow: true,
5545 ..RouteInputs::default()
5546 },
5547 vec![Route::Shell],
5548 ),
5549 ]
5550 }
5551
5552 #[test]
5557 fn the_route_table_holds_its_invariants_on_every_input() {
5558 for tier in ALL_TIERS {
5559 for declared in [
5560 ContainmentClass::Advisory,
5561 ContainmentClass::Scoped,
5562 ContainmentClass::Sealed,
5563 ] {
5564 let mechanism =
5565 ContainmentDecision::decide(*tier, declared, false, false).enforcement_tier();
5566 for (case, inputs, present) in presence_cases() {
5567 let rows = route_table(&inputs, mechanism);
5568 assert_eq!(
5569 rows.iter().map(|row| row.route).collect::<Vec<_>>(),
5570 Route::ALL.to_vec(),
5571 "{case}"
5572 );
5573 for row in &rows {
5574 let covered = matches!(
5577 row.route,
5578 Route::Shell
5579 | Route::PlanShellSteps
5580 | Route::ProcessHarness
5581 | Route::HarnessTools
5582 | Route::WasmTools
5583 | Route::Drivers
5584 | Route::Hooks
5585 );
5586 assert_eq!(row.under_class, covered, "{case}: {row:?}");
5587 if covered {
5588 assert_eq!(row.mechanism, mechanism, "{case}: {row:?}");
5589 assert_eq!(row.note, None, "{case}: {row:?}");
5590 } else {
5591 assert_eq!(row.mechanism, HOST_PROCESS_MECHANISM, "{case}: {row:?}");
5592 assert!(row.note.is_some(), "{case}: {row:?}");
5593 }
5594 assert_eq!(
5595 row.present,
5596 present.contains(&row.route),
5597 "{case} on {tier:?}/{declared}: {row:?}"
5598 );
5599 }
5600 }
5601 }
5602 }
5603 }
5604
5605 #[test]
5608 fn every_route_is_under_the_class_with_no_note() {
5609 for route in Route::ALL {
5610 assert!(route.under_class(), "{route:?}");
5611 assert_eq!(route.note(), None, "{route:?}");
5612 }
5613 }
5614
5615 #[test]
5617 fn route_inputs_read_roles_off_the_preopens() {
5618 let preopen = |artifact: &str, role: &str| PreopenReport {
5619 artifact: artifact.to_string(),
5620 role: role.to_string(),
5621 scope: None,
5622 surface: PreopenSurface::WholeWorkdir,
5623 runtime_dir_reach: None,
5624 extra_roots: Vec::new(),
5625 };
5626 let preopens = vec![
5627 preopen("fs-tool", "tool"),
5628 preopen("browser", "tool"),
5629 preopen("driver-anthropic", "driver"),
5630 preopen("audit", "hook"),
5631 ];
5632 let inputs = RouteInputs::new(&[], false, &[], &preopens, Some("http"));
5633 let rows = route_table(&inputs, "landlock+seccomp");
5634 let row = |route: Route| rows.iter().find(|row| row.route == route).unwrap();
5635 assert_eq!(row(Route::WasmTools).artifacts, vec!["fs-tool", "browser"]);
5636 assert_eq!(row(Route::Drivers).artifacts, vec!["driver-anthropic"]);
5637 assert_eq!(row(Route::Hooks).artifacts, vec!["audit"]);
5638 assert!(!row(Route::ProcessHarness).present);
5639 assert!(RouteInputs::new(&[], false, &[], &[], Some("process")).process_transport);
5640 }
5641
5642 #[test]
5645 fn the_routes_json_is_pinned() {
5646 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
5647 .with_routes(Some("process"));
5648 let json = serde_json::to_value(&report).unwrap();
5649 assert!(json.get("mechanism_covers").is_none(), "{json}");
5650 let routes = json["routes"].as_array().expect("routes is an array");
5651 assert_eq!(routes.len(), 7);
5652 let shell_present = !report.shell_allow.is_empty();
5653 assert_eq!(
5654 routes[0],
5655 serde_json::json!({
5656 "route": "shell",
5657 "present": shell_present,
5658 "under_class": true,
5659 "mechanism": "landlock+seccomp",
5660 "note": null,
5661 "artifacts": [],
5662 })
5663 );
5664 assert_eq!(
5665 routes[5],
5666 serde_json::json!({
5667 "route": "process-harness",
5668 "present": true,
5669 "under_class": true,
5670 "mechanism": "landlock+seccomp",
5671 "note": null,
5672 "artifacts": [],
5673 })
5674 );
5675 assert_eq!(
5676 routes
5677 .iter()
5678 .map(|row| row["route"].as_str().unwrap())
5679 .collect::<Vec<_>>(),
5680 [
5681 "shell",
5682 "plan-shell-steps",
5683 "wasm-tools",
5684 "drivers",
5685 "hooks",
5686 "process-harness",
5687 "harness-tools"
5688 ]
5689 );
5690 assert_eq!(
5691 json["cgroup_scope"],
5692 serde_json::json!({"status": "not-probed"})
5693 );
5694 }
5695
5696 #[test]
5698 fn the_routes_block_follows_the_containment_block() {
5699 let preopens = vec![PreopenReport {
5700 artifact: "fs-tool".to_string(),
5701 role: "tool".to_string(),
5702 scope: None,
5703 surface: PreopenSurface::WholeWorkdir,
5704 runtime_dir_reach: None,
5705 extra_roots: Vec::new(),
5706 }];
5707 let report = report_for(
5708 &sample_policy(),
5709 ContainmentClass::Advisory,
5710 EnforcementTier::KernelSealed,
5711 None,
5712 None,
5713 Some(6),
5714 None,
5715 Vec::new(),
5716 Vec::new(),
5717 preopens,
5718 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5719 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
5720 IoMaxReport::default(),
5721 )
5722 .with_routes(None);
5723 let rendered = report.render();
5724 assert!(!rendered.contains("covers:"), "{rendered}");
5725 let routes = rendered.find("\nRoutes\n").expect("a Routes block");
5726 assert!(
5727 routes > rendered.find(" mechanism: ").unwrap(),
5728 "{rendered}"
5729 );
5730 assert!(
5731 routes < rendered.find("\nRuntime directory\n").unwrap(),
5732 "{rendered}"
5733 );
5734 assert!(
5735 rendered.contains(" shell commands present landlock+seccomp\n"),
5736 "{rendered}"
5737 );
5738 assert!(
5739 rendered.contains(" WASM tools present landlock+seccomp (fs-tool)\n"),
5740 "{rendered}"
5741 );
5742 assert!(
5743 rendered.contains(" process harness absent landlock+seccomp\n"),
5744 "{rendered}"
5745 );
5746 assert!(
5747 rendered.contains(" cgroup scope: not-probed\n"),
5748 "{rendered}"
5749 );
5750 }
5751
5752 #[test]
5755 fn the_uncovered_route_warning_names_exactly_the_present_uncovered_routes() {
5756 let inputs = RouteInputs {
5757 shell_allow: true,
5758 tools: vec!["fs-tool".to_string()],
5759 drivers: vec!["driver-anthropic".to_string()],
5760 ..RouteInputs::default()
5761 };
5762 let routes = route_table(&inputs, "landlock+seccomp");
5763 assert_eq!(
5764 uncovered_route_warning(ContainmentClass::Advisory, &routes),
5765 None
5766 );
5767
5768 let covered_only = route_table(
5769 &RouteInputs {
5770 shell_allow: true,
5771 plan_submit: true,
5772 ..RouteInputs::default()
5773 },
5774 "landlock+seccomp",
5775 );
5776 assert_eq!(
5777 uncovered_route_warning(ContainmentClass::Scoped, &covered_only),
5778 None
5779 );
5780
5781 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5783 assert_eq!(uncovered_route_warning(declared, &routes), None);
5784 }
5785
5786 let process = route_table(
5789 &RouteInputs {
5790 process_transport: true,
5791 drivers: vec!["driver-claude-code".to_string()],
5792 ..RouteInputs::default()
5793 },
5794 "landlock+seccomp",
5795 );
5796 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5797 assert_eq!(uncovered_route_warning(declared, &process), None);
5798 }
5799 }
5800
5801 #[test]
5803 fn the_uncovered_route_warning_reaches_the_bootstrap_log() {
5804 let temp = tempfile::tempdir().unwrap();
5805 let mut routes = route_table(
5806 &RouteInputs {
5807 drivers: vec!["d".to_string()],
5808 process_transport: true,
5809 ..RouteInputs::default()
5810 },
5811 "landlock+seccomp",
5812 );
5813 let harness = routes
5816 .iter_mut()
5817 .find(|row| row.route == Route::ProcessHarness)
5818 .unwrap();
5819 harness.under_class = false;
5820 warn_on_uncovered_routes(
5821 ContainmentClass::Sealed,
5822 &routes,
5823 Some(&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap()),
5824 );
5825 let log = std::fs::read_to_string(temp.path().join("logs").join("bootstrap.log"))
5826 .unwrap_or_default();
5827 assert_eq!(log.matches("warning[W-SEC-034]").count(), 1, "{log}");
5828 assert!(log.contains("the process harness"), "{log}");
5829 assert!(!log.contains("drivers (d)"), "{log}");
5830 assert!(log.contains("#w-sec-034"), "{log}");
5831 }
5832
5833 #[test]
5836 fn an_advisory_session_says_home_is_relocated_not_protected() {
5837 for tier in [
5838 EnforcementTier::EnvironmentOnly,
5839 EnforcementTier::KernelSeccompOnly,
5840 ] {
5841 let boundary = filesystem_boundary(tier);
5842 let home = boundary
5843 .not_protected
5844 .iter()
5845 .find(|statement| statement.contains("relocated, not protected"))
5846 .unwrap_or_else(|| panic!("{tier:?}: {boundary:?}"));
5847 assert!(home.contains("session's home folder"), "{home}");
5848 assert!(
5849 home.contains("real home directory stays readable and writable by absolute path"),
5850 "{home}"
5851 );
5852 assert!(home.contains("no kernel mechanism"), "{home}");
5853 }
5854 assert!(!filesystem_boundary(EnforcementTier::KernelFull)
5855 .not_protected
5856 .iter()
5857 .any(|statement| statement.contains("relocated, not protected")));
5858 assert!(filesystem_boundary(EnforcementTier::KernelSealed)
5859 .not_protected
5860 .is_empty());
5861 }
5862
5863 #[test]
5865 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
5866 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
5867
5868 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5869 assert_eq!(
5870 report.filesystem_boundary.restriction,
5871 FilesystemRestriction::Advisory
5872 );
5873 assert_eq!(report.filesystem_boundary.not_protected.len(), 5);
5876 assert_eq!(
5877 report.filesystem_boundary.not_protected[2],
5878 ADVISORY_HOME_NOT_PROTECTED
5879 );
5880 assert_eq!(
5881 Some(&report.filesystem_boundary.not_protected[3]),
5882 report.runtime_dir.not_protected_because.as_ref()
5883 );
5884 assert_eq!(
5885 Some(&report.filesystem_boundary.not_protected[4]),
5886 report.records_dir.not_protected_because.as_ref()
5887 );
5888 assert!(report.filesystem_boundary.not_protected[4].contains("the records directory"));
5889
5890 let filesystem = &report.filesystem_boundary.not_protected[0];
5891 assert!(filesystem.contains("advisory"), "{filesystem}");
5892 assert!(
5893 filesystem.contains("denies a read or a write outside the work folder"),
5894 "{filesystem}"
5895 );
5896
5897 let rewriting = &report.filesystem_boundary.not_protected[1];
5898 assert!(rewriting.contains("not a boundary"), "{rewriting}");
5899 assert!(rewriting.contains("HOME"), "{rewriting}");
5900
5901 let rendered = report.render();
5902 let heading = rendered
5903 .find("\nNot protected here\n")
5904 .expect("a session that composes no root prints the block");
5905 assert!(
5906 heading > rendered.find("Containment\n").unwrap(),
5907 "the block belongs under Containment:\n{rendered}"
5908 );
5909 assert!(
5910 heading < rendered.find("\nEffective grants\n").unwrap(),
5911 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
5912 );
5913 for statement in &report.filesystem_boundary.not_protected {
5914 assert!(rendered.contains(statement.as_str()), "{rendered}");
5915 }
5916 }
5917
5918 fn inside_placement() -> RuntimeDirPlacement {
5919 RuntimeDirPlacement {
5920 sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
5921 records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
5922 workdir: std::path::PathBuf::from("/home/dev"),
5923 sessions_inside_workdir: true,
5924 records_inside_workdir: true,
5925 }
5926 }
5927
5928 #[test]
5930 fn runtime_dir_report_makes_one_claim_per_tier() {
5931 let outside = RuntimeDirPlacement::outside_for_test();
5932 for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
5933 let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
5934 assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
5935 assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
5936 assert_eq!(sealed.not_protected_because, None);
5937 }
5938
5939 let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
5940 assert!(full.read_only_to_commands);
5941 assert_eq!(full.enforced_by, Some("landlock"));
5942 assert_eq!(full.not_protected_because, None);
5943
5944 for abi in [Some(1), Some(2)] {
5945 let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
5946 assert!(!old.read_only_to_commands);
5947 assert_eq!(old.enforced_by, None);
5948 let reason = old.not_protected_because.unwrap();
5949 assert!(
5950 reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
5951 && reason.contains("truncate(2)")
5952 && reason.contains("6.2"),
5953 "{reason}"
5954 );
5955 }
5956
5957 let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
5958 assert!(!seccomp.read_only_to_commands);
5959 assert!(seccomp
5960 .not_protected_because
5961 .unwrap()
5962 .contains("no Landlock"));
5963
5964 let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
5965 assert!(!none.read_only_to_commands);
5966 let reason = none.not_protected_because.unwrap();
5967 assert!(
5968 reason.contains("no kernel containment") && reason.contains("macOS"),
5969 "{reason}"
5970 );
5971
5972 assert_eq!(
5973 full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
5974 "the path carries the literal session-id segment"
5975 );
5976 }
5977
5978 #[test]
5980 fn runtime_dir_report_inside_the_workdir_is_never_protected() {
5981 for tier in ALL_TIERS {
5982 let report = runtime_dir_report(*tier, Some(6), &inside_placement());
5983 assert!(!report.read_only_to_commands, "{tier:?}");
5984 assert_eq!(report.enforced_by, None);
5985 }
5986 let reason =
5987 runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
5988 .not_protected_because
5989 .unwrap();
5990 assert!(
5991 reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
5992 "{reason}"
5993 );
5994 assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
5995 }
5996
5997 #[test]
6000 fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
6001 let inside = inside_placement();
6002 assert_eq!(
6003 achieved_containment_class(EnforcementTier::KernelSealed, false, true),
6004 ContainmentClass::Advisory
6005 );
6006 assert_eq!(
6007 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
6008 ContainmentClass::Sealed
6009 );
6010 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
6011 match check_containment_floor(
6012 declared,
6013 ContainmentClass::Advisory,
6014 None,
6015 false,
6016 inside.inside(),
6017 ) {
6018 Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
6019 assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
6020 assert!(reason.contains("the workdir /home/dev"), "{reason}");
6021 assert!(
6022 reason.contains("run with a --workdir that does not contain ~/.murmur"),
6023 "{reason}"
6024 );
6025 }
6026 other => panic!("{declared:?}: {other:?}"),
6027 }
6028 }
6029 assert!(check_containment_floor(
6030 ContainmentClass::Advisory,
6031 ContainmentClass::Advisory,
6032 None,
6033 false,
6034 inside.inside()
6035 )
6036 .is_ok());
6037
6038 let report = report_for(
6039 &sample_policy(),
6040 ContainmentClass::Advisory,
6041 EnforcementTier::KernelFull,
6042 None,
6043 None,
6044 Some(6),
6045 None,
6046 Vec::new(),
6047 Vec::new(),
6048 Vec::new(),
6049 &inside,
6050 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6051 EnforcementTier::KernelFull,
6052 ContainmentClass::Advisory,
6053 )),
6054 IoMaxReport::default(),
6055 );
6056 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
6057 let reason = report.runtime_dir.not_protected_because.clone().unwrap();
6058 assert!(report.filesystem_boundary.not_protected.contains(&reason));
6059 let rendered = report.render();
6060 assert!(
6061 rendered.contains(&format!(" commands: can change it — {reason}\n")),
6062 "{rendered}"
6063 );
6064 }
6065
6066 #[test]
6068 fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
6069 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
6070 let rendered = report.render();
6071 let block =
6072 "Runtime directory\n path: /home/test/.murmur/sessions/<session-id>/runtime\n \
6073 commands: read only (landlock)\n";
6074 let at = rendered.find(block).expect(&rendered);
6075 assert!(at > rendered.find("Containment\n").unwrap());
6076 assert!(at < rendered.find("Effective grants").unwrap());
6077
6078 let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
6079 assert!(sealed
6080 .render()
6081 .contains(" commands: read only (mountns+landlock)\n"));
6082
6083 let json = serde_json::to_string(&report.runtime_dir).unwrap();
6084 assert_eq!(
6085 json,
6086 r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
6087 );
6088 }
6089
6090 #[test]
6093 fn a_landlock_session_reports_denial_rather_than_absence() {
6094 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
6095
6096 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
6097 assert_eq!(
6098 report.filesystem_boundary.restriction,
6099 FilesystemRestriction::Enforced
6100 );
6101 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
6102
6103 let filesystem = &report.filesystem_boundary.not_protected[0];
6104 assert!(
6105 !filesystem.contains("advisory"),
6106 "Landlock mediates the filesystem here: {filesystem}"
6107 );
6108 assert!(
6109 filesystem.contains("denied rather than absent"),
6110 "{filesystem}"
6111 );
6112 for probe in ["stat", "access", "readlink"] {
6113 assert!(filesystem.contains(probe), "{filesystem}");
6114 }
6115 }
6116
6117 #[test]
6121 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
6122 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
6123
6124 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
6125 assert_eq!(report.enforcement_tier, "landlock+seccomp");
6126 assert_eq!(
6127 report.host_containment,
6128 ContainmentClass::Sealed,
6129 "the host can back sealed, which only `host_containment` says"
6130 );
6131 assert_eq!(
6132 report.filesystem_boundary.restriction,
6133 FilesystemRestriction::Enforced,
6134 "no composed root is installed for a capsule that did not declare one"
6135 );
6136 assert_eq!(
6137 report.filesystem_boundary.not_protected,
6138 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
6139 .filesystem_boundary
6140 .not_protected,
6141 "the same mechanism gets the same statements on either host"
6142 );
6143 assert!(report.render().contains("Not protected here"));
6144 }
6145
6146 #[test]
6149 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
6150 let advisory =
6151 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
6152 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
6153
6154 assert_eq!(
6155 advisory.filesystem_boundary.not_protected[1],
6156 enforced.filesystem_boundary.not_protected[1]
6157 );
6158 assert!(
6159 advisory.filesystem_boundary.not_protected[1]
6160 .contains("home/ in its records directory"),
6161 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
6162 );
6163 }
6164
6165 #[test]
6168 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
6169 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
6170
6171 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
6172 assert_eq!(
6173 report.filesystem_boundary.restriction,
6174 FilesystemRestriction::Absent
6175 );
6176 assert!(report.filesystem_boundary.not_protected.is_empty());
6177 assert!(
6178 !report.render().contains("Not protected here"),
6179 "{}",
6180 report.render()
6181 );
6182
6183 let json = serde_json::to_value(&report).unwrap();
6184 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
6185 assert_eq!(
6186 json["filesystem_boundary"]["not_protected"],
6187 serde_json::json!([]),
6188 "the key is present and empty at absent, never missing"
6189 );
6190 }
6191
6192 #[test]
6196 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
6197 let policy = CapabilityPolicy {
6198 workdir_exec_allowed: true,
6199 ..CapabilityPolicy::default()
6200 };
6201
6202 let report = report_for(
6203 &policy,
6204 ContainmentClass::Advisory,
6205 EnforcementTier::KernelSealed,
6206 None,
6207 None,
6208 Some(6),
6209 None,
6210 Vec::new(),
6211 Vec::new(),
6212 Vec::new(),
6213 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6214 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6215 EnforcementTier::KernelSealed,
6216 ContainmentClass::Advisory,
6217 )),
6218 IoMaxReport::default(),
6219 );
6220
6221 assert_eq!(report.enforcement_tier, "landlock+seccomp");
6222 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
6223 assert_eq!(
6224 report.filesystem_boundary.restriction,
6225 FilesystemRestriction::Enforced
6226 );
6227 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
6228 assert!(report.render().contains("Not protected here"));
6229 }
6230
6231 #[test]
6234 fn the_restriction_follows_the_tier_the_session_would_install() {
6235 for tier in ALL_TIERS {
6236 for declared in [
6237 ContainmentClass::Advisory,
6238 ContainmentClass::Scoped,
6239 ContainmentClass::Sealed,
6240 ] {
6241 let report = boundary_report(declared, *tier);
6242 let expected = match crate::sandbox::applied_tier(*tier, declared) {
6243 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
6244 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
6245 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
6246 FilesystemRestriction::Advisory
6247 }
6248 EnforcementTier::Unconfined => FilesystemRestriction::None,
6249 };
6250 assert_eq!(
6251 report.filesystem_boundary.restriction, expected,
6252 "{tier:?} / {declared:?}"
6253 );
6254 assert_eq!(
6255 report.filesystem_boundary.not_protected.is_empty(),
6256 expected == FilesystemRestriction::Absent,
6257 "{tier:?} / {declared:?}"
6258 );
6259 let wire = match expected {
6260 FilesystemRestriction::Advisory => "advisory",
6261 FilesystemRestriction::Enforced => "enforced",
6262 FilesystemRestriction::Absent => "absent",
6263 FilesystemRestriction::None => "none",
6264 };
6265 assert_eq!(
6266 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
6267 serde_json::Value::String(wire.to_string())
6268 );
6269 }
6270 }
6271 }
6272
6273 #[test]
6286 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
6287 if crate::network_namespace::skip_without_egress_namespace(
6288 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
6289 ) {
6290 return;
6291 }
6292 if crate::sandbox::find_on_path("bash").is_none() {
6293 eprintln!(
6294 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
6295 no bash on PATH"
6296 );
6297 return;
6298 }
6299 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
6300 let secret = outside.path().join("host-file");
6301 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
6302 let secret = secret.to_str().unwrap().to_string();
6303
6304 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
6305 let workdir = tempfile::tempdir().unwrap();
6306 let policy = CapabilityPolicy {
6307 shell_allow: vec!["bash".to_string()],
6308 ..CapabilityPolicy::default()
6309 };
6310 let probe = crate::sandbox::HostProbe::probe();
6311 let host_tier = probe.tier();
6312 let runtime = tempfile::tempdir().unwrap();
6313 let enforcement =
6314 crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
6315 .unwrap()
6316 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
6317 let report = report_for(
6318 &policy,
6319 declared,
6320 host_tier,
6321 None,
6322 None,
6323 None,
6324 None,
6325 Vec::new(),
6326 Vec::new(),
6327 Vec::new(),
6328 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6329 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6330 host_tier, declared,
6331 )),
6332 IoMaxReport::default(),
6333 );
6334
6335 let command = format!(
6336 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
6337 else echo read=refused; fi; \
6338 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
6339 );
6340 let result = crate::shell::execute_shell(
6341 "bash",
6342 &["-c", &command],
6343 &[],
6344 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
6345 &policy,
6346 &enforcement,
6347 )
6348 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
6349 let seen = result.stdout;
6350
6351 match report.filesystem_boundary.restriction {
6352 FilesystemRestriction::Advisory => assert!(
6353 seen.contains("read=PROBE-CONTENTS"),
6354 "reported advisory, so nothing denies the read: {seen}"
6355 ),
6356 FilesystemRestriction::Enforced => assert!(
6357 seen.contains("read=refused") && seen.contains("exists=yes"),
6358 "reported enforced, so the read is denied and the path still exists: {seen}"
6359 ),
6360 FilesystemRestriction::Absent => assert!(
6361 seen.contains("read=refused") && seen.contains("exists=no"),
6362 "reported absent, so the path is not in the composed root: {seen}"
6363 ),
6364 FilesystemRestriction::None => assert!(
6365 seen.contains("read=PROBE-CONTENTS"),
6366 "reported none, so nothing denies the read: {seen}"
6367 ),
6368 }
6369 }
6370 }
6371
6372 fn capability_policy_field_names(source: &str) -> Vec<String> {
6374 source
6375 .split_once("pub struct CapabilityPolicy {")
6376 .expect("types.rs declares CapabilityPolicy")
6377 .1
6378 .split_once("\n}")
6379 .expect("CapabilityPolicy's declaration is closed at column 0")
6380 .0
6381 .lines()
6382 .filter_map(|line| {
6383 line.strip_prefix(" pub ")
6384 .and_then(|rest| rest.split_once(':'))
6385 .map(|(name, _)| name.to_string())
6386 })
6387 .collect()
6388 }
6389
6390 #[test]
6395 fn the_temp_dir_report_follows_the_per_tier_table() {
6396 use crate::temp_dir::temp_mapping;
6397
6398 let placement = RuntimeDirPlacement::outside_for_test();
6399 let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
6400 let tmp = crate::temp_dir::canonical_tmp();
6401 let elsewhere = std::path::Path::new("/work");
6402 let rows = [
6404 (
6405 EnforcementTier::KernelSealed,
6406 ContainmentClass::Sealed,
6407 true,
6408 elsewhere,
6409 true,
6410 Some("composed-root"),
6411 ),
6412 (
6413 EnforcementTier::KernelSealed,
6414 ContainmentClass::Scoped,
6415 true,
6416 elsewhere,
6417 true,
6418 Some("mount-namespace"),
6419 ),
6420 (
6421 EnforcementTier::KernelSealed,
6422 ContainmentClass::Advisory,
6423 true,
6424 elsewhere,
6425 true,
6426 Some("mount-namespace"),
6427 ),
6428 (
6429 EnforcementTier::KernelFull,
6430 ContainmentClass::Scoped,
6431 true,
6432 elsewhere,
6433 true,
6434 Some("mount-namespace"),
6435 ),
6436 (
6437 EnforcementTier::KernelFull,
6438 ContainmentClass::Scoped,
6439 false,
6440 elsewhere,
6441 false,
6442 None,
6443 ),
6444 (
6445 EnforcementTier::KernelSeccompOnly,
6446 ContainmentClass::Advisory,
6447 true,
6448 elsewhere,
6449 true,
6450 Some("mount-namespace"),
6451 ),
6452 (
6453 EnforcementTier::KernelSeccompOnly,
6454 ContainmentClass::Advisory,
6455 false,
6456 elsewhere,
6457 false,
6458 None,
6459 ),
6460 (
6461 EnforcementTier::EnvironmentOnly,
6462 ContainmentClass::Advisory,
6463 false,
6464 elsewhere,
6465 false,
6466 None,
6467 ),
6468 (
6469 EnforcementTier::KernelFull,
6470 ContainmentClass::Scoped,
6471 true,
6472 tmp.as_path(),
6473 false,
6474 None,
6475 ),
6476 (
6477 EnforcementTier::KernelSeccompOnly,
6478 ContainmentClass::Advisory,
6479 true,
6480 tmp.as_path(),
6481 false,
6482 None,
6483 ),
6484 ];
6485 for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
6486 let applied = crate::sandbox::applied_tier(tier, declared);
6487 let mapping = temp_mapping(applied, usable, workdir);
6488 let report = temp_dir_report(mapping, &placement);
6489 let row = format!(
6490 "{tier:?} {declared:?} usable={usable} workdir={}",
6491 workdir.display()
6492 );
6493 assert_eq!(report.path, folder, "{row}");
6494 assert_eq!(report.tmp_mapped, mapped, "{row}");
6495 assert_eq!(report.mapped_by, mapped_by, "{row}");
6496 assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
6497 assert_eq!(
6498 report
6499 .not_mapped_because
6500 .as_deref()
6501 .is_some_and(|reason| !reason.is_empty()),
6502 !mapped,
6503 "{row}"
6504 );
6505
6506 let scope = report_for(
6507 &CapabilityPolicy::default(),
6508 declared,
6509 tier,
6510 None,
6511 None,
6512 None,
6513 None,
6514 Vec::new(),
6515 Vec::new(),
6516 Vec::new(),
6517 &placement,
6518 mapping,
6519 IoMaxReport::default(),
6520 );
6521 assert_eq!(scope.temp_dir, report, "{row}");
6522 }
6523
6524 let reason = |tier, usable, workdir| {
6525 temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
6526 .not_mapped_because
6527 .unwrap()
6528 };
6529 assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
6530 .starts_with("this host has no kernel containment for capsule commands"));
6531 assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
6532 .contains("neither a private mount namespace nor Landlock"));
6533 assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
6534 .contains("a literal /tmp path is refused"));
6535 assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
6536 .starts_with("the workdir is /tmp itself"));
6537 }
6538
6539 #[test]
6542 fn the_temp_folder_block_renders_after_the_records_directory() {
6543 use crate::temp_dir::{NotMappedReason, TempMapping};
6544
6545 let placement = RuntimeDirPlacement::outside_for_test();
6546 let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
6547 assert_eq!(
6548 render_temp_dir(&mapped),
6549 "Temporary folder\n path: /home/test/.murmur/records/work/<session-id>/tmp\n \
6550 TMPDIR: /tmp\n /tmp: mapped (mount-namespace)\n"
6551 );
6552 let unmapped = temp_dir_report(
6553 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
6554 &placement,
6555 );
6556 assert_eq!(
6557 render_temp_dir(&unmapped),
6558 format!(
6559 "Temporary folder\n path: {path}\n TMPDIR: {path}\n /tmp: not \
6560 mapped — {}\n",
6561 NotMappedReason::NoMountNamespace.describe(),
6562 path = "/home/test/.murmur/records/work/<session-id>/tmp"
6563 )
6564 );
6565 assert_eq!(
6566 serde_json::to_value(&mapped).unwrap(),
6567 serde_json::json!({
6568 "path": "/home/test/.murmur/records/work/<session-id>/tmp",
6569 "tmpdir": "/tmp",
6570 "tmp_mapped": true,
6571 "mapped_by": "mount-namespace",
6572 "not_mapped_because": null,
6573 })
6574 );
6575
6576 let scope = report_for(
6577 &CapabilityPolicy::default(),
6578 ContainmentClass::Scoped,
6579 EnforcementTier::KernelFull,
6580 None,
6581 None,
6582 None,
6583 None,
6584 Vec::new(),
6585 Vec::new(),
6586 Vec::new(),
6587 &placement,
6588 TempMapping::MountNamespace,
6589 IoMaxReport::default(),
6590 );
6591 let rendered = scope.render();
6592 let records = rendered.find("Records directory\n").expect("records block");
6593 let temp = rendered.find("Temporary folder\n").expect("temp block");
6594 assert!(records < temp, "{rendered}");
6595 assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
6596 assert!(serde_json::to_value(&scope)
6597 .unwrap()
6598 .get("temp_dir")
6599 .is_some());
6600 }
6601}