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)]
975pub struct ScopeReport {
976 pub declared_containment: ContainmentClass,
978 pub achieved_containment: ContainmentClass,
982 pub host_containment: ContainmentClass,
986 pub floor_met: bool,
988 #[serde(skip_serializing_if = "Option::is_none")]
990 pub shortfall_reason: Option<String>,
991 pub enforcement_tier: &'static str,
994 pub routes: Vec<RouteReport>,
1003 pub userns_grant: Option<UsernsGrant>,
1013 pub landlock_abi: Option<u8>,
1023 pub filesystem_scope: Option<String>,
1025 pub workdir_exec: bool,
1033 pub read_only_paths: Vec<String>,
1043 pub read_only_advisory_for: Vec<String>,
1053 pub preopens: Vec<PreopenReport>,
1066 pub network_allow: Vec<String>,
1068 pub unix_sockets: bool,
1070 pub shell_allow: Vec<String>,
1072 pub spawn_allow: Vec<String>,
1074 pub spawn_workdir_snapshot: Vec<String>,
1078 pub parent_workdir: Option<crate::workdir_snapshot::ParentWorkdirReport>,
1086 pub images: Option<ImagesReport>,
1090 pub env_allow: Vec<String>,
1092 pub plan_submit: bool,
1100 pub install_skill: Vec<String>,
1108 pub install_tool: Vec<String>,
1112 pub install_from: Vec<String>,
1117 pub interpreter_runtime_grants: Vec<String>,
1121 pub derived_interpreter_runtime: Vec<String>,
1126 pub trust_store: Vec<String>,
1132 pub name_resolution: Vec<String>,
1136 pub host_paths: Vec<crate::host_paths::HostPathReport>,
1141 pub exports_files: Option<ExportsFilesReport>,
1151 pub peer_files: Option<PeerFilesReport>,
1159 pub peer_tasks: bool,
1165 pub peer_fetch_allow: Vec<String>,
1172 pub state_stores: Vec<StateStoreReport>,
1182 pub configured_artifacts: Vec<String>,
1196 pub staged_runtime_grants: Vec<String>,
1205 pub runtime_writes: Vec<crate::workdir_writes::RuntimeWriteReport>,
1220 pub manifest_files: Vec<crate::manifest_files::ManifestFileReport>,
1230 pub io_max: IoMaxReport,
1246 pub cgroup_scope: CgroupScopeReport,
1255 pub filesystem_boundary: FilesystemBoundaryReport,
1267 pub runtime_dir: RuntimeDirReport,
1272 pub records_dir: RuntimeDirReport,
1276 pub temp_dir: TempDirReport,
1280 pub process_view: crate::proc_view::ProcessViewReport,
1284 pub unconfined: Option<UnconfinedReport>,
1287}
1288
1289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1291pub struct UnconfinedReport {
1292 pub user: Option<String>,
1294 pub uid: u32,
1296 pub home: Option<String>,
1299 pub applies_to: Vec<&'static str>,
1301 pub resource_limits: Vec<&'static str>,
1304}
1305
1306pub const UNCONFINED_APPLIES_TO: &[&str] = &["shell", "plan-shell-step", "process-harness"];
1309
1310impl UnconfinedReport {
1311 pub(crate) fn for_this_process(declared: &murmur_artifact::ResourceCapabilities) -> Self {
1314 let uid = current_uid();
1315 Self {
1316 user: account_name(uid),
1317 uid,
1318 home: std::env::var_os("HOME").map(|home| home.to_string_lossy().into_owned()),
1319 applies_to: UNCONFINED_APPLIES_TO.to_vec(),
1320 resource_limits: crate::resources::declared_resource_fields(declared),
1321 }
1322 }
1323
1324 #[must_use]
1326 pub fn render(&self) -> String {
1327 let applies_to = self
1328 .applies_to
1329 .iter()
1330 .map(|route| match *route {
1331 "shell" => "shell commands",
1332 "plan-shell-step" => "plan shell steps",
1333 "process-harness" => "process harness",
1334 other => other,
1335 })
1336 .collect::<Vec<_>>()
1337 .join(", ");
1338 let limits = if self.resource_limits.is_empty() {
1339 "none declared".to_string()
1340 } else {
1341 self.resource_limits.join(", ")
1342 };
1343 format!(
1344 "Unconfined\n user: {}\n uid: {}\n HOME: {}\n applies to: \
1345 {applies_to}\n resource limits: {limits}\n",
1346 self.user.as_deref().unwrap_or("<unknown>"),
1347 self.uid,
1348 self.home.as_deref().unwrap_or("<unset>"),
1349 )
1350 }
1351}
1352
1353pub(crate) fn unconfined_warning(report: &UnconfinedReport) -> String {
1355 let user = match &report.user {
1356 Some(user) => format!("{user} (uid {})", report.uid),
1357 None => format!("uid {}", report.uid),
1358 };
1359 let home = report.home.as_deref().unwrap_or("<unset>");
1360 format!(
1361 "this capsule runs unconfined: its shell commands, plan shell steps and process harness \
1362 run as {user} with HOME {home}, the whole environment mur run was \
1363 started with (credential variables included), the host filesystem and network as that \
1364 account has them, and no no_new_privs, so sudo works wherever that account's sudoers \
1365 allows it"
1366 )
1367}
1368
1369pub(crate) fn warn_unconfined_launch(
1372 records_root: &crate::workdir_fs::WorkdirRoot,
1373 report: &UnconfinedReport,
1374) {
1375 let code = murmur_artifact::security_warnings::W_SEC_036;
1376 let message = unconfined_warning(report);
1377 let link = murmur_artifact::security_warning_link(code);
1378 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
1379 crate::agent::append_bootstrap_log(
1380 records_root,
1381 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
1382 );
1383}
1384
1385#[cfg(unix)]
1387pub(crate) fn current_uid() -> u32 {
1388 rustix::process::getuid().as_raw()
1389}
1390
1391#[cfg(not(unix))]
1392pub(crate) fn current_uid() -> u32 {
1393 0
1394}
1395
1396#[cfg(unix)]
1398#[allow(unsafe_code)]
1399pub(crate) fn account_name(uid: u32) -> Option<String> {
1400 let mut buffer = vec![0 as libc::c_char; 16 * 1024];
1401 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
1404 let mut result: *mut libc::passwd = std::ptr::null_mut();
1405 let rc = unsafe {
1408 libc::getpwuid_r(
1409 uid,
1410 &mut entry,
1411 buffer.as_mut_ptr(),
1412 buffer.len(),
1413 &mut result,
1414 )
1415 };
1416 if rc != 0 || result.is_null() || entry.pw_name.is_null() {
1417 return None;
1418 }
1419 let name = unsafe { std::ffi::CStr::from_ptr(entry.pw_name) };
1422 Some(name.to_string_lossy().into_owned())
1423}
1424
1425#[cfg(not(unix))]
1426pub(crate) fn account_name(_uid: u32) -> Option<String> {
1427 None
1428}
1429
1430impl ScopeReport {
1431 pub fn render(&self) -> String {
1433 let mut out = String::new();
1434 out.push_str("Containment\n");
1435 out.push_str(&format!(" declared: {}\n", self.declared_containment));
1436 out.push_str(&format!(" achieved: {}\n", self.achieved_containment));
1437 out.push_str(&format!(" host can back: {}\n", self.host_containment));
1438 out.push_str(&format!(
1439 " floor met: {}\n",
1440 if self.floor_met { "yes" } else { "no" }
1441 ));
1442 if let Some(reason) = &self.shortfall_reason {
1443 out.push_str(&format!(" reason: {reason}\n"));
1444 }
1445 out.push_str(&format!(" mechanism: {}\n", self.enforcement_tier));
1446 out.push_str(&format!(
1447 " userns grant: {}\n",
1448 match self.userns_grant {
1449 Some(grant) => grant.wire_name(),
1450 None => "n/a",
1451 }
1452 ));
1453 out.push_str(&format!(
1454 " landlock ABI: {}\n",
1455 match self.landlock_abi {
1456 Some(abi) => abi.to_string(),
1457 None => "n/a".to_string(),
1458 }
1459 ));
1460 out.push_str(&format!(
1461 " {}\n",
1462 crate::cgroup::render_cgroup_scope(&self.cgroup_scope)
1463 ));
1464
1465 out.push('\n');
1466 out.push_str(&render_routes(&self.routes));
1467
1468 if let Some(unconfined) = &self.unconfined {
1469 out.push('\n');
1470 out.push_str(&unconfined.render());
1471 }
1472
1473 out.push('\n');
1474 out.push_str(&render_runtime_dir(&self.runtime_dir));
1475 out.push('\n');
1476 out.push_str(&render_records_dir(&self.records_dir));
1477 out.push('\n');
1478 out.push_str(&render_temp_dir(&self.temp_dir));
1479 out.push('\n');
1480 out.push_str(&crate::proc_view::render_process_view(&self.process_view));
1481
1482 out.push_str(&render_not_protected(&self.filesystem_boundary));
1483
1484 out.push_str("\nEffective grants\n");
1485 out.push_str(&format!(
1486 " filesystem scope: {}\n",
1487 self.filesystem_scope.as_deref().unwrap_or("<none>")
1488 ));
1489 out.push_str(&format!(" workdir exec: {}\n", self.workdir_exec));
1490 out.push_str(&render_read_only(
1491 &self.read_only_paths,
1492 &self.read_only_advisory_for,
1493 ));
1494 push_list(
1495 &mut out,
1496 "preopens",
1497 &self
1498 .preopens
1499 .iter()
1500 .map(PreopenReport::render)
1501 .collect::<Vec<_>>(),
1502 );
1503 push_list(&mut out, "network allow", &self.network_allow);
1504 out.push_str(&format!(" unix sockets: {}\n", self.unix_sockets));
1505 push_list(&mut out, "shell allow", &self.shell_allow);
1506 push_list(&mut out, "spawn allow", &self.spawn_allow);
1507 push_list(
1508 &mut out,
1509 "spawn workdir snapshot",
1510 &self.spawn_workdir_snapshot,
1511 );
1512 push_list(&mut out, "env allow", &self.env_allow);
1513 out.push_str(&format!(" plan submit: {}\n", self.plan_submit));
1514 out.push_str(&render_install(
1515 &self.install_skill,
1516 &self.install_tool,
1517 &self.install_from,
1518 ));
1519 push_list(
1520 &mut out,
1521 "interpreter runtime",
1522 &self.interpreter_runtime_grants,
1523 );
1524 push_list(
1525 &mut out,
1526 "derived interpreter runtime",
1527 &self.derived_interpreter_runtime,
1528 );
1529 push_list(&mut out, "trust store", &self.trust_store);
1530 push_list(&mut out, "name resolution", &self.name_resolution);
1531 push_list(
1532 &mut out,
1533 "host paths",
1534 &self
1535 .host_paths
1536 .iter()
1537 .map(crate::host_paths::HostPathReport::render)
1538 .collect::<Vec<_>>(),
1539 );
1540 push_list(&mut out, "staged runtime", &self.staged_runtime_grants);
1541 push_list(
1542 &mut out,
1543 "state stores",
1544 &self
1545 .state_stores
1546 .iter()
1547 .map(|store| format!("{}: {} -> {}", store.artifact, store.store, store.host_path))
1548 .collect::<Vec<_>>(),
1549 );
1550 push_list(&mut out, "artifact config", &self.configured_artifacts);
1551
1552 if let Some(parent) = &self.parent_workdir {
1553 out.push_str(&render_parent_workdir(parent));
1554 }
1555
1556 if let Some(images) = &self.images {
1557 out.push_str(&render_images(images));
1558 }
1559
1560 out.push_str("\nResource plane\n");
1561 match &self.exports_files {
1562 None => out.push_str(" exports.files: <none>\n"),
1563 Some(export) => {
1564 out.push_str(&format!(" exports.files root: {}\n", export.root));
1565 out.push_str(&format!(" mode: {}\n", export.mode));
1566 out.push_str(&format!(
1567 " max bytes: {} (per file)\n",
1568 export.max_bytes
1569 ));
1570 }
1571 }
1572
1573 out.push_str(&format!(
1574 " io.max: {} ({} bytes/s declared)\n",
1575 match self.io_max.status {
1576 IoMaxStatus::Enforced => "enforced",
1577 IoMaxStatus::Unavailable => "unavailable",
1578 IoMaxStatus::NotRequired => "not-required",
1579 IoMaxStatus::NotProbed => "not-probed",
1580 },
1581 self.io_max.declared_bytes_per_sec
1582 ));
1583 if let Some(reason) = &self.io_max.reason {
1584 out.push_str(&format!(" {reason}\n"));
1585 }
1586
1587 out.push_str("\nPeer handoff\n");
1588 match &self.peer_files {
1589 None => out.push_str(" exports.peer_files: <none>\n"),
1590 Some(peer) => {
1591 out.push_str(&format!(" exports.peer_files root: {}\n", peer.root));
1592 out.push_str(&format!(
1593 " max handle ttl: {}s\n",
1594 peer.max_ttl_secs
1595 ));
1596 out.push_str(&format!(
1597 " max bytes: {} (per file)\n",
1598 peer.max_bytes
1599 ));
1600 }
1601 }
1602 out.push_str(if self.peer_tasks {
1603 " exports.peer_tasks: accept\n"
1604 } else {
1605 " exports.peer_tasks: refuse\n"
1606 });
1607 push_list(&mut out, "peer_fetch allow", &self.peer_fetch_allow);
1608
1609 out.push_str("\nRuntime writes\n");
1610 out.push_str(&crate::workdir_writes::render_runtime_writes(
1611 &self.runtime_writes,
1612 ));
1613 out.push_str(&crate::manifest_files::render_manifest_files(
1614 &self.manifest_files,
1615 ));
1616
1617 if !self.floor_met {
1618 out.push_str(
1619 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1620 launch here.\n",
1621 );
1622 } else if self.cgroup_scope.status == CgroupScopeStatus::Unavailable {
1623 out.push_str(
1624 "\nThis is a report only — `mur run` without --explain-scope would refuse to \
1625 launch here with E-RUN-012: this capsule can run a subprocess and no \
1626 cgroup scope can be created to bound it (see `cgroup scope:` above).\n",
1627 );
1628 }
1629 out
1630 }
1631}
1632
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1636pub struct ImagesReport {
1637 pub keep: u32,
1639 pub max_bytes: u64,
1641 pub max_per_turn: u32,
1644 pub keep_applies: bool,
1647}
1648
1649impl ScopeReport {
1650 #[must_use]
1653 pub fn with_images(
1654 mut self,
1655 images: murmur_artifact::ContextImagesConfig,
1656 transport: Option<&str>,
1657 ) -> Self {
1658 self.images = Some(ImagesReport {
1659 keep: images.keep,
1660 max_bytes: images.max_bytes,
1661 max_per_turn: images.max_per_turn,
1662 keep_applies: transport != Some("process"),
1663 });
1664 self
1665 }
1666
1667 #[must_use]
1673 pub fn with_routes(mut self, transport: Option<&str>) -> Self {
1674 let inputs = RouteInputs::new(
1675 &self.shell_allow,
1676 self.plan_submit,
1677 &self.install_tool,
1678 &self.preopens,
1679 transport,
1680 );
1681 self.routes = route_table(&inputs, self.enforcement_tier);
1682 self
1683 }
1684
1685 #[must_use]
1687 pub fn with_manifest_files(
1688 mut self,
1689 rows: Vec<crate::manifest_files::ManifestFileReport>,
1690 ) -> Self {
1691 self.manifest_files = rows;
1692 self
1693 }
1694
1695 #[must_use]
1698 pub fn with_process_view(mut self, view: &crate::proc_view::ProcView) -> Self {
1699 self.process_view = crate::proc_view::process_view_report(view);
1700 self
1701 }
1702
1703 #[must_use]
1705 pub fn with_cgroup_scope(mut self, cgroup_scope: CgroupScopeReport) -> Self {
1706 self.cgroup_scope = cgroup_scope;
1707 self
1708 }
1709
1710 #[must_use]
1714 pub fn with_parent_workdir(
1715 mut self,
1716 snapshot: Option<crate::workdir_snapshot::WorkdirSnapshot>,
1717 ) -> Self {
1718 let restriction = self.filesystem_boundary.restriction;
1719 self.parent_workdir =
1720 snapshot.map(|snapshot| crate::workdir_snapshot::ParentWorkdirReport {
1721 snapshot,
1722 restriction,
1723 });
1724 self
1725 }
1726}
1727
1728#[must_use]
1730pub fn render_images(images: &ImagesReport) -> String {
1731 let mut out = String::from("\nImages\n");
1732 out.push_str(&format!(" max_bytes: {}\n", images.max_bytes));
1733 if images.keep_applies {
1734 out.push_str(&format!(" keep: {}\n", images.keep));
1735 out.push_str(&format!(" max_per_turn: {}\n", images.max_per_turn));
1736 } else {
1737 out.push_str(&format!(
1738 " keep: {} (does nothing here: the harness holds the conversation)\n",
1739 images.keep
1740 ));
1741 out.push_str(&format!(
1742 " max_per_turn: {} (counts the images of one tool call here)\n",
1743 images.max_per_turn
1744 ));
1745 }
1746 out
1747}
1748
1749#[must_use]
1752pub fn render_parent_workdir(parent: &crate::workdir_snapshot::ParentWorkdirReport) -> String {
1753 let snapshot = &parent.snapshot;
1754 let mut out = String::from("\nParent work folder\n");
1755 out.push_str(&format!(" source: {}\n", snapshot.source));
1756 out.push_str(&format!(
1757 " copied: {} files, {} directories, {} symlinks ({} re-pointed), {} bytes\n",
1758 snapshot.files,
1759 snapshot.dirs,
1760 snapshot.symlinks,
1761 snapshot.repointed_symlinks,
1762 snapshot.bytes
1763 ));
1764 if snapshot.skipped_count == 0 {
1765 out.push_str(" skipped: <none>\n");
1766 } else {
1767 out.push_str(&format!(" skipped: {}\n", snapshot.skipped_count));
1768 for entry in &snapshot.skipped {
1769 let reason = serde_json::to_value(entry.reason)
1770 .ok()
1771 .and_then(|value| value.as_str().map(str::to_string))
1772 .unwrap_or_default();
1773 out.push_str(&format!(" - {} ({reason})\n", entry.path));
1774 }
1775 }
1776 out.push_str(&format!(
1777 " restriction: {}\n",
1778 parent.restriction_sentence()
1779 ));
1780 out
1781}
1782
1783#[must_use]
1791pub fn read_only_advisory_for(read_only_paths: &[String], shell_allow: &[String]) -> Vec<String> {
1792 if read_only_paths.is_empty() {
1793 return Vec::new();
1794 }
1795 shell_allow
1796 .iter()
1797 .filter(|binary| crate::protected_paths::is_advisory_interpreter(binary))
1798 .cloned()
1799 .collect()
1800}
1801
1802#[must_use]
1810pub fn render_read_only(read_only_paths: &[String], advisory_for: &[String]) -> String {
1811 let mut out = String::new();
1812 push_list(&mut out, "read_only", read_only_paths);
1813 if read_only_paths.is_empty() {
1814 return out;
1815 }
1816 if advisory_for.is_empty() {
1817 out.push_str(
1818 " read_only enforcement: enforced for every tool call and every shell command the \
1819 dispatch check can read\n",
1820 );
1821 } else {
1822 out.push_str(&format!(
1823 " read_only enforcement: advisory against {} — an interpreter's own file I/O is not \
1824 something the dispatch check can read; enforced for every tool call and for every \
1825 shell command it can read\n",
1826 advisory_for.join(", ")
1827 ));
1828 }
1829 out
1830}
1831
1832#[must_use]
1840pub fn render_install(
1841 install_skill: &[String],
1842 install_tool: &[String],
1843 install_from: &[String],
1844) -> String {
1845 let mut out = String::new();
1846 push_list(&mut out, "install skill", install_skill);
1847 push_list(&mut out, "install tool", install_tool);
1848 if !install_from.is_empty() {
1849 out.push_str(&format!(" install from: {}\n", install_from.join(", ")));
1850 }
1851 out
1852}
1853
1854#[must_use]
1856pub fn install_from_places(policy: &CapabilityPolicy) -> Vec<String> {
1857 if !crate::install_grant::is_granted(policy) {
1858 return Vec::new();
1859 }
1860 let mut places = vec!["stores".to_string()];
1861 if policy.install_from_sources {
1862 places.push("sources".to_string());
1863 }
1864 places
1865}
1866
1867fn render_not_protected(boundary: &FilesystemBoundaryReport) -> String {
1875 if boundary.not_protected.is_empty() {
1876 return String::new();
1877 }
1878 let mut out = String::from("\nNot protected here\n");
1879 for statement in &boundary.not_protected {
1880 out.push_str(&format!(" - {statement}\n"));
1881 }
1882 out
1883}
1884
1885fn push_list(out: &mut String, label: &str, values: &[String]) {
1886 if values.is_empty() {
1887 out.push_str(&format!(" {label}: <none>\n"));
1888 return;
1889 }
1890 out.push_str(&format!(" {label}:\n"));
1891 for value in values {
1892 out.push_str(&format!(" - {value}\n"));
1893 }
1894}
1895
1896#[allow(clippy::too_many_arguments)]
1912pub fn explain_scope(
1913 policy: &CapabilityPolicy,
1914 declared: ContainmentClass,
1915 exports: Option<&Exports>,
1916 state_stores: Vec<StateStoreReport>,
1917 configured_artifacts: Vec<String>,
1918 preopens: Vec<PreopenReport>,
1919 io_max: IoMaxReport,
1920 workdir: &std::path::Path,
1921) -> Result<ScopeReport, crate::murmur_home::HomeUnusable> {
1922 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1923 let probe = HostProbe::probe();
1924 let decision = ContainmentDecision::decide(
1925 probe.tier(),
1926 declared,
1927 policy.workdir_exec_allowed,
1928 placement.inside_workdir(),
1929 );
1930 let temp_mapping = crate::temp_dir::temp_mapping(
1931 decision.applied_tier(),
1932 probe.mount_namespace_usable(),
1933 &placement.workdir,
1934 );
1935 Ok(scope_report_for_tier(
1936 policy,
1937 &decision,
1938 probe.sealed_shortfall().as_ref(),
1939 probe.userns_grant(),
1940 probe.landlock_kernel_abi(),
1941 exports,
1942 state_stores,
1943 configured_artifacts,
1944 preopens,
1945 &placement,
1946 temp_mapping,
1947 io_max,
1948 )
1949 .with_process_view(&crate::proc_view::proc_view(
1950 decision.applied_tier(),
1951 probe.namespaces(),
1952 )))
1953}
1954
1955pub fn doctor_runtime_dir(
1963 probe: &HostProbe,
1964 declared: ContainmentClass,
1965 workdir: &std::path::Path,
1966) -> Result<DoctorDirReports, crate::murmur_home::HomeUnusable> {
1967 let placement = crate::runtime_dir::runtime_dir_placement(workdir)?;
1968 let applied = ContainmentDecision::decide(probe.tier(), declared, false, false).applied_tier();
1970 let abi = crate::sandbox::ruleset_landlock_abi(probe.landlock_kernel_abi(), applied);
1971 let temp_mapping =
1972 crate::temp_dir::temp_mapping(applied, probe.mount_namespace_usable(), &placement.workdir);
1973 Ok(DoctorDirReports {
1974 runtime_dir: runtime_dir_report(applied, abi, &placement),
1975 records_dir: records_dir_report(applied, abi, &placement),
1976 temp_dir: temp_dir_report(temp_mapping, &placement),
1977 process_view: crate::proc_view::process_view_report(&crate::proc_view::proc_view(
1978 applied,
1979 probe.namespaces(),
1980 )),
1981 placement,
1982 })
1983}
1984
1985#[derive(Debug, Clone, PartialEq, Eq)]
1987pub struct DoctorDirReports {
1988 pub runtime_dir: RuntimeDirReport,
1989 pub records_dir: RuntimeDirReport,
1990 pub temp_dir: TempDirReport,
1991 pub process_view: crate::proc_view::ProcessViewReport,
1992 pub placement: RuntimeDirPlacement,
1993}
1994
1995#[allow(clippy::too_many_arguments)]
2002pub(crate) fn scope_report_for_tier(
2003 policy: &CapabilityPolicy,
2004 decision: &ContainmentDecision,
2005 sealed_shortfall: Option<&SealedShortfall>,
2006 userns_grant: Option<UsernsGrant>,
2007 landlock_kernel_abi: Option<u8>,
2008 exports: Option<&Exports>,
2009 state_stores: Vec<StateStoreReport>,
2010 configured_artifacts: Vec<String>,
2011 preopens: Vec<PreopenReport>,
2012 runtime_dir_placement: &RuntimeDirPlacement,
2013 temp_mapping: crate::temp_dir::TempMapping,
2014 io_max: IoMaxReport,
2015) -> ScopeReport {
2016 debug_assert_eq!(
2017 *decision,
2018 ContainmentDecision::decide(
2019 decision.host_tier,
2020 decision.declared,
2021 policy.workdir_exec_allowed,
2022 runtime_dir_placement.inside_workdir(),
2023 ),
2024 "the decision must be made from the policy and placement this report states"
2025 );
2026 let declared = decision.declared();
2027 let achieved = decision.achieved();
2028 let applied = decision.applied_tier();
2032 let shortfall_reason = containment_shortfall_reason(
2033 declared,
2034 achieved,
2035 sealed_shortfall,
2036 policy.workdir_exec_allowed,
2037 runtime_dir_placement.inside(),
2038 );
2039 let landlock_abi = crate::sandbox::ruleset_landlock_abi(landlock_kernel_abi, applied);
2040 let runtime_dir = runtime_dir_report(applied, landlock_abi, runtime_dir_placement);
2041 let records_dir = records_dir_report(applied, landlock_abi, runtime_dir_placement);
2042 let mut filesystem_boundary = filesystem_boundary(applied);
2043 let enforcement_tier = decision.enforcement_tier();
2044 let routes = route_table(
2046 &RouteInputs::new(
2047 &policy.shell_allow,
2048 policy.plan_submit,
2049 &policy.install_tool,
2050 &preopens,
2051 None,
2052 ),
2053 enforcement_tier,
2054 );
2055 if applied != EnforcementTier::Unconfined {
2057 for report in [&runtime_dir, &records_dir] {
2058 if let Some(reason) = &report.not_protected_because {
2059 filesystem_boundary.not_protected.push(reason.clone());
2060 }
2061 }
2062 }
2063 let unconfined = (applied == EnforcementTier::Unconfined)
2064 .then(|| UnconfinedReport::for_this_process(&policy.declared_resources));
2065
2066 ScopeReport {
2067 declared_containment: declared,
2068 achieved_containment: achieved,
2069 host_containment: decision.host_containment(),
2070 floor_met: shortfall_reason.is_none(),
2071 shortfall_reason,
2072 enforcement_tier,
2073 routes,
2074 userns_grant,
2075 landlock_abi,
2076 exports_files: exports
2079 .and_then(|exports| exports.files.as_ref())
2080 .map(ExportsFilesReport::from),
2081 peer_files: exports
2082 .and_then(|exports| exports.peer_files.as_ref())
2083 .map(PeerFilesReport::from),
2084 peer_tasks: exports.is_some_and(Exports::accepts_peer_tasks),
2085 peer_fetch_allow: policy.peer_fetch_allow.clone(),
2086 state_stores,
2090 configured_artifacts,
2094 filesystem_scope: policy.filesystem_scope.clone(),
2095 workdir_exec: policy.workdir_exec_allowed,
2096 read_only_paths: policy.read_only_paths.clone(),
2097 read_only_advisory_for: read_only_advisory_for(
2098 &policy.read_only_paths,
2099 &policy.shell_allow,
2100 ),
2101 preopens,
2105 network_allow: policy.network_allow.clone(),
2106 unix_sockets: policy.unix_sockets_allowed,
2107 shell_allow: policy.shell_allow.clone(),
2108 spawn_allow: policy.spawn_allow.clone(),
2109 spawn_workdir_snapshot: policy.spawn_workdir_snapshot.clone(),
2110 parent_workdir: None,
2113 images: None,
2115 env_allow: policy.env_allow.clone(),
2116 plan_submit: policy.plan_submit,
2117 install_skill: policy.install_skill.clone(),
2118 install_tool: policy.install_tool.clone(),
2119 install_from: install_from_places(policy),
2120 interpreter_runtime_grants: policy
2121 .shell_interpreter_runtime
2122 .iter()
2123 .flat_map(|grant| {
2124 grant.dirs.iter().map(move |dir| {
2125 let listable = if dir.list_dir { " (list_dir)" } else { "" };
2126 format!("{}: {}{}", grant.binary, dir.path, listable)
2127 })
2128 })
2129 .collect(),
2130 derived_interpreter_runtime: policy
2131 .shell_derived_interpreter_runtime
2132 .iter()
2133 .flat_map(|runtime| {
2134 let binary = runtime.binary.display();
2135 runtime
2136 .entries()
2137 .map(|(path, is_dir)| {
2138 let listable = if is_dir { " (list_dir)" } else { "" };
2139 format!("{binary}: {}{listable}", path.display())
2140 })
2141 .chain(
2142 runtime
2143 .libraries
2144 .iter()
2145 .map(|library| format!("{binary}: {}", library.display())),
2146 )
2147 .collect::<Vec<_>>()
2148 })
2149 .collect(),
2150 name_resolution: match applied {
2153 EnforcementTier::KernelFull | EnforcementTier::KernelSealed => {
2154 crate::sandbox::SCOPED_NAME_RESOLUTION_PATHS
2155 .iter()
2156 .map(|path| (*path).to_string())
2157 .collect()
2158 }
2159 _ => Vec::new(),
2160 },
2161 trust_store: policy
2162 .shell_trust_store
2163 .iter()
2164 .map(|entry| {
2165 let listable = if entry.directory { " (list_dir)" } else { "" };
2166 format!("{}{listable}", entry.path.display())
2167 })
2168 .collect(),
2169 host_paths: crate::host_paths::host_path_reports(
2172 &policy.host_paths,
2173 crate::host_paths::HostPathMechanism::for_enforcement_tier(decision.enforcement_tier()),
2174 ),
2175 staged_runtime_grants: policy
2176 .shell_staged_runtime
2177 .iter()
2178 .map(|grant| {
2179 format!(
2180 "{}: {} (pin: {})",
2181 grant.binary, grant.source_path, grant.pin
2182 )
2183 })
2184 .collect(),
2185 runtime_writes: crate::workdir_writes::runtime_writes(),
2189 manifest_files: Vec::new(),
2192 io_max,
2196 cgroup_scope: CgroupScopeReport::default(),
2199 filesystem_boundary,
2203 runtime_dir,
2204 records_dir,
2205 temp_dir: temp_dir_report(temp_mapping, runtime_dir_placement),
2206 process_view: crate::proc_view::process_view_report(&crate::proc_view::tier_proc_view(
2209 applied,
2210 )),
2211 unconfined,
2212 }
2213}
2214
2215#[cfg(test)]
2218#[allow(clippy::too_many_arguments)]
2219pub(crate) fn report_for(
2220 policy: &CapabilityPolicy,
2221 declared: ContainmentClass,
2222 tier: EnforcementTier,
2223 sealed_shortfall: Option<&SealedShortfall>,
2224 userns_grant: Option<UsernsGrant>,
2225 landlock_kernel_abi: Option<u8>,
2226 exports: Option<&Exports>,
2227 state_stores: Vec<StateStoreReport>,
2228 configured_artifacts: Vec<String>,
2229 preopens: Vec<PreopenReport>,
2230 runtime_dir_placement: &RuntimeDirPlacement,
2231 temp_mapping: crate::temp_dir::TempMapping,
2232 io_max: IoMaxReport,
2233) -> ScopeReport {
2234 let decision = ContainmentDecision::decide(
2235 tier,
2236 declared,
2237 policy.workdir_exec_allowed,
2238 runtime_dir_placement.inside_workdir(),
2239 );
2240 scope_report_for_tier(
2241 policy,
2242 &decision,
2243 sealed_shortfall,
2244 userns_grant,
2245 landlock_kernel_abi,
2246 exports,
2247 state_stores,
2248 configured_artifacts,
2249 preopens,
2250 runtime_dir_placement,
2251 temp_mapping,
2252 io_max,
2253 )
2254}
2255
2256pub const HOST_PROCESS_MECHANISM: &str = "host-process";
2259
2260const UNCONFINED_ROUTE_NOTE: &str = "Runs as the operator, with their HOME, whole environment, \
2262 filesystem, network and sudo; nothing holds it.";
2263
2264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
2266#[serde(rename_all = "kebab-case")]
2267pub enum Route {
2268 Shell,
2270 PlanShellSteps,
2272 WasmTools,
2274 Drivers,
2276 Hooks,
2278 ProcessHarness,
2280 HarnessTools,
2282}
2283
2284impl Route {
2285 pub const ALL: [Self; 7] = [
2287 Self::Shell,
2288 Self::PlanShellSteps,
2289 Self::WasmTools,
2290 Self::Drivers,
2291 Self::Hooks,
2292 Self::ProcessHarness,
2293 Self::HarnessTools,
2294 ];
2295
2296 #[must_use]
2298 pub fn label(self) -> &'static str {
2299 match self {
2300 Self::Shell => "shell commands",
2301 Self::PlanShellSteps => "plan shell steps",
2302 Self::WasmTools => "WASM tools",
2303 Self::Drivers => "drivers",
2304 Self::Hooks => "hooks",
2305 Self::ProcessHarness => "the process harness",
2306 Self::HarnessTools => "harness tools",
2307 }
2308 }
2309
2310 #[must_use]
2315 pub fn under_class(self) -> bool {
2316 match self {
2317 Self::Shell | Self::PlanShellSteps => true,
2318 Self::WasmTools
2319 | Self::Drivers
2320 | Self::Hooks
2321 | Self::ProcessHarness
2322 | Self::HarnessTools => false,
2323 }
2324 }
2325
2326 #[must_use]
2329 pub fn runs_unconfined(self) -> bool {
2330 match self {
2331 Self::Shell | Self::PlanShellSteps | Self::ProcessHarness => true,
2332 Self::WasmTools | Self::Drivers | Self::Hooks | Self::HarnessTools => false,
2333 }
2334 }
2335
2336 #[must_use]
2339 pub fn note(self) -> Option<&'static str> {
2340 match self {
2341 Self::Shell | Self::PlanShellSteps => None,
2342 Self::WasmTools => Some(
2343 "Runs inside the mur process, held by its WASI preopens and the host-side network \
2344 allowlist only.",
2345 ),
2346 Self::Drivers => Some(
2347 "Runs inside the mur process, held by its WASI preopens and the host-side network \
2348 allowlist only.",
2349 ),
2350 Self::Hooks => Some(
2351 "Runs inside the mur process, held only by the preopens and network grants its \
2352 own capabilities block declares.",
2353 ),
2354 Self::ProcessHarness => Some(
2355 "A child of mur with a cleared environment and nothing else: no Landlock, \
2356 seccomp, network namespace or rlimits, in a cgroup with no limits.",
2357 ),
2358 Self::HarnessTools => Some(
2359 "Run by the harness with the user's authority, kept off only by the arguments \
2360 the process driver passes.",
2361 ),
2362 }
2363 }
2364}
2365
2366#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2368pub struct RouteReport {
2369 pub route: Route,
2371 pub present: bool,
2373 pub under_class: bool,
2375 pub mechanism: &'static str,
2379 pub note: Option<&'static str>,
2383 pub artifacts: Vec<String>,
2386}
2387
2388#[derive(Debug, Clone, Default, PartialEq, Eq)]
2390pub struct RouteInputs {
2391 pub shell_allow: bool,
2393 pub plan_submit: bool,
2395 pub install_tool: bool,
2397 pub tools: Vec<String>,
2399 pub drivers: Vec<String>,
2401 pub hooks: Vec<String>,
2403 pub process_transport: bool,
2405}
2406
2407impl RouteInputs {
2408 #[must_use]
2411 pub fn new(
2412 shell_allow: &[String],
2413 plan_submit: bool,
2414 install_tool: &[String],
2415 preopens: &[PreopenReport],
2416 transport: Option<&str>,
2417 ) -> Self {
2418 let named = |role: murmur_artifact::ArtifactRuntime| {
2419 preopens
2420 .iter()
2421 .filter(|preopen| preopen.role == role.as_str())
2422 .map(|preopen| preopen.artifact.clone())
2423 .collect::<Vec<_>>()
2424 };
2425 Self {
2426 shell_allow: !shell_allow.is_empty(),
2427 plan_submit,
2428 install_tool: !install_tool.is_empty(),
2429 tools: named(murmur_artifact::ArtifactRuntime::Tool),
2430 drivers: named(murmur_artifact::ArtifactRuntime::Driver),
2431 hooks: named(murmur_artifact::ArtifactRuntime::Hook),
2432 process_transport: transport == Some("process"),
2433 }
2434 }
2435}
2436
2437#[must_use]
2444pub fn route_table(inputs: &RouteInputs, enforcement_tier: &'static str) -> Vec<RouteReport> {
2445 let unconfined = enforcement_tier == enforcement_tier_name(EnforcementTier::Unconfined);
2446 Route::ALL
2447 .into_iter()
2448 .map(|route| {
2449 let (present, artifacts) = match route {
2450 Route::Shell => (inputs.shell_allow, Vec::new()),
2451 Route::PlanShellSteps => (inputs.plan_submit && inputs.shell_allow, Vec::new()),
2453 Route::WasmTools => (
2454 !inputs.tools.is_empty() || inputs.install_tool,
2455 inputs.tools.clone(),
2456 ),
2457 Route::Drivers => (!inputs.drivers.is_empty(), inputs.drivers.clone()),
2458 Route::Hooks => (!inputs.hooks.is_empty(), inputs.hooks.clone()),
2459 Route::ProcessHarness | Route::HarnessTools => {
2460 (inputs.process_transport, Vec::new())
2461 }
2462 };
2463 if unconfined && route.runs_unconfined() {
2464 return RouteReport {
2465 route,
2466 present,
2467 under_class: true,
2468 mechanism: enforcement_tier,
2469 note: Some(UNCONFINED_ROUTE_NOTE),
2470 artifacts,
2471 };
2472 }
2473 let under_class = route.under_class();
2474 RouteReport {
2475 route,
2476 present,
2477 under_class,
2478 mechanism: if under_class {
2479 enforcement_tier
2480 } else {
2481 HOST_PROCESS_MECHANISM
2482 },
2483 note: route.note(),
2484 artifacts,
2485 }
2486 })
2487 .collect()
2488}
2489
2490#[must_use]
2494pub fn render_routes(routes: &[RouteReport]) -> String {
2495 let mut out = String::from("Routes\n");
2496 for row in routes {
2497 let label = match row.route {
2498 Route::ProcessHarness => "process harness",
2499 route => route.label(),
2500 };
2501 let presence = if row.present { "present" } else { "absent" };
2502 let mechanism = if row.under_class {
2503 row.mechanism
2504 } else {
2505 "host process, not confined by the class"
2506 };
2507 let artifacts = if row.artifacts.is_empty() {
2508 String::new()
2509 } else {
2510 format!(" ({})", row.artifacts.join(", "))
2511 };
2512 out.push_str(&format!(
2513 " {label:<17} {presence:<8} {mechanism}{artifacts}\n"
2514 ));
2515 if let (true, Some(note)) = (row.present, row.note) {
2516 out.push_str(&format!(" {note}\n"));
2517 }
2518 }
2519 out
2520}
2521
2522#[must_use]
2527pub fn uncovered_route_warning(
2528 declared: ContainmentClass,
2529 routes: &[RouteReport],
2530) -> Option<String> {
2531 if declared < ContainmentClass::Scoped {
2532 return None;
2533 }
2534 let uncovered: Vec<String> = routes
2535 .iter()
2536 .filter(|row| row.present && !row.under_class)
2537 .map(|row| {
2538 if row.artifacts.is_empty() {
2539 row.route.label().to_string()
2540 } else {
2541 format!("{} ({})", row.route.label(), row.artifacts.join(", "))
2542 }
2543 })
2544 .collect();
2545 let named = match uncovered.as_slice() {
2546 [] => return None,
2547 [only] => only.clone(),
2548 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
2549 };
2550 Some(format!(
2551 "the declared {declared} floor does not confine every route this capsule runs code on: \
2552 {named} run outside the class, with none of its Landlock, seccomp or network namespace \
2553 around them (`mur run --explain-scope` lists what holds each under Routes). The class \
2554 confines shell commands and plan shell steps; nothing is refused"
2555 ))
2556}
2557
2558pub fn warn_on_uncovered_routes(
2562 declared: ContainmentClass,
2563 routes: &[RouteReport],
2564 bootstrap_log: Option<&crate::workdir_fs::WorkdirRoot>,
2565) {
2566 let Some(message) = uncovered_route_warning(declared, routes) else {
2567 return;
2568 };
2569 let code = murmur_artifact::W_SEC_034;
2570 let link = murmur_artifact::security_warning_link(code);
2571 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2572 if let Some(records) = bootstrap_log {
2573 crate::agent::append_bootstrap_log(
2574 records,
2575 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2576 );
2577 }
2578}
2579
2580pub fn applied_mechanism(probe: &HostProbe, declared: ContainmentClass) -> &'static str {
2585 ContainmentDecision::decide(probe.tier(), declared, false, false).enforcement_tier()
2587}
2588
2589fn enforcement_tier_name(tier: EnforcementTier) -> &'static str {
2592 match tier {
2593 EnforcementTier::KernelSealed => "mountns+pivot_root+landlock+seccomp",
2594 EnforcementTier::KernelFull => "landlock+seccomp",
2595 EnforcementTier::KernelSeccompOnly => "seccomp-only",
2596 EnforcementTier::EnvironmentOnly => "none",
2597 EnforcementTier::Unconfined => "unconfined",
2598 }
2599}
2600
2601#[cfg(test)]
2602#[allow(clippy::print_stdout, clippy::print_stderr)]
2603mod tests {
2604 use super::*;
2605 use crate::sealed::SealedBlocker;
2606
2607 fn blocked(blocker: SealedBlocker) -> SealedShortfall {
2609 SealedShortfall {
2610 blocker,
2611 failed_call: None,
2612 errno: None,
2613 userns_grant: UsernsGrant::Withheld,
2614 attachment: crate::sealed::ProfileAttachment::NotReported,
2615 }
2616 }
2617 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant, StagedRuntimeGrant};
2618
2619 const ALL_TIERS: &[EnforcementTier] = &[
2621 EnforcementTier::KernelSealed,
2622 EnforcementTier::KernelFull,
2623 EnforcementTier::KernelSeccompOnly,
2624 EnforcementTier::EnvironmentOnly,
2625 ];
2626
2627 #[test]
2628 fn kernel_full_achieves_scoped() {
2629 assert_eq!(
2630 achieved_class_for_tier(EnforcementTier::KernelFull),
2631 ContainmentClass::Scoped
2632 );
2633 }
2634
2635 #[test]
2637 fn only_the_sealed_tier_achieves_sealed() {
2638 assert_eq!(
2639 achieved_class_for_tier(EnforcementTier::KernelSealed),
2640 ContainmentClass::Sealed
2641 );
2642 for tier in ALL_TIERS
2643 .iter()
2644 .filter(|t| **t != EnforcementTier::KernelSealed)
2645 {
2646 assert_ne!(achieved_class_for_tier(*tier), ContainmentClass::Sealed);
2647 }
2648 }
2649
2650 #[test]
2651 fn tiers_without_landlock_achieve_only_advisory() {
2652 assert_eq!(
2653 achieved_class_for_tier(EnforcementTier::KernelSeccompOnly),
2654 ContainmentClass::Advisory
2655 );
2656 assert_eq!(
2657 achieved_class_for_tier(EnforcementTier::EnvironmentOnly),
2658 ContainmentClass::Advisory
2659 );
2660 }
2661
2662 #[test]
2668 fn workdir_exec_caps_the_achieved_class_at_advisory_on_every_tier() {
2669 assert_eq!(
2671 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
2672 ContainmentClass::Sealed
2673 );
2674 assert_eq!(
2675 achieved_containment_class(EnforcementTier::KernelFull, false, false),
2676 ContainmentClass::Scoped
2677 );
2678 assert_eq!(
2679 achieved_containment_class(EnforcementTier::KernelSeccompOnly, false, false),
2680 ContainmentClass::Advisory
2681 );
2682 assert_eq!(
2683 achieved_containment_class(EnforcementTier::EnvironmentOnly, false, false),
2684 ContainmentClass::Advisory
2685 );
2686
2687 for tier in ALL_TIERS {
2689 assert_eq!(
2690 achieved_containment_class(*tier, true, false),
2691 ContainmentClass::Advisory,
2692 "workdir_exec must cap tier {tier:?} at advisory"
2693 );
2694 }
2695 }
2696
2697 #[test]
2700 fn the_tier_only_mapping_is_unaffected_by_the_cap() {
2701 for tier in ALL_TIERS {
2702 assert_eq!(
2703 achieved_class_for_tier(*tier),
2704 achieved_containment_class(*tier, false, false),
2705 "the tier-only mapping and the uncapped wrapper must agree on tier {tier:?}"
2706 );
2707 }
2708 }
2709
2710 #[test]
2714 fn workdir_exec_refuses_a_scoped_floor_even_on_a_landlock_capable_host() {
2715 let achieved = achieved_containment_class(EnforcementTier::KernelFull, true, false);
2716 assert_eq!(achieved, ContainmentClass::Advisory);
2717
2718 let error = check_containment_floor(ContainmentClass::Scoped, achieved, None, true, None)
2719 .expect_err("workdir_exec + scoped must refuse");
2720 match error {
2721 RuntimeError::ContainmentFloorUnmet {
2722 declared,
2723 achieved,
2724 reason,
2725 ..
2726 } => {
2727 assert_eq!(declared, ContainmentClass::Scoped);
2728 assert_eq!(achieved, ContainmentClass::Advisory);
2729 assert!(
2730 reason.contains("workdir_exec"),
2731 "the reason must name the manifest key, got: {reason}"
2732 );
2733 assert!(
2734 !reason.contains("this host provides no kernel filesystem mediation"),
2735 "the reason must not blame the host for a manifest declaration: {reason}"
2736 );
2737 }
2738 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2739 }
2740 }
2741
2742 #[test]
2746 fn workdir_exec_outranks_a_probed_sealed_blocker_in_the_reason() {
2747 let reason = containment_shortfall_reason(
2748 ContainmentClass::Sealed,
2749 ContainmentClass::Advisory,
2750 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
2751 true,
2752 None,
2753 )
2754 .expect("refusal");
2755 assert!(reason.contains("workdir_exec"));
2756 assert!(!reason.contains("apparmor_parser"));
2757 }
2758
2759 #[test]
2762 fn workdir_exec_alone_refuses_nothing() {
2763 for tier in ALL_TIERS {
2764 let achieved = achieved_containment_class(*tier, true, false);
2765 assert!(
2766 check_containment_floor(ContainmentClass::Advisory, achieved, None, true, None)
2767 .is_ok(),
2768 "workdir_exec at an advisory floor must launch on tier {tier:?}"
2769 );
2770 }
2771 }
2772
2773 #[test]
2774 fn advisory_floor_is_met_on_every_tier() {
2775 for tier in ALL_TIERS {
2776 let achieved = achieved_class_for_tier(*tier);
2777 assert!(check_containment_floor(
2778 ContainmentClass::Advisory,
2779 achieved,
2780 None,
2781 false,
2782 None
2783 )
2784 .is_ok());
2785 }
2786 }
2787
2788 #[test]
2789 fn scoped_floor_is_met_on_every_landlock_capable_tier() {
2790 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
2791 let achieved = achieved_class_for_tier(tier);
2792 assert!(
2793 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
2794 .is_ok()
2795 );
2796 }
2797
2798 for tier in [
2799 EnforcementTier::KernelSeccompOnly,
2800 EnforcementTier::EnvironmentOnly,
2801 ] {
2802 let achieved = achieved_class_for_tier(tier);
2803 let error =
2804 check_containment_floor(ContainmentClass::Scoped, achieved, None, false, None)
2805 .expect_err("scoped must refuse without Landlock");
2806 match error {
2807 RuntimeError::ContainmentFloorUnmet {
2808 declared,
2809 achieved,
2810 reason,
2811 ..
2812 } => {
2813 assert_eq!(declared, ContainmentClass::Scoped);
2814 assert_eq!(achieved, ContainmentClass::Advisory);
2815 assert!(
2816 reason.contains("Landlock"),
2817 "reason must name the missing mechanism, got: {reason}"
2818 );
2819 }
2820 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
2821 }
2822 }
2823 }
2824
2825 #[test]
2829 fn a_weaker_declaration_is_satisfied_by_a_sealed_host_without_being_upgraded() {
2830 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
2831 let decision =
2832 ContainmentDecision::decide(EnforcementTier::KernelSealed, declared, false, false);
2833 assert_eq!(decision.applied_tier(), EnforcementTier::KernelFull);
2834 assert_eq!(decision.achieved(), ContainmentClass::Scoped);
2835 assert_eq!(decision.enforcement_tier(), "landlock+seccomp");
2836 assert_eq!(decision.host_containment(), ContainmentClass::Sealed);
2837 assert!(
2838 check_containment_floor(declared, decision.achieved(), None, false, None).is_ok()
2839 );
2840 assert_eq!(
2841 containment_shortfall_reason(declared, decision.achieved(), None, false, None),
2842 None
2843 );
2844 }
2845 }
2846
2847 const ALL_CLASSES: &[ContainmentClass] = &[
2848 ContainmentClass::Advisory,
2849 ContainmentClass::Scoped,
2850 ContainmentClass::Sealed,
2851 ];
2852
2853 #[test]
2857 fn the_decision_reads_the_class_off_the_tier_the_session_installs() {
2858 use ContainmentClass::{Advisory, Scoped, Sealed};
2859 use EnforcementTier::{EnvironmentOnly, KernelFull, KernelSealed, KernelSeccompOnly};
2860 let table = [
2862 (
2863 (KernelSealed, Advisory),
2864 (KernelFull, Scoped, "landlock+seccomp"),
2865 ),
2866 (
2867 (KernelSealed, Scoped),
2868 (KernelFull, Scoped, "landlock+seccomp"),
2869 ),
2870 (
2871 (KernelSealed, Sealed),
2872 (KernelSealed, Sealed, "mountns+pivot_root+landlock+seccomp"),
2873 ),
2874 (
2875 (KernelFull, Advisory),
2876 (KernelFull, Scoped, "landlock+seccomp"),
2877 ),
2878 (
2879 (KernelFull, Scoped),
2880 (KernelFull, Scoped, "landlock+seccomp"),
2881 ),
2882 (
2883 (KernelFull, Sealed),
2884 (KernelFull, Scoped, "landlock+seccomp"),
2885 ),
2886 (
2887 (KernelSeccompOnly, Advisory),
2888 (KernelSeccompOnly, Advisory, "seccomp-only"),
2889 ),
2890 (
2891 (KernelSeccompOnly, Scoped),
2892 (KernelSeccompOnly, Advisory, "seccomp-only"),
2893 ),
2894 (
2895 (KernelSeccompOnly, Sealed),
2896 (KernelSeccompOnly, Advisory, "seccomp-only"),
2897 ),
2898 (
2899 (EnvironmentOnly, Advisory),
2900 (EnvironmentOnly, Advisory, "none"),
2901 ),
2902 (
2903 (EnvironmentOnly, Scoped),
2904 (EnvironmentOnly, Advisory, "none"),
2905 ),
2906 (
2907 (EnvironmentOnly, Sealed),
2908 (EnvironmentOnly, Advisory, "none"),
2909 ),
2910 ];
2911 assert_eq!(table.len(), ALL_TIERS.len() * ALL_CLASSES.len());
2912 for ((host_tier, declared), (applied, class, wire)) in table {
2913 let host_class = match host_tier {
2914 KernelSealed => Sealed,
2915 KernelFull => Scoped,
2916 KernelSeccompOnly | EnvironmentOnly => Advisory,
2917 EnforcementTier::Unconfined => unreachable!("never a host tier"),
2918 };
2919 for workdir_exec in [false, true] {
2920 for inside in [false, true] {
2921 let decision =
2922 ContainmentDecision::decide(host_tier, declared, workdir_exec, inside);
2923 let what = format!("{host_tier:?} {declared} exec={workdir_exec} in={inside}");
2924 assert_eq!(decision.declared(), declared, "{what}");
2925 assert_eq!(decision.applied_tier(), applied, "{what}");
2926 assert_eq!(decision.enforcement_tier(), wire, "{what}");
2927 assert_eq!(decision.host_containment(), host_class, "{what}");
2928 let expected = if workdir_exec || inside {
2929 Advisory
2930 } else {
2931 class
2932 };
2933 assert_eq!(decision.achieved(), expected, "{what}");
2934 assert!(decision.achieved() <= decision.host_containment(), "{what}");
2937 assert!(
2938 decision.achieved() != Sealed || declared == Sealed,
2939 "{what}"
2940 );
2941 }
2942 }
2943 }
2944
2945 let scoped = ContainmentDecision::decide(KernelSealed, Scoped, false, false);
2947 assert_eq!(
2948 (
2949 scoped.achieved(),
2950 scoped.enforcement_tier(),
2951 scoped.host_containment()
2952 ),
2953 (Scoped, "landlock+seccomp", Sealed)
2954 );
2955 let sealed = ContainmentDecision::decide(KernelSealed, Sealed, false, false);
2956 assert_eq!(
2957 (sealed.achieved(), sealed.enforcement_tier()),
2958 (Sealed, "mountns+pivot_root+landlock+seccomp")
2959 );
2960 let advisory = ContainmentDecision::decide(KernelSealed, Advisory, false, false);
2961 assert_eq!(
2962 (advisory.achieved(), advisory.enforcement_tier()),
2963 (Scoped, "landlock+seccomp")
2964 );
2965 }
2966
2967 #[test]
2971 fn the_floor_verdict_and_wording_are_those_of_the_host_tier_class() {
2972 for &host_tier in ALL_TIERS {
2973 for &declared in ALL_CLASSES {
2974 for workdir_exec in [false, true] {
2975 for inside in [None, Some(inside_placement())] {
2976 let in_workdir = inside.is_some();
2977 let decision = ContainmentDecision::decide(
2978 host_tier,
2979 declared,
2980 workdir_exec,
2981 in_workdir,
2982 );
2983 let host_class =
2984 achieved_containment_class(host_tier, workdir_exec, in_workdir);
2985 let shortfall = blocked(SealedBlocker::NamespaceCreationDenied);
2986 assert_eq!(
2987 containment_shortfall_reason(
2988 declared,
2989 decision.achieved(),
2990 Some(&shortfall),
2991 workdir_exec,
2992 inside.as_ref(),
2993 ),
2994 containment_shortfall_reason(
2995 declared,
2996 host_class,
2997 Some(&shortfall),
2998 workdir_exec,
2999 inside.as_ref(),
3000 ),
3001 "{host_tier:?} {declared} exec={workdir_exec} in={in_workdir}"
3002 );
3003 }
3004 }
3005 }
3006 }
3007 }
3008
3009 #[test]
3010 fn sealed_floor_is_met_only_on_the_sealed_tier() {
3011 assert!(check_containment_floor(
3012 ContainmentClass::Sealed,
3013 achieved_class_for_tier(EnforcementTier::KernelSealed),
3014 None,
3015 false,
3016 None
3017 )
3018 .is_ok());
3019
3020 for tier in ALL_TIERS
3021 .iter()
3022 .filter(|t| **t != EnforcementTier::KernelSealed)
3023 {
3024 let achieved = achieved_class_for_tier(*tier);
3025 let error = check_containment_floor(
3026 ContainmentClass::Sealed,
3027 achieved,
3028 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3029 false,
3030 None,
3031 )
3032 .expect_err("sealed must refuse on a host that cannot back it");
3033 match error {
3034 RuntimeError::ContainmentFloorUnmet {
3035 declared, reason, ..
3036 } => {
3037 assert_eq!(declared, ContainmentClass::Sealed);
3038 assert!(
3039 reason.contains("seccomp profile") && !reason.contains("--cap-add"),
3040 "reason must name the blocking mechanism's remediation, got: {reason}"
3041 );
3042 }
3043 other => panic!("expected ContainmentFloorUnmet, got {other:?}"),
3044 }
3045 }
3046 }
3047
3048 #[test]
3051 fn the_sealed_refusal_names_the_specific_blocker() {
3052 let achieved = ContainmentClass::Scoped;
3053
3054 let apparmor = containment_shortfall_reason(
3055 ContainmentClass::Sealed,
3056 achieved,
3057 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3058 false,
3059 None,
3060 )
3061 .expect("refusal");
3062 assert!(apparmor.contains("mur-sealed"));
3063 assert!(apparmor.contains("apparmor_parser -r"));
3064 assert!(!apparmor.contains("--cap-add"));
3065
3066 let container = containment_shortfall_reason(
3067 ContainmentClass::Sealed,
3068 achieved,
3069 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3070 false,
3071 None,
3072 )
3073 .expect("refusal");
3074 assert!(!container.contains("--cap-add"), "{container}");
3075 assert!(container.contains("seccomp profile"), "{container}");
3076 assert!(container.contains("unshare(CLONE_NEWUSER)"), "{container}");
3077 assert!(container.contains("--security-opt seccomp="), "{container}");
3078 assert!(
3079 container.contains("seccompProfile: Localhost"),
3080 "{container}"
3081 );
3082 assert!(container.contains("outside the container"));
3083 assert!(!container.contains("apparmor_parser"));
3084
3085 let pivot = containment_shortfall_reason(
3088 ContainmentClass::Sealed,
3089 ContainmentClass::Advisory,
3090 Some(&blocked(SealedBlocker::PivotRootDenied)),
3091 false,
3092 None,
3093 )
3094 .expect("refusal");
3095 assert!(pivot.contains("pivot_root(2)"), "reason was: {pivot}");
3096 assert_ne!(
3097 pivot,
3098 containment_shortfall_reason(
3099 ContainmentClass::Sealed,
3100 ContainmentClass::Advisory,
3101 Some(&blocked(SealedBlocker::MountDenied)),
3102 false,
3103 None,
3104 )
3105 .expect("refusal")
3106 );
3107
3108 for blocker in [
3110 SealedBlocker::NotLinux,
3111 SealedBlocker::AppArmorProfileMissing,
3112 SealedBlocker::NamespaceCreationDenied,
3113 SealedBlocker::MountDenied,
3114 SealedBlocker::PivotRootDenied,
3115 SealedBlocker::KernelUnsupported,
3116 SealedBlocker::LandlockUnavailable,
3117 ] {
3118 let reason = containment_shortfall_reason(
3119 ContainmentClass::Sealed,
3120 achieved,
3121 Some(&blocked(blocker)),
3122 false,
3123 None,
3124 )
3125 .expect("refusal");
3126 assert!(
3127 !reason.contains("no host can provide it today"),
3128 "got: {reason}"
3129 );
3130 }
3131 }
3132
3133 #[test]
3136 fn the_sealed_refusal_falls_back_to_naming_the_mechanism_without_a_probe() {
3137 let reason = containment_shortfall_reason(
3138 ContainmentClass::Sealed,
3139 ContainmentClass::Advisory,
3140 None,
3141 false,
3142 None,
3143 )
3144 .expect("refusal");
3145 assert!(reason.contains("pivot_root"));
3146 assert!(reason.contains("CLONE_NEWNS"));
3147 }
3148
3149 #[test]
3150 fn shortfall_reason_is_absent_when_the_floor_is_met() {
3151 assert_eq!(
3152 containment_shortfall_reason(
3153 ContainmentClass::Advisory,
3154 ContainmentClass::Scoped,
3155 None,
3156 false,
3157 None
3158 ),
3159 None
3160 );
3161 assert_eq!(
3162 containment_shortfall_reason(
3163 ContainmentClass::Scoped,
3164 ContainmentClass::Scoped,
3165 None,
3166 false,
3167 None
3168 ),
3169 None
3170 );
3171 assert_eq!(
3172 containment_shortfall_reason(
3173 ContainmentClass::Sealed,
3174 ContainmentClass::Sealed,
3175 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3176 false,
3177 None
3178 ),
3179 None,
3180 "a met floor must ignore a stale blocker rather than manufacture a refusal"
3181 );
3182 }
3183
3184 fn sample_policy() -> CapabilityPolicy {
3185 CapabilityPolicy {
3186 network_allow: vec!["https://api.example.com".to_string()],
3187 unix_sockets_allowed: false,
3188 filesystem_scope: Some("workdir".to_string()),
3189 shell_allow: vec!["python3".to_string()],
3190 spawn_allow: vec!["helper".to_string()],
3191 env_allow: vec!["TZ".to_string()],
3192 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
3193 binary: "python3".to_string(),
3194 dirs: vec![InterpreterRuntimeDir {
3195 path: "/usr/lib/python3.11".to_string(),
3196 list_dir: true,
3197 }],
3198 }],
3199 ..CapabilityPolicy::default()
3200 }
3201 }
3202
3203 #[test]
3207 fn an_unconfined_report_names_no_protection_it_does_not_install() {
3208 for host_tier in [
3209 EnforcementTier::KernelSealed,
3210 EnforcementTier::KernelFull,
3211 EnforcementTier::KernelSeccompOnly,
3212 EnforcementTier::EnvironmentOnly,
3213 ] {
3214 let decision =
3215 ContainmentDecision::decide(host_tier, ContainmentClass::Unconfined, true, true);
3216 assert_eq!(decision.applied_tier(), EnforcementTier::Unconfined);
3217 assert_eq!(decision.achieved(), ContainmentClass::Unconfined);
3218 assert_eq!(decision.enforcement_tier(), "unconfined");
3219 assert_eq!(
3220 decision.host_containment(),
3221 achieved_class_for_tier(host_tier)
3222 );
3223 }
3224 let policy = CapabilityPolicy {
3225 containment_floor: ContainmentClass::Unconfined,
3226 declared_resources: murmur_artifact::ResourceCapabilities {
3227 max_open_files: Some(256),
3228 ..Default::default()
3229 },
3230 ..sample_policy()
3231 };
3232 let report = report_for(
3233 &policy,
3234 ContainmentClass::Unconfined,
3235 EnforcementTier::KernelFull,
3236 None,
3237 None,
3238 Some(4),
3239 None,
3240 Vec::new(),
3241 Vec::new(),
3242 Vec::new(),
3243 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3244 crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
3245 IoMaxReport::default(),
3246 );
3247 assert!(report.floor_met);
3248 assert_eq!(report.achieved_containment, ContainmentClass::Unconfined);
3249 assert_eq!(report.enforcement_tier, "unconfined");
3250 for row in &report.routes {
3252 if row.route.runs_unconfined() {
3253 assert!(row.under_class, "{row:?}");
3254 assert_eq!(row.mechanism, "unconfined", "{row:?}");
3255 assert_eq!(row.note, Some(UNCONFINED_ROUTE_NOTE), "{row:?}");
3256 } else {
3257 assert_eq!(row.note, row.route.note(), "{row:?}");
3258 }
3259 }
3260 assert_eq!(
3261 uncovered_route_warning(report.declared_containment, &report.routes),
3262 None,
3263 "W-SEC-034 keys on a scoped or sealed floor"
3264 );
3265 assert_eq!(report.landlock_abi, None);
3266 assert_eq!(
3267 report.filesystem_boundary.restriction,
3268 FilesystemRestriction::None
3269 );
3270 assert_eq!(
3271 report.filesystem_boundary.not_protected,
3272 vec![UNCONFINED_NOT_PROTECTED.to_string()]
3273 );
3274 for dir in [&report.runtime_dir, &report.records_dir] {
3275 assert!(!dir.read_only_to_commands);
3276 assert_eq!(dir.enforced_by, None);
3277 assert!(dir
3278 .not_protected_because
3279 .as_deref()
3280 .unwrap()
3281 .contains("unconfined"));
3282 }
3283 assert!(!report.temp_dir.tmp_mapped);
3284 assert_eq!(
3285 report.temp_dir.not_mapped_because.as_deref(),
3286 Some(crate::temp_dir::NotMappedReason::Unconfined.describe())
3287 );
3288 let unconfined = report.unconfined.as_ref().expect("an unconfined report");
3289 assert_eq!(unconfined.uid, current_uid());
3290 assert_eq!(unconfined.applies_to, UNCONFINED_APPLIES_TO.to_vec());
3291 assert_eq!(unconfined.resource_limits, vec!["max_open_files"]);
3292 let json = serde_json::to_value(&report).unwrap();
3293 assert_eq!(json["enforcement_tier"], "unconfined");
3294 assert_eq!(json["achieved_containment"], "unconfined");
3295 assert_eq!(json["filesystem_boundary"]["restriction"], "none");
3296 assert!(json["unconfined"].is_object(), "{json}");
3297 let rendered = report.render();
3298 assert!(rendered.contains(" achieved: unconfined\n"), "{rendered}");
3299 assert!(rendered.contains("\nUnconfined\n user:"), "{rendered}");
3300 assert!(rendered.contains(" uid: "), "{rendered}");
3301 assert!(rendered.contains(" HOME: "), "{rendered}");
3302 assert!(
3303 rendered.contains("applies to: shell commands, plan shell steps, process harness"),
3304 "{rendered}"
3305 );
3306 assert!(unconfined_warning(unconfined).contains("sudo"));
3307
3308 let contained = report_for(
3310 &sample_policy(),
3311 ContainmentClass::Scoped,
3312 EnforcementTier::KernelFull,
3313 None,
3314 None,
3315 None,
3316 None,
3317 Vec::new(),
3318 Vec::new(),
3319 Vec::new(),
3320 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3321 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3322 IoMaxReport::default(),
3323 );
3324 let json = serde_json::to_value(&contained).unwrap();
3325 assert!(json["unconfined"].is_null(), "{json}");
3326 assert!(json.as_object().unwrap().contains_key("unconfined"));
3327 }
3328
3329 #[test]
3330 fn scope_report_mirrors_the_policy_and_the_probed_tier() {
3331 let report = report_for(
3332 &sample_policy(),
3333 ContainmentClass::Scoped,
3334 EnforcementTier::KernelFull,
3335 None,
3336 None,
3337 None,
3338 None,
3339 Vec::new(),
3340 Vec::new(),
3341 Vec::new(),
3342 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3343 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3344 EnforcementTier::KernelFull,
3345 ContainmentClass::Scoped,
3346 )),
3347 IoMaxReport::default(),
3348 );
3349
3350 assert_eq!(report.declared_containment, ContainmentClass::Scoped);
3351 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3352 assert!(report.floor_met);
3353 assert_eq!(report.shortfall_reason, None);
3354 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3355 assert_eq!(report.filesystem_scope.as_deref(), Some("workdir"));
3356 assert_eq!(report.network_allow, vec!["https://api.example.com"]);
3357 assert!(!report.unix_sockets);
3358 assert_eq!(report.shell_allow, vec!["python3"]);
3359 assert_eq!(report.spawn_allow, vec!["helper"]);
3360 assert_eq!(report.env_allow, vec!["TZ"]);
3361 assert!(!report.workdir_exec);
3362 assert_eq!(
3363 report.interpreter_runtime_grants,
3364 vec!["python3: /usr/lib/python3.11 (list_dir)"]
3365 );
3366 }
3367
3368 #[test]
3372 fn scope_report_surfaces_workdir_exec_and_the_advisory_it_forces() {
3373 let policy = CapabilityPolicy {
3374 shell_allow: vec!["gcc".to_string()],
3375 workdir_exec_allowed: true,
3376 ..CapabilityPolicy::default()
3377 };
3378
3379 let report = report_for(
3380 &policy,
3381 ContainmentClass::Scoped,
3382 EnforcementTier::KernelFull,
3383 None,
3384 None,
3385 None,
3386 None,
3387 Vec::new(),
3388 Vec::new(),
3389 Vec::new(),
3390 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3391 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3392 EnforcementTier::KernelFull,
3393 ContainmentClass::Scoped,
3394 )),
3395 IoMaxReport::default(),
3396 );
3397
3398 assert!(report.workdir_exec);
3399 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3400 assert!(!report.floor_met);
3401 assert_eq!(report.enforcement_tier, "landlock+seccomp");
3404 assert!(report
3405 .shortfall_reason
3406 .as_deref()
3407 .expect("refusal")
3408 .contains("workdir_exec"));
3409
3410 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3411 assert_eq!(value["workdir_exec"], true);
3412
3413 let rendered = report.render();
3414 assert!(rendered.contains("workdir exec: true"));
3415 assert!(rendered.contains("achieved: advisory"));
3416 }
3417
3418 #[test]
3422 fn scope_report_json_always_carries_io_max() {
3423 let unprobed: serde_json::Value = serde_json::to_value(report_for(
3424 &sample_policy(),
3425 ContainmentClass::Scoped,
3426 EnforcementTier::KernelFull,
3427 None,
3428 None,
3429 None,
3430 None,
3431 Vec::new(),
3432 Vec::new(),
3433 Vec::new(),
3434 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3435 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3436 EnforcementTier::KernelFull,
3437 ContainmentClass::Scoped,
3438 )),
3439 IoMaxReport::default(),
3440 ))
3441 .unwrap();
3442 assert_eq!(unprobed["io_max"]["status"], "not-probed");
3443 assert_eq!(unprobed["io_max"]["declared_bytes_per_sec"], 0);
3444 assert!(unprobed["io_max"].get("reason").is_none());
3445
3446 let enforced: serde_json::Value = serde_json::to_value(report_for(
3447 &sample_policy(),
3448 ContainmentClass::Scoped,
3449 EnforcementTier::KernelFull,
3450 None,
3451 None,
3452 None,
3453 None,
3454 Vec::new(),
3455 Vec::new(),
3456 Vec::new(),
3457 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3458 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3459 EnforcementTier::KernelFull,
3460 ContainmentClass::Scoped,
3461 )),
3462 IoMaxReport {
3463 declared_bytes_per_sec: 52_428_800,
3464 status: IoMaxStatus::Enforced,
3465 reason: None,
3466 },
3467 ))
3468 .unwrap();
3469 assert_eq!(enforced["io_max"]["status"], "enforced");
3470 assert_eq!(enforced["io_max"]["declared_bytes_per_sec"], 52_428_800);
3471 }
3472
3473 #[test]
3477 fn the_rendered_resource_plane_states_what_became_of_the_io_ceiling() {
3478 let rendered = report_for(
3479 &sample_policy(),
3480 ContainmentClass::Scoped,
3481 EnforcementTier::KernelFull,
3482 None,
3483 None,
3484 None,
3485 None,
3486 Vec::new(),
3487 Vec::new(),
3488 Vec::new(),
3489 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3490 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3491 EnforcementTier::KernelFull,
3492 ContainmentClass::Scoped,
3493 )),
3494 IoMaxReport {
3495 declared_bytes_per_sec: 52_428_800,
3496 status: IoMaxStatus::Unavailable,
3497 reason: Some("the io controller is not delegated".to_string()),
3498 },
3499 )
3500 .render();
3501
3502 assert!(rendered.contains("Resource plane"), "{rendered}");
3503 assert!(rendered.contains("io.max:"), "{rendered}");
3504 assert!(rendered.contains("unavailable"), "{rendered}");
3505 assert!(rendered.contains("52428800 bytes/s declared"), "{rendered}");
3506 assert!(
3507 rendered.contains("the io controller is not delegated"),
3508 "{rendered}"
3509 );
3510 }
3511
3512 #[test]
3515 fn scope_report_json_always_carries_workdir_exec() {
3516 let value: serde_json::Value = serde_json::to_value(report_for(
3517 &sample_policy(),
3518 ContainmentClass::Scoped,
3519 EnforcementTier::KernelFull,
3520 None,
3521 None,
3522 None,
3523 None,
3524 Vec::new(),
3525 Vec::new(),
3526 Vec::new(),
3527 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3528 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3529 EnforcementTier::KernelFull,
3530 ContainmentClass::Scoped,
3531 )),
3532 IoMaxReport::default(),
3533 ))
3534 .unwrap();
3535 assert_eq!(value["workdir_exec"], false);
3536 }
3537
3538 #[test]
3542 fn staged_runtime_grants_are_reported_on_every_tier() {
3543 let policy = CapabilityPolicy {
3544 shell_allow: vec!["python3".to_string()],
3545 shell_staged_runtime: vec![StagedRuntimeGrant {
3546 binary: "python3".to_string(),
3547 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3548 pin: "conda-4.10.3/python-3.9.19".to_string(),
3549 }],
3550 ..CapabilityPolicy::default()
3551 };
3552
3553 for tier in ALL_TIERS {
3554 let report = report_for(
3555 &policy,
3556 ContainmentClass::Sealed,
3557 *tier,
3558 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3559 None,
3560 None,
3561 None,
3562 Vec::new(),
3563 Vec::new(),
3564 Vec::new(),
3565 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3566 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3567 *tier,
3568 ContainmentClass::Sealed,
3569 )),
3570 IoMaxReport::default(),
3571 );
3572 assert_eq!(
3573 report.staged_runtime_grants,
3574 vec![
3575 "python3: /opt/testbed/conda/envs/django__django \
3576 (pin: conda-4.10.3/python-3.9.19)"
3577 ],
3578 "tier {tier:?} must still report the declared staged runtime"
3579 );
3580 assert!(
3581 report.render().contains("staged runtime"),
3582 "the rendered report must carry a staged runtime section on tier {tier:?}"
3583 );
3584 }
3585 }
3586
3587 #[test]
3590 fn absent_staged_runtime_reports_nothing() {
3591 let report = report_for(
3592 &sample_policy(),
3593 ContainmentClass::Scoped,
3594 EnforcementTier::KernelFull,
3595 None,
3596 None,
3597 None,
3598 None,
3599 Vec::new(),
3600 Vec::new(),
3601 Vec::new(),
3602 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3603 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3604 EnforcementTier::KernelFull,
3605 ContainmentClass::Scoped,
3606 )),
3607 IoMaxReport::default(),
3608 );
3609 assert!(report.staged_runtime_grants.is_empty());
3610 assert!(report.render().contains("staged runtime: <none>"));
3611 }
3612
3613 #[test]
3617 fn grant_set_never_raises_the_achieved_class() {
3618 let report = report_for(
3619 &sample_policy(),
3620 ContainmentClass::Sealed,
3621 EnforcementTier::KernelFull,
3622 Some(&blocked(SealedBlocker::NamespaceCreationDenied)),
3623 None,
3624 None,
3625 None,
3626 Vec::new(),
3627 Vec::new(),
3628 Vec::new(),
3629 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3630 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3631 EnforcementTier::KernelFull,
3632 ContainmentClass::Sealed,
3633 )),
3634 IoMaxReport::default(),
3635 );
3636 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
3637 assert!(!report.floor_met);
3638 }
3639
3640 #[test]
3641 fn scope_report_still_reports_when_the_floor_is_unmet() {
3642 let report = report_for(
3643 &sample_policy(),
3644 ContainmentClass::Sealed,
3645 EnforcementTier::EnvironmentOnly,
3646 Some(&blocked(SealedBlocker::NotLinux)),
3647 None,
3648 None,
3649 None,
3650 Vec::new(),
3651 Vec::new(),
3652 Vec::new(),
3653 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3654 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3655 EnforcementTier::EnvironmentOnly,
3656 ContainmentClass::Sealed,
3657 )),
3658 IoMaxReport::default(),
3659 );
3660
3661 assert_eq!(report.declared_containment, ContainmentClass::Sealed);
3662 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
3663 assert!(!report.floor_met);
3664 assert!(report.shortfall_reason.unwrap().contains("non-Linux"));
3665 assert_eq!(report.enforcement_tier, "none");
3666 }
3667
3668 #[test]
3669 fn scope_report_names_the_sealed_mechanism_on_a_sealed_host() {
3670 let report = report_for(
3671 &sample_policy(),
3672 ContainmentClass::Sealed,
3673 EnforcementTier::KernelSealed,
3674 None,
3675 None,
3676 None,
3677 None,
3678 Vec::new(),
3679 Vec::new(),
3680 Vec::new(),
3681 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3682 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3683 EnforcementTier::KernelSealed,
3684 ContainmentClass::Sealed,
3685 )),
3686 IoMaxReport::default(),
3687 );
3688 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
3689 assert_eq!(report.host_containment, ContainmentClass::Sealed);
3690 assert!(report.floor_met);
3691 assert_eq!(
3692 report.enforcement_tier,
3693 "mountns+pivot_root+landlock+seccomp"
3694 );
3695 assert_eq!(
3696 report.filesystem_boundary.restriction,
3697 FilesystemRestriction::Absent
3698 );
3699 }
3700
3701 #[test]
3705 fn every_report_field_follows_the_decision() {
3706 for &host_tier in ALL_TIERS {
3707 for &declared in ALL_CLASSES {
3708 let decision = ContainmentDecision::decide(host_tier, declared, false, false);
3709 let report = scope_report_for_tier(
3710 &sample_policy(),
3711 &decision,
3712 None,
3713 None,
3714 Some(6),
3715 None,
3716 Vec::new(),
3717 Vec::new(),
3718 Vec::new(),
3719 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3720 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3721 IoMaxReport::default(),
3722 );
3723 let what = format!("{host_tier:?} {declared}");
3724 assert_eq!(report.declared_containment, declared, "{what}");
3725 assert_eq!(report.achieved_containment, decision.achieved(), "{what}");
3726 assert_eq!(
3727 report.enforcement_tier,
3728 decision.enforcement_tier(),
3729 "{what}"
3730 );
3731 assert_eq!(
3732 report.host_containment,
3733 decision.host_containment(),
3734 "{what}"
3735 );
3736 assert_eq!(
3737 report.landlock_abi,
3738 crate::sandbox::ruleset_landlock_abi(Some(6), decision.applied_tier()),
3739 "{what}"
3740 );
3741 let restriction = filesystem_boundary(decision.applied_tier()).restriction;
3742 assert_eq!(
3743 report.filesystem_boundary.restriction, restriction,
3744 "{what}"
3745 );
3746 assert_eq!(
3747 restriction,
3748 match report.enforcement_tier {
3749 "mountns+pivot_root+landlock+seccomp" => FilesystemRestriction::Absent,
3750 "landlock+seccomp" => FilesystemRestriction::Enforced,
3751 _ => FilesystemRestriction::Advisory,
3752 },
3753 "{what}"
3754 );
3755 assert_eq!(
3756 report.temp_dir,
3757 temp_dir_report(
3758 crate::temp_dir::tier_temp_mapping(decision.applied_tier()),
3759 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3760 ),
3761 "{what}"
3762 );
3763 }
3764 }
3765 }
3766
3767 #[test]
3770 fn host_containment_is_reported_beside_the_achieved_class() {
3771 let report = report_for(
3772 &sample_policy(),
3773 ContainmentClass::Scoped,
3774 EnforcementTier::KernelSealed,
3775 None,
3776 None,
3777 None,
3778 None,
3779 Vec::new(),
3780 Vec::new(),
3781 Vec::new(),
3782 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3783 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
3784 IoMaxReport::default(),
3785 );
3786 let value = serde_json::to_value(&report).unwrap();
3787 assert_eq!(value["achieved_containment"], "scoped");
3788 assert_eq!(value["host_containment"], "sealed");
3789 assert_eq!(value["enforcement_tier"], "landlock+seccomp");
3790 assert_eq!(value["filesystem_boundary"]["restriction"], "enforced");
3791
3792 let rendered = report.render();
3793 assert!(
3794 rendered.contains(" achieved: scoped\n host can back: sealed\n floor met: yes\n"),
3795 "{rendered}"
3796 );
3797 assert!(
3798 rendered.contains(" mechanism: landlock+seccomp\n"),
3799 "{rendered}"
3800 );
3801 }
3802
3803 #[test]
3804 fn scope_report_json_uses_wire_names() {
3805 let report = report_for(
3806 &CapabilityPolicy::default(),
3807 ContainmentClass::Advisory,
3808 EnforcementTier::KernelSeccompOnly,
3809 None,
3810 None,
3811 None,
3812 None,
3813 Vec::new(),
3814 Vec::new(),
3815 Vec::new(),
3816 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3817 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3818 EnforcementTier::KernelSeccompOnly,
3819 ContainmentClass::Advisory,
3820 )),
3821 IoMaxReport::default(),
3822 );
3823 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3824
3825 assert_eq!(value["declared_containment"], "advisory");
3826 assert_eq!(value["achieved_containment"], "advisory");
3827 assert_eq!(value["host_containment"], "advisory");
3828 assert_eq!(value["floor_met"], true);
3829 assert_eq!(value["enforcement_tier"], "seccomp-only");
3830 assert!(value.get("shortfall_reason").is_none());
3832 }
3833
3834 #[test]
3837 fn scope_report_names_the_landlock_abi_the_ruleset_is_built_at() {
3838 let report = |tier, kernel| {
3839 report_for(
3840 &sample_policy(),
3841 ContainmentClass::Scoped,
3842 tier,
3843 None,
3844 None,
3845 kernel,
3846 None,
3847 Vec::new(),
3848 Vec::new(),
3849 Vec::new(),
3850 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3851 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3852 tier,
3853 ContainmentClass::Scoped,
3854 )),
3855 IoMaxReport::default(),
3856 )
3857 };
3858
3859 for (tier, kernel, expected) in [
3860 (EnforcementTier::KernelFull, Some(8), Some(3)),
3861 (EnforcementTier::KernelSealed, Some(8), Some(3)),
3862 (EnforcementTier::KernelFull, Some(2), Some(2)),
3863 (EnforcementTier::KernelFull, Some(1), Some(1)),
3864 (EnforcementTier::KernelFull, None, None),
3865 (EnforcementTier::KernelSeccompOnly, Some(8), None),
3866 (EnforcementTier::EnvironmentOnly, None, None),
3867 ] {
3868 let scope = report(tier, kernel);
3869 assert_eq!(
3870 scope.landlock_abi, expected,
3871 "{tier:?} with kernel {kernel:?}"
3872 );
3873
3874 let value: serde_json::Value = serde_json::to_value(&scope).unwrap();
3875 assert!(value.as_object().unwrap().contains_key("landlock_abi"));
3876 match expected {
3877 Some(abi) => assert_eq!(value["landlock_abi"], abi),
3878 None => assert!(value["landlock_abi"].is_null()),
3879 }
3880
3881 let rendered = scope.render();
3882 let line = match expected {
3883 Some(abi) => format!(" landlock ABI: {abi}\n"),
3884 None => " landlock ABI: n/a\n".to_string(),
3885 };
3886 assert!(rendered.contains(&line), "{rendered}");
3887 assert!(
3888 rendered.find("userns grant:").unwrap() < rendered.find("landlock ABI:").unwrap()
3889 );
3890 }
3891 }
3892
3893 #[test]
3896 fn the_userns_grant_is_reported_without_changing_any_verdict() {
3897 let baseline = report_for(
3898 &sample_policy(),
3899 ContainmentClass::Advisory,
3900 EnforcementTier::KernelSealed,
3901 None,
3902 None,
3903 None,
3904 None,
3905 Vec::new(),
3906 Vec::new(),
3907 Vec::new(),
3908 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3909 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3910 EnforcementTier::KernelSealed,
3911 ContainmentClass::Advisory,
3912 )),
3913 IoMaxReport::default(),
3914 );
3915
3916 for grant in UsernsGrant::ALL {
3917 let report = report_for(
3918 &sample_policy(),
3919 ContainmentClass::Advisory,
3920 EnforcementTier::KernelSealed,
3921 None,
3922 Some(*grant),
3923 None,
3924 None,
3925 Vec::new(),
3926 Vec::new(),
3927 Vec::new(),
3928 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3929 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3930 EnforcementTier::KernelSealed,
3931 ContainmentClass::Advisory,
3932 )),
3933 IoMaxReport::default(),
3934 );
3935 assert_eq!(report.achieved_containment, baseline.achieved_containment);
3936 assert_eq!(report.floor_met, baseline.floor_met);
3937 assert_eq!(report.enforcement_tier, baseline.enforcement_tier);
3938 assert_eq!(report.userns_grant, Some(*grant));
3939
3940 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
3941 assert_eq!(value["userns_grant"], grant.wire_name());
3942 assert!(report
3943 .render()
3944 .contains(&format!("userns grant: {}", grant.wire_name())));
3945 }
3946
3947 let value: serde_json::Value = serde_json::to_value(&baseline).unwrap();
3950 assert!(value["userns_grant"].is_null());
3951 assert!(baseline.render().contains("userns grant: n/a"));
3952 }
3953
3954 #[test]
3955 fn rendered_report_names_both_classes_and_the_refusal() {
3956 let rendered = report_for(
3957 &sample_policy(),
3958 ContainmentClass::Sealed,
3959 EnforcementTier::KernelFull,
3960 Some(&blocked(SealedBlocker::AppArmorProfileMissing)),
3961 None,
3962 None,
3963 None,
3964 Vec::new(),
3965 Vec::new(),
3966 Vec::new(),
3967 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
3968 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
3969 EnforcementTier::KernelFull,
3970 ContainmentClass::Sealed,
3971 )),
3972 IoMaxReport::default(),
3973 )
3974 .render();
3975
3976 assert!(rendered.contains("declared: sealed"));
3977 assert!(rendered.contains("achieved: scoped"));
3978 assert!(rendered.contains("floor met: no"));
3979 assert!(rendered.contains("mur-sealed"));
3980 assert!(rendered.contains("would refuse to launch"));
3981 }
3982
3983 #[test]
3987 fn an_export_never_changes_what_the_report_says_about_containment() {
3988 let export = FileExport {
3989 root: "out/".to_string(),
3990 mode: ExportMode::ReadOnly,
3991 max_bytes: 10 * 1024 * 1024,
3992 };
3993 for tier in ALL_TIERS {
3994 for workdir_exec in [false, true] {
3995 let policy = CapabilityPolicy {
3996 workdir_exec_allowed: workdir_exec,
3997 ..sample_policy()
3998 };
3999 let without = report_for(
4000 &policy,
4001 ContainmentClass::Scoped,
4002 *tier,
4003 None,
4004 None,
4005 None,
4006 None,
4007 Vec::new(),
4008 Vec::new(),
4009 Vec::new(),
4010 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4011 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4012 *tier,
4013 ContainmentClass::Scoped,
4014 )),
4015 IoMaxReport::default(),
4016 );
4017 let with = report_for(
4018 &policy,
4019 ContainmentClass::Scoped,
4020 *tier,
4021 None,
4022 None,
4023 None,
4024 Some(&Exports {
4025 files: Some(export.clone()),
4026 peer_files: None,
4027 peer_tasks: None,
4028 }),
4029 Vec::new(),
4030 Vec::new(),
4031 Vec::new(),
4032 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4033 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4034 *tier,
4035 ContainmentClass::Scoped,
4036 )),
4037 IoMaxReport::default(),
4038 );
4039 assert_eq!(
4040 with.achieved_containment, without.achieved_containment,
4041 "tier {tier:?}, workdir_exec {workdir_exec}"
4042 );
4043 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4044 assert_eq!(with.floor_met, without.floor_met);
4045 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4046 assert_eq!(
4049 ScopeReport {
4050 exports_files: None,
4051 ..with.clone()
4052 },
4053 without
4054 );
4055 assert_eq!(
4056 with.exports_files,
4057 Some(ExportsFilesReport {
4058 root: "out/".to_string(),
4059 mode: ExportMode::ReadOnly,
4060 max_bytes: 10 * 1024 * 1024,
4061 })
4062 );
4063 }
4064 }
4065 }
4066
4067 #[test]
4071 fn peer_handoff_never_changes_what_the_report_says_about_containment() {
4072 let peer_files = PeerFilesExport {
4073 root: "out/".to_string(),
4074 max_ttl_secs: Some(900),
4075 max_bytes: 10 * 1024 * 1024,
4076 };
4077 for tier in ALL_TIERS {
4078 for workdir_exec in [false, true] {
4079 let without_policy = CapabilityPolicy {
4080 workdir_exec_allowed: workdir_exec,
4081 ..sample_policy()
4082 };
4083 let with_policy = CapabilityPolicy {
4084 peer_fetch_allow: vec!["localhost:41234".to_string()],
4085 ..without_policy.clone()
4086 };
4087 let without = report_for(
4088 &without_policy,
4089 ContainmentClass::Scoped,
4090 *tier,
4091 None,
4092 None,
4093 None,
4094 None,
4095 Vec::new(),
4096 Vec::new(),
4097 Vec::new(),
4098 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4099 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4100 *tier,
4101 ContainmentClass::Scoped,
4102 )),
4103 IoMaxReport::default(),
4104 );
4105 let with = report_for(
4106 &with_policy,
4107 ContainmentClass::Scoped,
4108 *tier,
4109 None,
4110 None,
4111 None,
4112 Some(&Exports {
4113 files: None,
4114 peer_files: Some(peer_files.clone()),
4115 peer_tasks: None,
4116 }),
4117 Vec::new(),
4118 Vec::new(),
4119 Vec::new(),
4120 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4121 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4122 *tier,
4123 ContainmentClass::Scoped,
4124 )),
4125 IoMaxReport::default(),
4126 );
4127 assert_eq!(
4128 with.achieved_containment, without.achieved_containment,
4129 "tier {tier:?}, workdir_exec {workdir_exec}"
4130 );
4131 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4132 assert_eq!(with.floor_met, without.floor_met);
4133 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4134 assert_eq!(
4135 ScopeReport {
4136 peer_files: None,
4137 peer_fetch_allow: Vec::new(),
4138 ..with.clone()
4139 },
4140 without
4141 );
4142 assert_eq!(
4143 with.peer_files,
4144 Some(PeerFilesReport {
4145 root: "out/".to_string(),
4146 max_ttl_secs: 900,
4147 max_bytes: 10 * 1024 * 1024,
4148 })
4149 );
4150 assert_eq!(with.peer_fetch_allow, vec!["localhost:41234".to_string()]);
4151 assert_eq!(with.network_allow, without.network_allow);
4155 }
4156 }
4157 }
4158
4159 #[test]
4163 fn the_peer_fields_are_always_serialized() {
4164 let undeclared: serde_json::Value = serde_json::to_value(report_for(
4165 &sample_policy(),
4166 ContainmentClass::Advisory,
4167 EnforcementTier::KernelSealed,
4168 None,
4169 None,
4170 None,
4171 None,
4172 Vec::new(),
4173 Vec::new(),
4174 Vec::new(),
4175 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4176 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4177 EnforcementTier::KernelSealed,
4178 ContainmentClass::Advisory,
4179 )),
4180 IoMaxReport::default(),
4181 ))
4182 .unwrap();
4183 assert_eq!(undeclared["peer_files"], serde_json::Value::Null);
4184 assert_eq!(undeclared["peer_fetch_allow"], serde_json::json!([]));
4185
4186 let declared: serde_json::Value = serde_json::to_value(report_for(
4187 &CapabilityPolicy {
4188 peer_fetch_allow: vec!["localhost:41234".to_string()],
4189 ..sample_policy()
4190 },
4191 ContainmentClass::Advisory,
4192 EnforcementTier::KernelSealed,
4193 None,
4194 None,
4195 None,
4196 Some(&Exports {
4197 files: None,
4198 peer_files: Some(PeerFilesExport {
4199 root: "out/".to_string(),
4200 max_ttl_secs: None,
4201 max_bytes: 10 * 1024 * 1024,
4202 }),
4203 peer_tasks: None,
4204 }),
4205 Vec::new(),
4206 Vec::new(),
4207 Vec::new(),
4208 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4209 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4210 EnforcementTier::KernelSealed,
4211 ContainmentClass::Advisory,
4212 )),
4213 IoMaxReport::default(),
4214 ))
4215 .unwrap();
4216 assert_eq!(
4217 declared["peer_files"],
4218 serde_json::json!({"root": "out/", "max_ttl_secs": 3600, "max_bytes": 10_485_760})
4219 );
4220 assert_eq!(
4221 declared["peer_fetch_allow"],
4222 serde_json::json!(["localhost:41234"])
4223 );
4224 }
4225
4226 #[test]
4227 fn render_names_peer_handoff_in_both_directions() {
4228 let undeclared = report_for(
4229 &sample_policy(),
4230 ContainmentClass::Advisory,
4231 EnforcementTier::KernelSealed,
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 EnforcementTier::KernelSealed,
4242 ContainmentClass::Advisory,
4243 )),
4244 IoMaxReport::default(),
4245 )
4246 .render();
4247 assert!(undeclared.contains("Peer handoff"));
4248 assert!(undeclared.contains("exports.peer_files: <none>"));
4249 assert!(undeclared.contains("peer_fetch allow: <none>"));
4250
4251 let declared = report_for(
4252 &CapabilityPolicy {
4253 peer_fetch_allow: vec!["localhost:41234".to_string()],
4254 ..sample_policy()
4255 },
4256 ContainmentClass::Advisory,
4257 EnforcementTier::KernelSealed,
4258 None,
4259 None,
4260 None,
4261 Some(&Exports {
4262 files: None,
4263 peer_files: Some(PeerFilesExport {
4264 root: "out/".to_string(),
4265 max_ttl_secs: Some(900),
4266 max_bytes: 4096,
4267 }),
4268 peer_tasks: None,
4269 }),
4270 Vec::new(),
4271 Vec::new(),
4272 Vec::new(),
4273 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4274 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4275 EnforcementTier::KernelSealed,
4276 ContainmentClass::Advisory,
4277 )),
4278 IoMaxReport::default(),
4279 )
4280 .render();
4281 assert!(declared.contains("exports.peer_files root: out/"));
4282 assert!(declared.contains("max handle ttl: 900s"));
4283 assert!(declared.contains("max bytes: 4096 (per file)"));
4284 assert!(declared.contains("- localhost:41234"));
4285 }
4286
4287 fn report_with_peer_tasks(tier: EnforcementTier, peer_tasks: Option<bool>) -> ScopeReport {
4288 report_for(
4289 &sample_policy(),
4290 ContainmentClass::Scoped,
4291 tier,
4292 None,
4293 None,
4294 None,
4295 peer_tasks
4296 .map(|accept| Exports {
4297 files: None,
4298 peer_files: None,
4299 peer_tasks: Some(murmur_artifact::PeerTasksExport { accept }),
4300 })
4301 .as_ref(),
4302 Vec::new(),
4303 Vec::new(),
4304 Vec::new(),
4305 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4306 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4307 tier,
4308 ContainmentClass::Scoped,
4309 )),
4310 IoMaxReport::default(),
4311 )
4312 }
4313
4314 #[test]
4317 fn peer_tasks_is_reported_and_moves_no_other_field() {
4318 for tier in ALL_TIERS {
4319 let absent = report_with_peer_tasks(*tier, None);
4320 let refused = report_with_peer_tasks(*tier, Some(false));
4321 let accepted = report_with_peer_tasks(*tier, Some(true));
4322 assert!(!absent.peer_tasks);
4323 assert_eq!(refused, absent, "tier {tier:?}");
4324 assert!(accepted.peer_tasks);
4325 assert_eq!(accepted.achieved_containment, absent.achieved_containment);
4326 assert_eq!(accepted.enforcement_tier, absent.enforcement_tier);
4327 assert_eq!(accepted.floor_met, absent.floor_met);
4328 assert_eq!(
4329 ScopeReport {
4330 peer_tasks: false,
4331 ..accepted.clone()
4332 },
4333 absent
4334 );
4335 }
4336 }
4337
4338 #[test]
4340 fn peer_tasks_is_always_serialized() {
4341 let tier = EnforcementTier::KernelFull;
4342 let absent = serde_json::to_value(report_with_peer_tasks(tier, None)).unwrap();
4343 assert_eq!(absent["peer_tasks"], serde_json::json!(false));
4344 let accepted = serde_json::to_value(report_with_peer_tasks(tier, Some(true))).unwrap();
4345 assert_eq!(accepted["peer_tasks"], serde_json::json!(true));
4346 }
4347
4348 #[test]
4349 fn render_names_the_peer_task_posture_under_peer_handoff() {
4350 let tier = EnforcementTier::KernelFull;
4351 for (declared, line) in [
4352 (None, " exports.peer_tasks: refuse\n"),
4353 (Some(false), " exports.peer_tasks: refuse\n"),
4354 (Some(true), " exports.peer_tasks: accept\n"),
4355 ] {
4356 let rendered = report_with_peer_tasks(tier, declared).render();
4357 let section = rendered
4358 .split("\nPeer handoff\n")
4359 .nth(1)
4360 .and_then(|rest| rest.split("\n\n").next())
4361 .unwrap_or_else(|| panic!("no Peer handoff section:\n{rendered}"));
4362 assert!(
4363 section.contains(line.trim_start_matches(' ').trim_end()),
4364 "{declared:?}:\n{section}"
4365 );
4366 assert!(rendered.contains(line), "{declared:?}:\n{rendered}");
4367 }
4368 }
4369
4370 #[test]
4374 fn a_state_store_is_reported_and_moves_no_other_field() {
4375 let policy = sample_policy();
4376 let stores = vec![StateStoreReport {
4377 artifact: "murmur/tool-corpus".to_string(),
4378 store: "shey".to_string(),
4379 host_path: "/home/dev/.murmur/state/shey".to_string(),
4380 }];
4381
4382 let without = report_for(
4383 &policy,
4384 ContainmentClass::Scoped,
4385 EnforcementTier::KernelFull,
4386 None,
4387 None,
4388 None,
4389 None,
4390 Vec::new(),
4391 Vec::new(),
4392 Vec::new(),
4393 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4394 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4395 EnforcementTier::KernelFull,
4396 ContainmentClass::Scoped,
4397 )),
4398 IoMaxReport::default(),
4399 );
4400 let with = report_for(
4401 &policy,
4402 ContainmentClass::Scoped,
4403 EnforcementTier::KernelFull,
4404 None,
4405 None,
4406 None,
4407 None,
4408 stores.clone(),
4409 Vec::new(),
4410 Vec::new(),
4411 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4412 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4413 EnforcementTier::KernelFull,
4414 ContainmentClass::Scoped,
4415 )),
4416 IoMaxReport::default(),
4417 );
4418
4419 assert_eq!(with.state_stores, stores);
4420 assert!(without.state_stores.is_empty());
4421 assert_eq!(with.achieved_containment, without.achieved_containment);
4422 assert_eq!(with.floor_met, without.floor_met);
4423 assert_eq!(with.enforcement_tier, without.enforcement_tier);
4424 assert_eq!(with.shortfall_reason, without.shortfall_reason);
4425 assert_eq!(
4427 ScopeReport {
4428 state_stores: Vec::new(),
4429 ..with.clone()
4430 },
4431 without
4432 );
4433
4434 assert!(with
4436 .render()
4437 .contains("- murmur/tool-corpus: shey -> /home/dev/.murmur/state/shey"));
4438 assert!(without.render().contains("state stores: <none>"));
4439
4440 let json = serde_json::to_value(&without).unwrap();
4443 assert_eq!(json["state_stores"], serde_json::json!([]));
4444 assert_eq!(
4445 serde_json::to_value(&with).unwrap()["state_stores"],
4446 serde_json::json!([{
4447 "artifact": "murmur/tool-corpus",
4448 "store": "shey",
4449 "host_path": "/home/dev/.murmur/state/shey",
4450 }])
4451 );
4452 }
4453
4454 fn extra_roots_preopen(artifact: &str, role: &str, scope: Option<&str>) -> PreopenReport {
4455 PreopenReport {
4456 artifact: artifact.to_string(),
4457 role: role.to_string(),
4458 scope: scope.map(str::to_string),
4459 surface: if scope.is_some() {
4460 PreopenSurface::ScopedSubtree
4461 } else {
4462 PreopenSurface::WholeWorkdir
4463 },
4464 runtime_dir_reach: (role == "tool" && scope.is_none())
4465 .then_some(RuntimeDirReach::MurmurRuntime),
4466 extra_roots: Vec::new(),
4467 }
4468 }
4469
4470 fn two_host_folders() -> Vec<crate::guest_roots::GuestRoot> {
4471 let host_path = |path: &str, access| crate::host_paths::HostPath {
4472 declared: path.to_string(),
4473 path: std::path::PathBuf::from(path),
4474 access,
4475 credential_locations: Vec::new(),
4476 };
4477 crate::guest_roots::extra_guest_roots_for(
4478 ContainmentClass::Advisory,
4479 &[
4480 host_path("/data/ref", crate::host_paths::HostPathAccess::Read),
4481 host_path("/data/out", crate::host_paths::HostPathAccess::ReadWrite),
4482 ],
4483 )
4484 }
4485
4486 #[test]
4488 fn extra_roots_serialize_with_four_keys_always_present() {
4489 let mut reports = vec![
4490 extra_roots_preopen("probe", "tool", None),
4491 extra_roots_preopen("cache-tool", "tool", Some("cache")),
4492 extra_roots_preopen("driver", "driver", None),
4493 ];
4494 attach_extra_roots(&mut reports, &two_host_folders());
4495 let json = serde_json::to_value(&reports).unwrap();
4496 assert_eq!(
4497 json[0]["extra_roots"],
4498 serde_json::json!([
4499 {
4500 "guest_path": "murmur-host/data/ref",
4501 "path": "/data/ref",
4502 "access": "read",
4503 "source": "host_paths",
4504 },
4505 {
4506 "guest_path": "murmur-host/data/out",
4507 "path": "/data/out",
4508 "access": "read-write",
4509 "source": "host_paths",
4510 },
4511 ])
4512 );
4513 assert_eq!(json[1]["extra_roots"], serde_json::json!([]));
4514 assert_eq!(json[2]["extra_roots"], serde_json::json!([]));
4515
4516 let mut reports = vec![extra_roots_preopen("probe", "tool", None)];
4517 attach_extra_roots(
4518 &mut reports,
4519 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4520 );
4521 assert_eq!(
4522 serde_json::to_value(&reports[0].extra_roots).unwrap(),
4523 serde_json::json!([{
4524 "guest_path": "murmur-host",
4525 "path": "/",
4526 "access": "read-write",
4527 "source": "unconfined",
4528 }])
4529 );
4530 }
4531
4532 #[test]
4535 fn extra_roots_render_after_the_runtime_directory() {
4536 let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4537 attach_extra_roots(&mut reports, &two_host_folders());
4538 assert_eq!(reports[0].extra_roots.len(), 2);
4539 assert!(
4540 reports[0].render().ends_with(
4541 "; runtime directory read-only at murmur-runtime/; host folders under \
4542 murmur-host/: /data/ref (read), /data/out (read-write)"
4543 ),
4544 "{}",
4545 reports[0].render()
4546 );
4547
4548 let mut whole = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4549 attach_extra_roots(
4550 &mut whole,
4551 &crate::guest_roots::extra_guest_roots_for(ContainmentClass::Unconfined, &[]),
4552 );
4553 assert!(whole[0].render().ends_with(
4554 "; runtime directory read-only at murmur-runtime/; whole host read-write \
4555 under murmur-host/"
4556 ));
4557 }
4558
4559 #[test]
4561 fn extra_roots_absent_leave_the_line_unchanged() {
4562 let mut reports = vec![extra_roots_preopen("wasm-tool", "tool", None)];
4563 attach_extra_roots(&mut reports, &[]);
4564 assert_eq!(
4565 reports[0].render(),
4566 "wasm-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4567 declared; runtime directory read-only at murmur-runtime/"
4568 );
4569 }
4570
4571 #[test]
4576 fn preopens_are_reported_and_move_no_other_field() {
4577 let policy = sample_policy();
4578 let preopens = vec![
4579 PreopenReport {
4580 artifact: "notes-tool".to_string(),
4581 role: "tool".to_string(),
4582 scope: None,
4583 surface: PreopenSurface::WholeWorkdir,
4584 runtime_dir_reach: Some(RuntimeDirReach::MurmurRuntime),
4585 extra_roots: Vec::new(),
4586 },
4587 PreopenReport {
4588 artifact: "cache-tool".to_string(),
4589 role: "tool".to_string(),
4590 scope: Some("cache".to_string()),
4591 surface: PreopenSurface::ScopedSubtree,
4592 runtime_dir_reach: None,
4593 extra_roots: Vec::new(),
4594 },
4595 PreopenReport {
4596 artifact: "telemetry-hook".to_string(),
4597 role: "hook".to_string(),
4598 scope: None,
4599 surface: PreopenSurface::Nothing,
4600 runtime_dir_reach: None,
4601 extra_roots: Vec::new(),
4602 },
4603 ];
4604
4605 let without = report_for(
4606 &policy,
4607 ContainmentClass::Scoped,
4608 EnforcementTier::KernelFull,
4609 None,
4610 None,
4611 None,
4612 None,
4613 Vec::new(),
4614 Vec::new(),
4615 Vec::new(),
4616 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4617 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4618 EnforcementTier::KernelFull,
4619 ContainmentClass::Scoped,
4620 )),
4621 IoMaxReport::default(),
4622 );
4623 let with = report_for(
4624 &policy,
4625 ContainmentClass::Scoped,
4626 EnforcementTier::KernelFull,
4627 None,
4628 None,
4629 None,
4630 None,
4631 Vec::new(),
4632 Vec::new(),
4633 preopens.clone(),
4634 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4635 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4636 EnforcementTier::KernelFull,
4637 ContainmentClass::Scoped,
4638 )),
4639 IoMaxReport::default(),
4640 );
4641
4642 assert_eq!(with.preopens, preopens);
4643 assert!(without.preopens.is_empty());
4644 assert_eq!(
4647 ScopeReport {
4648 preopens: Vec::new(),
4649 routes: without.routes.clone(),
4650 ..with.clone()
4651 },
4652 without
4653 );
4654
4655 let rendered = with.render();
4657 assert!(rendered.contains(
4658 "- notes-tool (tool): the whole accessible workdir — no capabilities.filesystem.scope \
4659 declared; runtime directory read-only at murmur-runtime/"
4660 ));
4661 assert!(rendered.contains(
4662 "- cache-tool (tool): one subtree of the accessible workdir — \
4663 capabilities.filesystem.scope: cache"
4664 ));
4665 assert!(rendered.contains(
4666 "- telemetry-hook (hook): nothing preopened — no capabilities.filesystem.scope declared"
4667 ));
4668 assert!(without.render().contains("preopens: <none>"));
4669
4670 assert_eq!(
4672 serde_json::to_value(&without).unwrap()["preopens"],
4673 serde_json::json!([])
4674 );
4675 assert_eq!(
4676 serde_json::to_value(&with).unwrap()["preopens"],
4677 serde_json::json!([
4678 {
4679 "artifact": "notes-tool",
4680 "role": "tool",
4681 "scope": null,
4682 "surface": "whole-workdir",
4683 "runtime_dir_reach": "murmur-runtime",
4684 "extra_roots": [],
4685 },
4686 {
4687 "artifact": "cache-tool",
4688 "role": "tool",
4689 "scope": "cache",
4690 "surface": "scoped-subtree",
4691 "runtime_dir_reach": null,
4692 "extra_roots": [],
4693 },
4694 {
4695 "artifact": "telemetry-hook",
4696 "role": "hook",
4697 "scope": null,
4698 "surface": "nothing",
4699 "runtime_dir_reach": null,
4700 "extra_roots": [],
4701 },
4702 ])
4703 );
4704 }
4705
4706 #[test]
4710 fn exports_files_is_always_serialized() {
4711 let undeclared = serde_json::to_value(report_for(
4712 &sample_policy(),
4713 ContainmentClass::Advisory,
4714 EnforcementTier::KernelFull,
4715 None,
4716 None,
4717 None,
4718 None,
4719 Vec::new(),
4720 Vec::new(),
4721 Vec::new(),
4722 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4723 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4724 EnforcementTier::KernelFull,
4725 ContainmentClass::Advisory,
4726 )),
4727 IoMaxReport::default(),
4728 ))
4729 .unwrap();
4730 assert_eq!(undeclared["exports_files"], serde_json::Value::Null);
4731
4732 let declared = serde_json::to_value(report_for(
4733 &sample_policy(),
4734 ContainmentClass::Advisory,
4735 EnforcementTier::KernelFull,
4736 None,
4737 None,
4738 None,
4739 Some(&Exports {
4740 files: Some(FileExport {
4741 root: "out/".to_string(),
4742 mode: ExportMode::ReadOnly,
4743 max_bytes: 10_485_760,
4744 }),
4745 peer_files: None,
4746 peer_tasks: None,
4747 }),
4748 Vec::new(),
4749 Vec::new(),
4750 Vec::new(),
4751 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4752 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4753 EnforcementTier::KernelFull,
4754 ContainmentClass::Advisory,
4755 )),
4756 IoMaxReport::default(),
4757 ))
4758 .unwrap();
4759 assert_eq!(
4760 declared["exports_files"],
4761 serde_json::json!({"root": "out/", "mode": "read-only", "max_bytes": 10_485_760})
4762 );
4763 }
4764
4765 #[test]
4766 fn render_names_the_resource_plane_in_both_directions() {
4767 let undeclared = report_for(
4768 &sample_policy(),
4769 ContainmentClass::Advisory,
4770 EnforcementTier::KernelFull,
4771 None,
4772 None,
4773 None,
4774 None,
4775 Vec::new(),
4776 Vec::new(),
4777 Vec::new(),
4778 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4779 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4780 EnforcementTier::KernelFull,
4781 ContainmentClass::Advisory,
4782 )),
4783 IoMaxReport::default(),
4784 )
4785 .render();
4786 assert!(
4787 undeclared.contains("Resource plane") && undeclared.contains("exports.files: <none>"),
4788 "{undeclared}"
4789 );
4790
4791 let declared = report_for(
4792 &sample_policy(),
4793 ContainmentClass::Advisory,
4794 EnforcementTier::KernelFull,
4795 None,
4796 None,
4797 None,
4798 Some(&Exports {
4799 files: Some(FileExport {
4800 root: "out/".to_string(),
4801 mode: ExportMode::ReadOnly,
4802 max_bytes: 10_485_760,
4803 }),
4804 peer_files: None,
4805 peer_tasks: None,
4806 }),
4807 Vec::new(),
4808 Vec::new(),
4809 Vec::new(),
4810 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4811 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4812 EnforcementTier::KernelFull,
4813 ContainmentClass::Advisory,
4814 )),
4815 IoMaxReport::default(),
4816 )
4817 .render();
4818 assert!(declared.contains("exports.files root: out/"), "{declared}");
4819 assert!(
4820 declared.contains("mode: read-only"),
4821 "{declared}"
4822 );
4823 assert!(
4824 declared.contains("max bytes: 10485760 (per file)"),
4825 "{declared}"
4826 );
4827 }
4828
4829 fn read_only_report(read_only: &[&str], shell_allow: &[&str]) -> ScopeReport {
4832 let policy = CapabilityPolicy {
4833 read_only_paths: read_only.iter().map(|p| (*p).to_string()).collect(),
4834 shell_allow: shell_allow.iter().map(|b| (*b).to_string()).collect(),
4835 ..CapabilityPolicy::default()
4836 };
4837 report_for(
4838 &policy,
4839 ContainmentClass::Scoped,
4840 EnforcementTier::KernelFull,
4841 None,
4842 None,
4843 None,
4844 None,
4845 Vec::new(),
4846 Vec::new(),
4847 Vec::new(),
4848 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
4849 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
4850 EnforcementTier::KernelFull,
4851 ContainmentClass::Scoped,
4852 )),
4853 IoMaxReport::default(),
4854 )
4855 }
4856
4857 #[test]
4860 fn declared_read_only_with_no_interpreter_reports_as_enforced() {
4861 let report = read_only_report(&["tests", "bench/fixtures"], &["git"]);
4862 assert_eq!(
4863 report.read_only_paths,
4864 vec!["tests".to_string(), "bench/fixtures".to_string()]
4865 );
4866 assert!(report.read_only_advisory_for.is_empty());
4867
4868 let rendered = report.render();
4869 assert!(rendered.contains(" read_only:\n - tests\n - bench/fixtures\n"));
4870 assert!(rendered.contains(
4871 "read_only enforcement: enforced for every tool call and every shell command the \
4872 dispatch check can read"
4873 ));
4874 assert!(!rendered.contains("advisory against"));
4875 }
4876
4877 #[test]
4880 fn an_allowlisted_interpreter_is_named_as_advisory() {
4881 let report = read_only_report(&["tests"], &["git", "python3"]);
4882 assert_eq!(report.read_only_advisory_for, vec!["python3".to_string()]);
4883
4884 let rendered = report.render();
4885 assert!(rendered.contains("read_only enforcement: advisory against python3"));
4886 assert!(rendered
4887 .contains("enforced for every tool call and for every shell command it can read"));
4888 }
4889
4890 #[test]
4894 fn the_reported_advisory_set_is_the_set_the_warning_fires_for() {
4895 let shell_allow: Vec<String> = ["git", "python3", "node", "make", "bash"]
4896 .iter()
4897 .map(|b| (*b).to_string())
4898 .collect();
4899 let declared = vec!["tests".to_string()];
4900 assert_eq!(
4901 read_only_report(&["tests"], &["git", "python3", "node", "make", "bash"])
4902 .read_only_advisory_for,
4903 read_only_advisory_for(&declared, &shell_allow)
4904 );
4905 assert!(read_only_advisory_for(&[], &shell_allow).is_empty());
4907 }
4908
4909 #[test]
4913 fn both_read_only_keys_are_always_serialized_arrays() {
4914 let json = serde_json::to_value(read_only_report(&[], &["python3"])).unwrap();
4915 assert_eq!(json["read_only_paths"], serde_json::json!([]));
4916 assert_eq!(json["read_only_advisory_for"], serde_json::json!([]));
4917
4918 let json = serde_json::to_value(read_only_report(&["tests"], &["python3"])).unwrap();
4919 assert_eq!(json["read_only_paths"], serde_json::json!(["tests"]));
4920 assert_eq!(
4921 json["read_only_advisory_for"],
4922 serde_json::json!(["python3"])
4923 );
4924 }
4925
4926 #[test]
4929 fn an_undeclared_read_only_renders_none_and_no_enforcement_line() {
4930 let rendered = read_only_report(&[], &["python3"]).render();
4931 assert!(rendered.contains(" read_only: <none>\n"));
4932 assert!(!rendered.contains("read_only enforcement:"));
4933 }
4934
4935 #[test]
4947 fn every_capability_policy_field_is_reported_or_exempted() {
4948 const EXEMPT: &[(&str, &str)] = &[
4950 (
4951 "shell_strip_env",
4952 "Names host variables removed from the shell child's environment on top of the \
4953 credential patterns the runtime always strips. It subtracts from what the \
4954 capsule sees and can open nothing, so reporting it beside the grants would pad \
4955 the list an operator audits for reach.",
4956 ),
4957 (
4958 "shell_baseline_env",
4959 "The fixed variables the shell child starts with (PATH, HOME, …). It shapes the \
4960 environment of a subprocess the capsule was already granted, and names no host \
4961 resource `shell allow` and `env allow` do not already account for.",
4962 ),
4963 (
4964 "limits",
4965 "Execution ceilings for a WASM guest — memory, table elements, instance count, \
4966 deadline. A bound on how much the capsule may consume, never a reach it gains.",
4967 ),
4968 (
4969 "resources",
4970 "The same, for subprocesses: rlimits, the Linux cgroup v2 scope and the \
4971 workdir size ceiling. A ceiling, not a grant, on exactly `limits`' terms.",
4972 ),
4973 (
4974 "declared_deadline_seconds",
4975 "The undefaulted form of `limits.deadline_seconds`, retained only so hook calls \
4976 can apply a lower default. Reporting it would report one ceiling twice.",
4977 ),
4978 (
4979 "state_declared",
4980 "Records that a capsule-wide `capabilities.state` block was declared where \
4981 nothing reads it, which `W-SEC-014` says in words. The stores that actually \
4982 exist are reported per artifact in `state_stores`.",
4983 ),
4984 (
4985 "conversation_declared",
4986 "Records a capsule-wide `capabilities.conversation` block that reaches no \
4987 artifact, on `state_declared`'s terms; `W-SEC-016` is what an operator needs, \
4988 and the grant itself is per artifact.",
4989 ),
4990 ];
4991
4992 const REPORTED: &[(&str, &str)] = &[
4994 ("network_allow", "network_allow"),
4995 ("peer_fetch_allow", "peer_fetch_allow"),
4996 ("unix_sockets_allowed", "unix_sockets"),
4997 ("filesystem_scope", "filesystem_scope"),
4998 ("workdir_exec_allowed", "workdir_exec"),
4999 ("read_only_paths", "read_only_paths"),
5000 ("shell_allow", "shell_allow"),
5001 ("spawn_allow", "spawn_allow"),
5002 ("spawn_workdir_snapshot", "spawn_workdir_snapshot"),
5003 ("shell_interpreter_runtime", "interpreter_runtime_grants"),
5004 (
5005 "shell_derived_interpreter_runtime",
5006 "derived_interpreter_runtime",
5007 ),
5008 ("shell_trust_store", "trust_store"),
5009 ("host_paths", "host_paths"),
5010 ("shell_staged_runtime", "staged_runtime_grants"),
5011 ("env_allow", "env_allow"),
5012 ("plan_submit", "plan_submit"),
5013 ("install_skill", "install_skill"),
5014 ("install_tool", "install_tool"),
5015 ("install_from_sources", "install_from"),
5016 ("containment_floor", "declared_containment"),
5017 ("declared_resources", "unconfined"),
5018 ];
5019
5020 let declared = capability_policy_field_names(include_str!("types.rs"));
5021 assert!(
5022 declared.len() > 10,
5023 "failed to read CapabilityPolicy's fields out of types.rs (found {declared:?})"
5024 );
5025
5026 for field in &declared {
5027 let reported = REPORTED.iter().any(|(policy, _)| policy == field);
5028 let exempt = EXEMPT.iter().any(|(policy, _)| policy == field);
5029 assert!(
5030 reported || exempt,
5031 "CapabilityPolicy::{field} is a grant no --explain-scope report mentions. Either \
5032 add it to ScopeReport and to this test's REPORTED table, or add it to EXEMPT \
5033 with a written reason for why an operator does not need to see it."
5034 );
5035 assert!(
5036 !(reported && exempt),
5037 "CapabilityPolicy::{field} is listed as both reported and exempt"
5038 );
5039 }
5040
5041 for (policy_field, _) in REPORTED.iter().chain(EXEMPT.iter()) {
5042 assert!(
5043 declared.iter().any(|field| field == policy_field),
5044 "the table names CapabilityPolicy::{policy_field}, which types.rs no longer \
5045 declares — remove the stale row"
5046 );
5047 }
5048
5049 let json = serde_json::to_value(report_for(
5052 &sample_policy(),
5053 ContainmentClass::Scoped,
5054 EnforcementTier::KernelFull,
5055 None,
5056 None,
5057 None,
5058 None,
5059 Vec::new(),
5060 Vec::new(),
5061 Vec::new(),
5062 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5063 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5064 EnforcementTier::KernelFull,
5065 ContainmentClass::Scoped,
5066 )),
5067 IoMaxReport::default(),
5068 ))
5069 .unwrap();
5070 let object = json.as_object().expect("a scope report is a JSON object");
5071 for (policy_field, report_key) in REPORTED {
5072 assert!(
5073 object.contains_key(*report_key),
5074 "the table maps CapabilityPolicy::{policy_field} to a report key \
5075 '{report_key}' that a serialized ScopeReport does not carry"
5076 );
5077 }
5078 }
5079
5080 fn boundary_report(declared: ContainmentClass, tier: EnforcementTier) -> ScopeReport {
5083 report_for(
5084 &sample_policy(),
5085 declared,
5086 tier,
5087 None,
5088 None,
5089 Some(6),
5092 None,
5093 Vec::new(),
5094 Vec::new(),
5095 Vec::new(),
5096 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5097 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(tier, declared)),
5098 IoMaxReport::default(),
5099 )
5100 }
5101
5102 fn sample_snapshot() -> crate::workdir_snapshot::WorkdirSnapshot {
5103 crate::workdir_snapshot::WorkdirSnapshot {
5104 source: "/home/you/project".to_string(),
5105 files: 12,
5106 dirs: 3,
5107 symlinks: 1,
5108 repointed_symlinks: 0,
5109 bytes: 40960,
5110 skipped_count: 1,
5111 skipped: vec![crate::workdir_snapshot::SkippedEntry {
5112 path: "run/pipe".to_string(),
5113 reason: crate::workdir_snapshot::SkipReason::Fifo,
5114 }],
5115 }
5116 }
5117
5118 #[test]
5121 fn spawn_workdir_snapshot_and_parent_workdir_are_always_reported() {
5122 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5123 let json = serde_json::to_value(&report).unwrap();
5124 assert_eq!(json["spawn_workdir_snapshot"], serde_json::json!([]));
5125 assert!(json.as_object().unwrap().contains_key("parent_workdir"));
5126 assert_eq!(json["parent_workdir"], serde_json::Value::Null);
5127 let rendered = report.render();
5128 let snapshot_line = rendered
5129 .find("\n spawn workdir snapshot: <none>\n")
5130 .unwrap_or_else(|| panic!("{rendered}"));
5131 assert!(
5132 rendered.find("\n spawn allow:").unwrap() < snapshot_line,
5133 "{rendered}"
5134 );
5135
5136 assert!(!rendered.contains("Parent work folder"), "{rendered}");
5137
5138 let mut policy = sample_policy();
5139 policy.spawn_allow = vec!["local/reviewer".to_string(), "local/worker".to_string()];
5140 policy.spawn_workdir_snapshot = vec!["local/reviewer".to_string()];
5141 let report = report_for(
5142 &policy,
5143 ContainmentClass::Scoped,
5144 EnforcementTier::KernelFull,
5145 None,
5146 None,
5147 Some(6),
5148 None,
5149 Vec::new(),
5150 Vec::new(),
5151 Vec::new(),
5152 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5153 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5154 EnforcementTier::KernelFull,
5155 ContainmentClass::Scoped,
5156 )),
5157 IoMaxReport::default(),
5158 );
5159 assert_eq!(report.spawn_workdir_snapshot, vec!["local/reviewer"]);
5160 assert_eq!(
5161 serde_json::to_value(&report).unwrap()["spawn_workdir_snapshot"],
5162 serde_json::json!(["local/reviewer"])
5163 );
5164 assert!(
5165 report
5166 .render()
5167 .contains(" spawn workdir snapshot:\n - local/reviewer\n"),
5168 "{}",
5169 report.render()
5170 );
5171 }
5172
5173 #[test]
5176 fn parent_workdir_restriction_follows_the_applied_tier() {
5177 for (declared, tier, restriction) in [
5178 (
5179 ContainmentClass::Sealed,
5180 EnforcementTier::KernelSealed,
5181 "absent",
5182 ),
5183 (
5184 ContainmentClass::Scoped,
5185 EnforcementTier::KernelFull,
5186 "enforced",
5187 ),
5188 (
5189 ContainmentClass::Advisory,
5190 EnforcementTier::KernelSeccompOnly,
5191 "advisory",
5192 ),
5193 (
5194 ContainmentClass::Advisory,
5195 EnforcementTier::EnvironmentOnly,
5196 "advisory",
5197 ),
5198 ] {
5199 let report =
5200 boundary_report(declared, tier).with_parent_workdir(Some(sample_snapshot()));
5201 let json = serde_json::to_value(&report).unwrap();
5202 let parent = &json["parent_workdir"];
5203 assert_eq!(parent["restriction"], restriction, "{tier:?}");
5204 assert_eq!(
5205 parent["restriction"],
5206 json["filesystem_boundary"]["restriction"]
5207 );
5208 assert_eq!(parent["source"], "/home/you/project");
5209 assert_eq!(parent["files"], 12);
5210 assert_eq!(parent["skipped"][0]["reason"], "fifo");
5211
5212 let rendered = report.render();
5213 assert!(
5214 rendered.contains(
5215 "\nParent work folder\n source: /home/you/project\n copied: \
5216 12 files, 3 directories, 1 symlinks (0 re-pointed), 40960 bytes\n \
5217 skipped: 1\n - run/pipe (fifo)\n restriction: "
5218 ),
5219 "{rendered}"
5220 );
5221 assert!(
5222 rendered.contains(&format!(" restriction: {restriction}: ")),
5223 "{rendered}"
5224 );
5225 }
5226 assert_eq!(
5227 boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull)
5228 .with_parent_workdir(None)
5229 .parent_workdir,
5230 None
5231 );
5232 }
5233
5234 fn presence_cases() -> Vec<(&'static str, RouteInputs, Vec<Route>)> {
5236 vec![
5237 ("nothing", RouteInputs::default(), vec![]),
5238 (
5239 "one tool",
5240 RouteInputs {
5241 tools: vec!["t".to_string()],
5242 ..RouteInputs::default()
5243 },
5244 vec![Route::WasmTools],
5245 ),
5246 (
5247 "a driver",
5248 RouteInputs {
5249 drivers: vec!["d".to_string()],
5250 ..RouteInputs::default()
5251 },
5252 vec![Route::Drivers],
5253 ),
5254 (
5255 "a hook",
5256 RouteInputs {
5257 hooks: vec!["h".to_string()],
5258 ..RouteInputs::default()
5259 },
5260 vec![Route::Hooks],
5261 ),
5262 (
5263 "install.tool",
5264 RouteInputs {
5265 install_tool: true,
5266 ..RouteInputs::default()
5267 },
5268 vec![Route::WasmTools],
5269 ),
5270 (
5271 "transport: process",
5272 RouteInputs {
5273 process_transport: true,
5274 ..RouteInputs::default()
5275 },
5276 vec![Route::ProcessHarness, Route::HarnessTools],
5277 ),
5278 (
5279 "plan.submit without shell.allow",
5280 RouteInputs {
5281 plan_submit: true,
5282 ..RouteInputs::default()
5283 },
5284 vec![],
5285 ),
5286 (
5287 "plan.submit with shell.allow",
5288 RouteInputs {
5289 plan_submit: true,
5290 shell_allow: true,
5291 ..RouteInputs::default()
5292 },
5293 vec![Route::Shell, Route::PlanShellSteps],
5294 ),
5295 (
5296 "shell.allow alone",
5297 RouteInputs {
5298 shell_allow: true,
5299 ..RouteInputs::default()
5300 },
5301 vec![Route::Shell],
5302 ),
5303 ]
5304 }
5305
5306 #[test]
5311 fn the_route_table_holds_its_invariants_on_every_input() {
5312 for tier in ALL_TIERS {
5313 for declared in [
5314 ContainmentClass::Advisory,
5315 ContainmentClass::Scoped,
5316 ContainmentClass::Sealed,
5317 ] {
5318 let mechanism =
5319 ContainmentDecision::decide(*tier, declared, false, false).enforcement_tier();
5320 for (case, inputs, present) in presence_cases() {
5321 let rows = route_table(&inputs, mechanism);
5322 assert_eq!(
5323 rows.iter().map(|row| row.route).collect::<Vec<_>>(),
5324 Route::ALL.to_vec(),
5325 "{case}"
5326 );
5327 for row in &rows {
5328 let covered = matches!(row.route, Route::Shell | Route::PlanShellSteps);
5329 assert_eq!(row.under_class, covered, "{case}: {row:?}");
5330 if covered {
5331 assert_eq!(row.mechanism, mechanism, "{case}: {row:?}");
5332 assert_eq!(row.note, None, "{case}: {row:?}");
5333 } else {
5334 assert_eq!(row.mechanism, HOST_PROCESS_MECHANISM, "{case}: {row:?}");
5335 assert!(row.note.is_some(), "{case}: {row:?}");
5336 }
5337 assert_eq!(
5338 row.present,
5339 present.contains(&row.route),
5340 "{case} on {tier:?}/{declared}: {row:?}"
5341 );
5342 }
5343 }
5344 }
5345 }
5346 }
5347
5348 #[test]
5350 fn each_uncovered_route_names_what_holds_it() {
5351 let notes = |route: Route| route.note().unwrap();
5352 for route in [Route::WasmTools, Route::Drivers] {
5353 assert!(notes(route).contains("inside the mur process"), "{route:?}");
5354 assert!(notes(route).contains("WASI preopens"), "{route:?}");
5355 assert!(
5356 notes(route).contains("host-side network allowlist only"),
5357 "{route:?}"
5358 );
5359 }
5360 assert!(notes(Route::Hooks).contains("its own capabilities block declares"));
5361 let harness = notes(Route::ProcessHarness);
5362 for word in [
5363 "cleared environment",
5364 "Landlock",
5365 "seccomp",
5366 "network namespace",
5367 "rlimits",
5368 "cgroup with no limits",
5369 ] {
5370 assert!(harness.contains(word), "{word}: {harness}");
5371 }
5372 assert!(notes(Route::HarnessTools).contains("the user's authority"));
5373 assert!(notes(Route::HarnessTools).contains("arguments the process driver passes"));
5374 }
5375
5376 #[test]
5378 fn route_inputs_read_roles_off_the_preopens() {
5379 let preopen = |artifact: &str, role: &str| PreopenReport {
5380 artifact: artifact.to_string(),
5381 role: role.to_string(),
5382 scope: None,
5383 surface: PreopenSurface::WholeWorkdir,
5384 runtime_dir_reach: None,
5385 extra_roots: Vec::new(),
5386 };
5387 let preopens = vec![
5388 preopen("fs-tool", "tool"),
5389 preopen("browser", "tool"),
5390 preopen("driver-anthropic", "driver"),
5391 preopen("audit", "hook"),
5392 ];
5393 let inputs = RouteInputs::new(&[], false, &[], &preopens, Some("http"));
5394 let rows = route_table(&inputs, "landlock+seccomp");
5395 let row = |route: Route| rows.iter().find(|row| row.route == route).unwrap();
5396 assert_eq!(row(Route::WasmTools).artifacts, vec!["fs-tool", "browser"]);
5397 assert_eq!(row(Route::Drivers).artifacts, vec!["driver-anthropic"]);
5398 assert_eq!(row(Route::Hooks).artifacts, vec!["audit"]);
5399 assert!(!row(Route::ProcessHarness).present);
5400 assert!(RouteInputs::new(&[], false, &[], &[], Some("process")).process_transport);
5401 }
5402
5403 #[test]
5406 fn the_routes_json_is_pinned() {
5407 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
5408 .with_routes(Some("process"));
5409 let json = serde_json::to_value(&report).unwrap();
5410 assert!(json.get("mechanism_covers").is_none(), "{json}");
5411 let routes = json["routes"].as_array().expect("routes is an array");
5412 assert_eq!(routes.len(), 7);
5413 let shell_present = !report.shell_allow.is_empty();
5414 assert_eq!(
5415 routes[0],
5416 serde_json::json!({
5417 "route": "shell",
5418 "present": shell_present,
5419 "under_class": true,
5420 "mechanism": "landlock+seccomp",
5421 "note": null,
5422 "artifacts": [],
5423 })
5424 );
5425 assert_eq!(
5426 routes[5],
5427 serde_json::json!({
5428 "route": "process-harness",
5429 "present": true,
5430 "under_class": false,
5431 "mechanism": "host-process",
5432 "note": Route::ProcessHarness.note(),
5433 "artifacts": [],
5434 })
5435 );
5436 assert_eq!(
5437 routes
5438 .iter()
5439 .map(|row| row["route"].as_str().unwrap())
5440 .collect::<Vec<_>>(),
5441 [
5442 "shell",
5443 "plan-shell-steps",
5444 "wasm-tools",
5445 "drivers",
5446 "hooks",
5447 "process-harness",
5448 "harness-tools"
5449 ]
5450 );
5451 assert_eq!(
5452 json["cgroup_scope"],
5453 serde_json::json!({"status": "not-probed"})
5454 );
5455 }
5456
5457 #[test]
5459 fn the_routes_block_follows_the_containment_block() {
5460 let preopens = vec![PreopenReport {
5461 artifact: "fs-tool".to_string(),
5462 role: "tool".to_string(),
5463 scope: None,
5464 surface: PreopenSurface::WholeWorkdir,
5465 runtime_dir_reach: None,
5466 extra_roots: Vec::new(),
5467 }];
5468 let report = report_for(
5469 &sample_policy(),
5470 ContainmentClass::Advisory,
5471 EnforcementTier::KernelSealed,
5472 None,
5473 None,
5474 Some(6),
5475 None,
5476 Vec::new(),
5477 Vec::new(),
5478 preopens,
5479 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5480 crate::temp_dir::tier_temp_mapping(EnforcementTier::KernelFull),
5481 IoMaxReport::default(),
5482 )
5483 .with_routes(None);
5484 let rendered = report.render();
5485 assert!(!rendered.contains("covers:"), "{rendered}");
5486 let routes = rendered.find("\nRoutes\n").expect("a Routes block");
5487 assert!(
5488 routes > rendered.find(" mechanism: ").unwrap(),
5489 "{rendered}"
5490 );
5491 assert!(
5492 routes < rendered.find("\nRuntime directory\n").unwrap(),
5493 "{rendered}"
5494 );
5495 assert!(
5496 rendered.contains(" shell commands present landlock+seccomp\n"),
5497 "{rendered}"
5498 );
5499 assert!(
5500 rendered.contains(
5501 " WASM tools present host process, not confined by the class (fs-tool)\n \
5502 Runs inside the mur process"
5503 ),
5504 "{rendered}"
5505 );
5506 assert!(
5507 rendered
5508 .contains(" process harness absent host process, not confined by the class\n"),
5509 "{rendered}"
5510 );
5511 assert!(
5512 rendered.contains(" cgroup scope: not-probed\n"),
5513 "{rendered}"
5514 );
5515 }
5516
5517 #[test]
5520 fn the_uncovered_route_warning_names_exactly_the_present_uncovered_routes() {
5521 let inputs = RouteInputs {
5522 shell_allow: true,
5523 tools: vec!["fs-tool".to_string()],
5524 drivers: vec!["driver-anthropic".to_string()],
5525 ..RouteInputs::default()
5526 };
5527 let routes = route_table(&inputs, "landlock+seccomp");
5528 assert_eq!(
5529 uncovered_route_warning(ContainmentClass::Advisory, &routes),
5530 None
5531 );
5532
5533 let covered_only = route_table(
5534 &RouteInputs {
5535 shell_allow: true,
5536 plan_submit: true,
5537 ..RouteInputs::default()
5538 },
5539 "landlock+seccomp",
5540 );
5541 assert_eq!(
5542 uncovered_route_warning(ContainmentClass::Scoped, &covered_only),
5543 None
5544 );
5545
5546 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5547 let message = uncovered_route_warning(declared, &routes).expect("fires");
5548 assert!(
5549 message.contains(&format!("the declared {declared} floor")),
5550 "{message}"
5551 );
5552 assert!(
5553 message.contains(
5554 "WASM tools (fs-tool) and drivers (driver-anthropic) run outside the class"
5555 ),
5556 "{message}"
5557 );
5558 assert!(message.contains("nothing is refused"), "{message}");
5559 for absent in ["hooks", "process harness", "harness tools"] {
5560 assert!(!message.contains(absent), "{absent}: {message}");
5561 }
5562 }
5563
5564 let process = route_table(
5565 &RouteInputs {
5566 process_transport: true,
5567 drivers: vec!["driver-claude-code".to_string()],
5568 ..RouteInputs::default()
5569 },
5570 "landlock+seccomp",
5571 );
5572 let message = uncovered_route_warning(ContainmentClass::Scoped, &process).unwrap();
5573 assert!(
5574 message.contains(
5575 "drivers (driver-claude-code), the process harness and harness tools run"
5576 ),
5577 "{message}"
5578 );
5579 }
5580
5581 #[test]
5583 fn the_uncovered_route_warning_reaches_the_bootstrap_log() {
5584 let temp = tempfile::tempdir().unwrap();
5585 let routes = route_table(
5586 &RouteInputs {
5587 drivers: vec!["d".to_string()],
5588 ..RouteInputs::default()
5589 },
5590 "landlock+seccomp",
5591 );
5592 warn_on_uncovered_routes(
5593 ContainmentClass::Sealed,
5594 &routes,
5595 Some(&crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap()),
5596 );
5597 let log = std::fs::read_to_string(temp.path().join("logs").join("bootstrap.log"))
5598 .unwrap_or_default();
5599 assert_eq!(log.matches("warning[W-SEC-034]").count(), 1, "{log}");
5600 assert!(log.contains("drivers (d)"), "{log}");
5601 assert!(log.contains("#w-sec-034"), "{log}");
5602 }
5603
5604 #[test]
5607 fn an_advisory_session_says_home_is_relocated_not_protected() {
5608 for tier in [
5609 EnforcementTier::EnvironmentOnly,
5610 EnforcementTier::KernelSeccompOnly,
5611 ] {
5612 let boundary = filesystem_boundary(tier);
5613 let home = boundary
5614 .not_protected
5615 .iter()
5616 .find(|statement| statement.contains("relocated, not protected"))
5617 .unwrap_or_else(|| panic!("{tier:?}: {boundary:?}"));
5618 assert!(home.contains("session's home folder"), "{home}");
5619 assert!(
5620 home.contains("real home directory stays readable and writable by absolute path"),
5621 "{home}"
5622 );
5623 assert!(home.contains("no kernel mechanism"), "{home}");
5624 }
5625 assert!(!filesystem_boundary(EnforcementTier::KernelFull)
5626 .not_protected
5627 .iter()
5628 .any(|statement| statement.contains("relocated, not protected")));
5629 assert!(filesystem_boundary(EnforcementTier::KernelSealed)
5630 .not_protected
5631 .is_empty());
5632 }
5633
5634 #[test]
5636 fn a_session_with_no_kernel_mechanism_reports_what_it_does_not_protect() {
5637 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
5638
5639 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5640 assert_eq!(
5641 report.filesystem_boundary.restriction,
5642 FilesystemRestriction::Advisory
5643 );
5644 assert_eq!(report.filesystem_boundary.not_protected.len(), 5);
5647 assert_eq!(
5648 report.filesystem_boundary.not_protected[2],
5649 ADVISORY_HOME_NOT_PROTECTED
5650 );
5651 assert_eq!(
5652 Some(&report.filesystem_boundary.not_protected[3]),
5653 report.runtime_dir.not_protected_because.as_ref()
5654 );
5655 assert_eq!(
5656 Some(&report.filesystem_boundary.not_protected[4]),
5657 report.records_dir.not_protected_because.as_ref()
5658 );
5659 assert!(report.filesystem_boundary.not_protected[4].contains("the records directory"));
5660
5661 let filesystem = &report.filesystem_boundary.not_protected[0];
5662 assert!(filesystem.contains("advisory"), "{filesystem}");
5663 assert!(
5664 filesystem.contains("denies a read or a write outside the work folder"),
5665 "{filesystem}"
5666 );
5667
5668 let rewriting = &report.filesystem_boundary.not_protected[1];
5669 assert!(rewriting.contains("not a boundary"), "{rewriting}");
5670 assert!(rewriting.contains("HOME"), "{rewriting}");
5671
5672 let rendered = report.render();
5673 let heading = rendered
5674 .find("\nNot protected here\n")
5675 .expect("a session that composes no root prints the block");
5676 assert!(
5677 heading > rendered.find("Containment\n").unwrap(),
5678 "the block belongs under Containment:\n{rendered}"
5679 );
5680 assert!(
5681 heading < rendered.find("\nEffective grants\n").unwrap(),
5682 "the block belongs ahead of the grants an operator reads it against:\n{rendered}"
5683 );
5684 for statement in &report.filesystem_boundary.not_protected {
5685 assert!(rendered.contains(statement.as_str()), "{rendered}");
5686 }
5687 }
5688
5689 fn inside_placement() -> RuntimeDirPlacement {
5690 RuntimeDirPlacement {
5691 sessions_dir: std::path::PathBuf::from("/home/dev/.murmur/sessions"),
5692 records_group: std::path::PathBuf::from("/home/dev/.murmur/records/home/dev"),
5693 workdir: std::path::PathBuf::from("/home/dev"),
5694 sessions_inside_workdir: true,
5695 records_inside_workdir: true,
5696 }
5697 }
5698
5699 #[test]
5701 fn runtime_dir_report_makes_one_claim_per_tier() {
5702 let outside = RuntimeDirPlacement::outside_for_test();
5703 for abi in [None, Some(1), Some(2), Some(3), Some(6)] {
5704 let sealed = runtime_dir_report(EnforcementTier::KernelSealed, abi, &outside);
5705 assert!(sealed.read_only_to_commands, "sealed at {abi:?}");
5706 assert_eq!(sealed.enforced_by, Some("mountns+landlock"));
5707 assert_eq!(sealed.not_protected_because, None);
5708 }
5709
5710 let full = runtime_dir_report(EnforcementTier::KernelFull, Some(3), &outside);
5711 assert!(full.read_only_to_commands);
5712 assert_eq!(full.enforced_by, Some("landlock"));
5713 assert_eq!(full.not_protected_because, None);
5714
5715 for abi in [Some(1), Some(2)] {
5716 let old = runtime_dir_report(EnforcementTier::KernelFull, abi, &outside);
5717 assert!(!old.read_only_to_commands);
5718 assert_eq!(old.enforced_by, None);
5719 let reason = old.not_protected_because.unwrap();
5720 assert!(
5721 reason.contains(&format!("Landlock ABI {}", abi.unwrap()))
5722 && reason.contains("truncate(2)")
5723 && reason.contains("6.2"),
5724 "{reason}"
5725 );
5726 }
5727
5728 let seccomp = runtime_dir_report(EnforcementTier::KernelSeccompOnly, None, &outside);
5729 assert!(!seccomp.read_only_to_commands);
5730 assert!(seccomp
5731 .not_protected_because
5732 .unwrap()
5733 .contains("no Landlock"));
5734
5735 let none = runtime_dir_report(EnforcementTier::EnvironmentOnly, None, &outside);
5736 assert!(!none.read_only_to_commands);
5737 let reason = none.not_protected_because.unwrap();
5738 assert!(
5739 reason.contains("no kernel containment") && reason.contains("macOS"),
5740 "{reason}"
5741 );
5742
5743 assert_eq!(
5744 full.path, "/home/test/.murmur/sessions/<session-id>/runtime",
5745 "the path carries the literal session-id segment"
5746 );
5747 }
5748
5749 #[test]
5751 fn runtime_dir_report_inside_the_workdir_is_never_protected() {
5752 for tier in ALL_TIERS {
5753 let report = runtime_dir_report(*tier, Some(6), &inside_placement());
5754 assert!(!report.read_only_to_commands, "{tier:?}");
5755 assert_eq!(report.enforced_by, None);
5756 }
5757 let reason =
5758 runtime_dir_report(EnforcementTier::KernelSealed, Some(6), &inside_placement())
5759 .not_protected_because
5760 .unwrap();
5761 assert!(
5762 reason.contains("/home/dev/.murmur/sessions/<session-id>/runtime"),
5763 "{reason}"
5764 );
5765 assert!(reason.contains("inside the workdir /home/dev"), "{reason}");
5766 }
5767
5768 #[test]
5771 fn runtime_dir_inside_the_workdir_caps_the_class_and_refuses_a_kernel_floor() {
5772 let inside = inside_placement();
5773 assert_eq!(
5774 achieved_containment_class(EnforcementTier::KernelSealed, false, true),
5775 ContainmentClass::Advisory
5776 );
5777 assert_eq!(
5778 achieved_containment_class(EnforcementTier::KernelSealed, false, false),
5779 ContainmentClass::Sealed
5780 );
5781 for declared in [ContainmentClass::Scoped, ContainmentClass::Sealed] {
5782 match check_containment_floor(
5783 declared,
5784 ContainmentClass::Advisory,
5785 None,
5786 false,
5787 inside.inside(),
5788 ) {
5789 Err(RuntimeError::ContainmentFloorUnmet { reason, .. }) => {
5790 assert!(reason.contains("/home/dev/.murmur/sessions"), "{reason}");
5791 assert!(reason.contains("the workdir /home/dev"), "{reason}");
5792 assert!(
5793 reason.contains("run with a --workdir that does not contain ~/.murmur"),
5794 "{reason}"
5795 );
5796 }
5797 other => panic!("{declared:?}: {other:?}"),
5798 }
5799 }
5800 assert!(check_containment_floor(
5801 ContainmentClass::Advisory,
5802 ContainmentClass::Advisory,
5803 None,
5804 false,
5805 inside.inside()
5806 )
5807 .is_ok());
5808
5809 let report = report_for(
5810 &sample_policy(),
5811 ContainmentClass::Advisory,
5812 EnforcementTier::KernelFull,
5813 None,
5814 None,
5815 Some(6),
5816 None,
5817 Vec::new(),
5818 Vec::new(),
5819 Vec::new(),
5820 &inside,
5821 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5822 EnforcementTier::KernelFull,
5823 ContainmentClass::Advisory,
5824 )),
5825 IoMaxReport::default(),
5826 );
5827 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5828 let reason = report.runtime_dir.not_protected_because.clone().unwrap();
5829 assert!(report.filesystem_boundary.not_protected.contains(&reason));
5830 let rendered = report.render();
5831 assert!(
5832 rendered.contains(&format!(" commands: can change it — {reason}\n")),
5833 "{rendered}"
5834 );
5835 }
5836
5837 #[test]
5839 fn runtime_dir_block_renders_after_containment_and_serializes_every_field() {
5840 let report = boundary_report(ContainmentClass::Scoped, EnforcementTier::KernelFull);
5841 let rendered = report.render();
5842 let block =
5843 "Runtime directory\n path: /home/test/.murmur/sessions/<session-id>/runtime\n \
5844 commands: read only (landlock)\n";
5845 let at = rendered.find(block).expect(&rendered);
5846 assert!(at > rendered.find("Containment\n").unwrap());
5847 assert!(at < rendered.find("Effective grants").unwrap());
5848
5849 let sealed = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
5850 assert!(sealed
5851 .render()
5852 .contains(" commands: read only (mountns+landlock)\n"));
5853
5854 let json = serde_json::to_string(&report.runtime_dir).unwrap();
5855 assert_eq!(
5856 json,
5857 r#"{"path":"/home/test/.murmur/sessions/<session-id>/runtime","read_only_to_commands":true,"enforced_by":"landlock","not_protected_because":null}"#
5858 );
5859 }
5860
5861 #[test]
5864 fn a_landlock_session_reports_denial_rather_than_absence() {
5865 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
5866
5867 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
5868 assert_eq!(
5869 report.filesystem_boundary.restriction,
5870 FilesystemRestriction::Enforced
5871 );
5872 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
5873
5874 let filesystem = &report.filesystem_boundary.not_protected[0];
5875 assert!(
5876 !filesystem.contains("advisory"),
5877 "Landlock mediates the filesystem here: {filesystem}"
5878 );
5879 assert!(
5880 filesystem.contains("denied rather than absent"),
5881 "{filesystem}"
5882 );
5883 for probe in ["stat", "access", "readlink"] {
5884 assert!(filesystem.contains(probe), "{filesystem}");
5885 }
5886 }
5887
5888 #[test]
5892 fn a_sealed_capable_host_still_discloses_landlock_for_a_capsule_that_declared_less() {
5893 let report = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelSealed);
5894
5895 assert_eq!(report.achieved_containment, ContainmentClass::Scoped);
5896 assert_eq!(report.enforcement_tier, "landlock+seccomp");
5897 assert_eq!(
5898 report.host_containment,
5899 ContainmentClass::Sealed,
5900 "the host can back sealed, which only `host_containment` says"
5901 );
5902 assert_eq!(
5903 report.filesystem_boundary.restriction,
5904 FilesystemRestriction::Enforced,
5905 "no composed root is installed for a capsule that did not declare one"
5906 );
5907 assert_eq!(
5908 report.filesystem_boundary.not_protected,
5909 boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull)
5910 .filesystem_boundary
5911 .not_protected,
5912 "the same mechanism gets the same statements on either host"
5913 );
5914 assert!(report.render().contains("Not protected here"));
5915 }
5916
5917 #[test]
5920 fn the_path_rewriting_statement_is_identical_wherever_it_is_carried() {
5921 let advisory =
5922 boundary_report(ContainmentClass::Advisory, EnforcementTier::EnvironmentOnly);
5923 let enforced = boundary_report(ContainmentClass::Advisory, EnforcementTier::KernelFull);
5924
5925 assert_eq!(
5926 advisory.filesystem_boundary.not_protected[1],
5927 enforced.filesystem_boundary.not_protected[1]
5928 );
5929 assert!(
5930 advisory.filesystem_boundary.not_protected[1]
5931 .contains("home/ in its records directory"),
5932 "the statement must name the directory `shell::build_shell_env` really sets HOME to"
5933 );
5934 }
5935
5936 #[test]
5939 fn a_composed_root_carries_the_key_with_nothing_to_disclaim() {
5940 let report = boundary_report(ContainmentClass::Sealed, EnforcementTier::KernelSealed);
5941
5942 assert_eq!(report.achieved_containment, ContainmentClass::Sealed);
5943 assert_eq!(
5944 report.filesystem_boundary.restriction,
5945 FilesystemRestriction::Absent
5946 );
5947 assert!(report.filesystem_boundary.not_protected.is_empty());
5948 assert!(
5949 !report.render().contains("Not protected here"),
5950 "{}",
5951 report.render()
5952 );
5953
5954 let json = serde_json::to_value(&report).unwrap();
5955 assert_eq!(json["filesystem_boundary"]["restriction"], "absent");
5956 assert_eq!(
5957 json["filesystem_boundary"]["not_protected"],
5958 serde_json::json!([]),
5959 "the key is present and empty at absent, never missing"
5960 );
5961 }
5962
5963 #[test]
5967 fn workdir_exec_caps_the_class_without_changing_the_mechanism() {
5968 let policy = CapabilityPolicy {
5969 workdir_exec_allowed: true,
5970 ..CapabilityPolicy::default()
5971 };
5972
5973 let report = report_for(
5974 &policy,
5975 ContainmentClass::Advisory,
5976 EnforcementTier::KernelSealed,
5977 None,
5978 None,
5979 Some(6),
5980 None,
5981 Vec::new(),
5982 Vec::new(),
5983 Vec::new(),
5984 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
5985 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
5986 EnforcementTier::KernelSealed,
5987 ContainmentClass::Advisory,
5988 )),
5989 IoMaxReport::default(),
5990 );
5991
5992 assert_eq!(report.enforcement_tier, "landlock+seccomp");
5993 assert_eq!(report.achieved_containment, ContainmentClass::Advisory);
5994 assert_eq!(
5995 report.filesystem_boundary.restriction,
5996 FilesystemRestriction::Enforced
5997 );
5998 assert_eq!(report.filesystem_boundary.not_protected.len(), 2);
5999 assert!(report.render().contains("Not protected here"));
6000 }
6001
6002 #[test]
6005 fn the_restriction_follows_the_tier_the_session_would_install() {
6006 for tier in ALL_TIERS {
6007 for declared in [
6008 ContainmentClass::Advisory,
6009 ContainmentClass::Scoped,
6010 ContainmentClass::Sealed,
6011 ] {
6012 let report = boundary_report(declared, *tier);
6013 let expected = match crate::sandbox::applied_tier(*tier, declared) {
6014 EnforcementTier::KernelSealed => FilesystemRestriction::Absent,
6015 EnforcementTier::KernelFull => FilesystemRestriction::Enforced,
6016 EnforcementTier::KernelSeccompOnly | EnforcementTier::EnvironmentOnly => {
6017 FilesystemRestriction::Advisory
6018 }
6019 EnforcementTier::Unconfined => FilesystemRestriction::None,
6020 };
6021 assert_eq!(
6022 report.filesystem_boundary.restriction, expected,
6023 "{tier:?} / {declared:?}"
6024 );
6025 assert_eq!(
6026 report.filesystem_boundary.not_protected.is_empty(),
6027 expected == FilesystemRestriction::Absent,
6028 "{tier:?} / {declared:?}"
6029 );
6030 let wire = match expected {
6031 FilesystemRestriction::Advisory => "advisory",
6032 FilesystemRestriction::Enforced => "enforced",
6033 FilesystemRestriction::Absent => "absent",
6034 FilesystemRestriction::None => "none",
6035 };
6036 assert_eq!(
6037 serde_json::to_value(report.filesystem_boundary.restriction).unwrap(),
6038 serde_json::Value::String(wire.to_string())
6039 );
6040 }
6041 }
6042 }
6043
6044 #[test]
6057 fn the_reported_restriction_matches_what_a_subprocess_actually_reaches() {
6058 if crate::network_namespace::skip_without_egress_namespace(
6059 "the_reported_restriction_matches_what_a_subprocess_actually_reaches",
6060 ) {
6061 return;
6062 }
6063 if crate::sandbox::find_on_path("bash").is_none() {
6064 eprintln!(
6065 "[SKIP-HOST] the_reported_restriction_matches_what_a_subprocess_actually_reaches: \
6066 no bash on PATH"
6067 );
6068 return;
6069 }
6070 let outside = tempfile::tempdir().expect("a temp directory outside the workdir");
6071 let secret = outside.path().join("host-file");
6072 std::fs::write(&secret, "PROBE-CONTENTS\n").unwrap();
6073 let secret = secret.to_str().unwrap().to_string();
6074
6075 for declared in [ContainmentClass::Advisory, ContainmentClass::Sealed] {
6076 let workdir = tempfile::tempdir().unwrap();
6077 let policy = CapabilityPolicy {
6078 shell_allow: vec!["bash".to_string()],
6079 ..CapabilityPolicy::default()
6080 };
6081 let probe = crate::sandbox::HostProbe::probe();
6082 let host_tier = probe.tier();
6083 let runtime = tempfile::tempdir().unwrap();
6084 let enforcement =
6085 crate::sandbox::ShellEnforcement::resolve_declared(&policy, declared, probe)
6086 .unwrap()
6087 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
6088 let report = report_for(
6089 &policy,
6090 declared,
6091 host_tier,
6092 None,
6093 None,
6094 None,
6095 None,
6096 Vec::new(),
6097 Vec::new(),
6098 Vec::new(),
6099 &crate::runtime_dir::RuntimeDirPlacement::outside_for_test(),
6100 crate::temp_dir::tier_temp_mapping(crate::sandbox::applied_tier(
6101 host_tier, declared,
6102 )),
6103 IoMaxReport::default(),
6104 );
6105
6106 let command = format!(
6107 "if read -r line < '{secret}' 2>/dev/null; then echo \"read=$line\"; \
6108 else echo read=refused; fi; \
6109 if [[ -e '{secret}' ]]; then echo exists=yes; else echo exists=no; fi"
6110 );
6111 let result = crate::shell::execute_shell(
6112 "bash",
6113 &["-c", &command],
6114 &[],
6115 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
6116 &policy,
6117 &enforcement,
6118 )
6119 .unwrap_or_else(|error| panic!("declared {declared:?}: {error}"));
6120 let seen = result.stdout;
6121
6122 match report.filesystem_boundary.restriction {
6123 FilesystemRestriction::Advisory => assert!(
6124 seen.contains("read=PROBE-CONTENTS"),
6125 "reported advisory, so nothing denies the read: {seen}"
6126 ),
6127 FilesystemRestriction::Enforced => assert!(
6128 seen.contains("read=refused") && seen.contains("exists=yes"),
6129 "reported enforced, so the read is denied and the path still exists: {seen}"
6130 ),
6131 FilesystemRestriction::Absent => assert!(
6132 seen.contains("read=refused") && seen.contains("exists=no"),
6133 "reported absent, so the path is not in the composed root: {seen}"
6134 ),
6135 FilesystemRestriction::None => assert!(
6136 seen.contains("read=PROBE-CONTENTS"),
6137 "reported none, so nothing denies the read: {seen}"
6138 ),
6139 }
6140 }
6141 }
6142
6143 fn capability_policy_field_names(source: &str) -> Vec<String> {
6145 source
6146 .split_once("pub struct CapabilityPolicy {")
6147 .expect("types.rs declares CapabilityPolicy")
6148 .1
6149 .split_once("\n}")
6150 .expect("CapabilityPolicy's declaration is closed at column 0")
6151 .0
6152 .lines()
6153 .filter_map(|line| {
6154 line.strip_prefix(" pub ")
6155 .and_then(|rest| rest.split_once(':'))
6156 .map(|(name, _)| name.to_string())
6157 })
6158 .collect()
6159 }
6160
6161 #[test]
6166 fn the_temp_dir_report_follows_the_per_tier_table() {
6167 use crate::temp_dir::temp_mapping;
6168
6169 let placement = RuntimeDirPlacement::outside_for_test();
6170 let folder = "/home/test/.murmur/records/work/<session-id>/tmp";
6171 let tmp = crate::temp_dir::canonical_tmp();
6172 let elsewhere = std::path::Path::new("/work");
6173 let rows = [
6175 (
6176 EnforcementTier::KernelSealed,
6177 ContainmentClass::Sealed,
6178 true,
6179 elsewhere,
6180 true,
6181 Some("composed-root"),
6182 ),
6183 (
6184 EnforcementTier::KernelSealed,
6185 ContainmentClass::Scoped,
6186 true,
6187 elsewhere,
6188 true,
6189 Some("mount-namespace"),
6190 ),
6191 (
6192 EnforcementTier::KernelSealed,
6193 ContainmentClass::Advisory,
6194 true,
6195 elsewhere,
6196 true,
6197 Some("mount-namespace"),
6198 ),
6199 (
6200 EnforcementTier::KernelFull,
6201 ContainmentClass::Scoped,
6202 true,
6203 elsewhere,
6204 true,
6205 Some("mount-namespace"),
6206 ),
6207 (
6208 EnforcementTier::KernelFull,
6209 ContainmentClass::Scoped,
6210 false,
6211 elsewhere,
6212 false,
6213 None,
6214 ),
6215 (
6216 EnforcementTier::KernelSeccompOnly,
6217 ContainmentClass::Advisory,
6218 true,
6219 elsewhere,
6220 true,
6221 Some("mount-namespace"),
6222 ),
6223 (
6224 EnforcementTier::KernelSeccompOnly,
6225 ContainmentClass::Advisory,
6226 false,
6227 elsewhere,
6228 false,
6229 None,
6230 ),
6231 (
6232 EnforcementTier::EnvironmentOnly,
6233 ContainmentClass::Advisory,
6234 false,
6235 elsewhere,
6236 false,
6237 None,
6238 ),
6239 (
6240 EnforcementTier::KernelFull,
6241 ContainmentClass::Scoped,
6242 true,
6243 tmp.as_path(),
6244 false,
6245 None,
6246 ),
6247 (
6248 EnforcementTier::KernelSeccompOnly,
6249 ContainmentClass::Advisory,
6250 true,
6251 tmp.as_path(),
6252 false,
6253 None,
6254 ),
6255 ];
6256 for (tier, declared, usable, workdir, mapped, mapped_by) in rows {
6257 let applied = crate::sandbox::applied_tier(tier, declared);
6258 let mapping = temp_mapping(applied, usable, workdir);
6259 let report = temp_dir_report(mapping, &placement);
6260 let row = format!(
6261 "{tier:?} {declared:?} usable={usable} workdir={}",
6262 workdir.display()
6263 );
6264 assert_eq!(report.path, folder, "{row}");
6265 assert_eq!(report.tmp_mapped, mapped, "{row}");
6266 assert_eq!(report.mapped_by, mapped_by, "{row}");
6267 assert_eq!(report.tmpdir, if mapped { "/tmp" } else { folder }, "{row}");
6268 assert_eq!(
6269 report
6270 .not_mapped_because
6271 .as_deref()
6272 .is_some_and(|reason| !reason.is_empty()),
6273 !mapped,
6274 "{row}"
6275 );
6276
6277 let scope = report_for(
6278 &CapabilityPolicy::default(),
6279 declared,
6280 tier,
6281 None,
6282 None,
6283 None,
6284 None,
6285 Vec::new(),
6286 Vec::new(),
6287 Vec::new(),
6288 &placement,
6289 mapping,
6290 IoMaxReport::default(),
6291 );
6292 assert_eq!(scope.temp_dir, report, "{row}");
6293 }
6294
6295 let reason = |tier, usable, workdir| {
6296 temp_dir_report(temp_mapping(tier, usable, workdir), &placement)
6297 .not_mapped_because
6298 .unwrap()
6299 };
6300 assert!(reason(EnforcementTier::EnvironmentOnly, false, elsewhere)
6301 .starts_with("this host has no kernel containment for capsule commands"));
6302 assert!(reason(EnforcementTier::KernelSeccompOnly, false, elsewhere)
6303 .contains("neither a private mount namespace nor Landlock"));
6304 assert!(reason(EnforcementTier::KernelFull, false, elsewhere)
6305 .contains("a literal /tmp path is refused"));
6306 assert!(reason(EnforcementTier::KernelFull, true, tmp.as_path())
6307 .starts_with("the workdir is /tmp itself"));
6308 }
6309
6310 #[test]
6313 fn the_temp_folder_block_renders_after_the_records_directory() {
6314 use crate::temp_dir::{NotMappedReason, TempMapping};
6315
6316 let placement = RuntimeDirPlacement::outside_for_test();
6317 let mapped = temp_dir_report(TempMapping::MountNamespace, &placement);
6318 assert_eq!(
6319 render_temp_dir(&mapped),
6320 "Temporary folder\n path: /home/test/.murmur/records/work/<session-id>/tmp\n \
6321 TMPDIR: /tmp\n /tmp: mapped (mount-namespace)\n"
6322 );
6323 let unmapped = temp_dir_report(
6324 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
6325 &placement,
6326 );
6327 assert_eq!(
6328 render_temp_dir(&unmapped),
6329 format!(
6330 "Temporary folder\n path: {path}\n TMPDIR: {path}\n /tmp: not \
6331 mapped — {}\n",
6332 NotMappedReason::NoMountNamespace.describe(),
6333 path = "/home/test/.murmur/records/work/<session-id>/tmp"
6334 )
6335 );
6336 assert_eq!(
6337 serde_json::to_value(&mapped).unwrap(),
6338 serde_json::json!({
6339 "path": "/home/test/.murmur/records/work/<session-id>/tmp",
6340 "tmpdir": "/tmp",
6341 "tmp_mapped": true,
6342 "mapped_by": "mount-namespace",
6343 "not_mapped_because": null,
6344 })
6345 );
6346
6347 let scope = report_for(
6348 &CapabilityPolicy::default(),
6349 ContainmentClass::Scoped,
6350 EnforcementTier::KernelFull,
6351 None,
6352 None,
6353 None,
6354 None,
6355 Vec::new(),
6356 Vec::new(),
6357 Vec::new(),
6358 &placement,
6359 TempMapping::MountNamespace,
6360 IoMaxReport::default(),
6361 );
6362 let rendered = scope.render();
6363 let records = rendered.find("Records directory\n").expect("records block");
6364 let temp = rendered.find("Temporary folder\n").expect("temp block");
6365 assert!(records < temp, "{rendered}");
6366 assert!(rendered.contains(&render_temp_dir(&mapped)), "{rendered}");
6367 assert!(serde_json::to_value(&scope)
6368 .unwrap()
6369 .get("temp_dir")
6370 .is_some());
6371 }
6372}