1use std::collections::BTreeSet;
33use std::fmt;
34use std::path::{Component, Path};
35
36use murmur_artifact::{ContainmentClass, RuntimeManifest};
37
38use crate::network_policy::{validate_filesystem_scope, NetworkAllowRule};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum EnvelopeAxis {
49 NetworkAllow,
50 UnixSockets,
51 PeerFetchAllow,
52 ShellAllow,
53 SpawnAllow,
54 EnvAllow,
55 FilesystemScope,
56 WorkdirExec,
57 StateStore,
58 InstallSkill,
59 InstallTool,
60 InstallFrom,
61 Containment,
62}
63
64impl EnvelopeAxis {
65 pub fn manifest_key(self) -> &'static str {
68 match self {
69 Self::NetworkAllow => "capabilities.network.allow",
70 Self::UnixSockets => "capabilities.network.unix_sockets",
71 Self::PeerFetchAllow => "capabilities.peer_fetch.allow",
72 Self::ShellAllow => "capabilities.shell.allow",
73 Self::SpawnAllow => "capabilities.spawn.allow",
74 Self::EnvAllow => "capabilities.env.allow",
75 Self::FilesystemScope => "capabilities.filesystem.scope",
76 Self::WorkdirExec => "capabilities.filesystem.workdir_exec",
77 Self::StateStore => "capabilities.state.store",
78 Self::InstallSkill => "capabilities.install.skill",
79 Self::InstallTool => "capabilities.install.tool",
80 Self::InstallFrom => "capabilities.install.from",
81 Self::Containment => "capabilities.containment",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EnvelopeViolation {
93 pub axis: EnvelopeAxis,
94 pub entry: String,
102 pub parent_entry: Option<String>,
107}
108
109impl fmt::Display for EnvelopeViolation {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 let key = self.axis.manifest_key();
112 let parent = self.parent_entry.as_deref().unwrap_or_default();
113 match self.axis {
114 EnvelopeAxis::Containment => write!(
115 f,
116 "{key}: the child declares containment '{}', below its parent's floor '{parent}' \
117 — a spawned capsule's containment floor may only rise",
118 self.entry,
119 ),
120 EnvelopeAxis::FilesystemScope if self.entry.is_empty() => write!(
121 f,
122 "{key}: the child declares no scope and would reach the whole workdir, which its \
123 parent's scope '{parent}' does not cover — a spawned capsule can never hold more \
124 capability than the capsule that spawned it",
125 ),
126 EnvelopeAxis::FilesystemScope => write!(
127 f,
128 "{key}: the child declares '{}', which its parent's scope '{parent}' does not \
129 cover — a spawned capsule can never hold more capability than the capsule that \
130 spawned it",
131 self.entry,
132 ),
133 _ => write!(
134 f,
135 "{key}: the child declares '{}', which its parent does not hold — a spawned \
136 capsule can never hold more capability than the capsule that spawned it",
137 self.entry,
138 ),
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default)]
152pub struct SpawnEnvelope {
153 pub network_allow: Vec<String>,
158 pub unix_sockets_allowed: bool,
159 pub peer_fetch_allow: Vec<String>,
160 pub shell_allow: Vec<String>,
161 pub spawn_allow: Vec<String>,
166 pub env_allow: Vec<String>,
167 pub filesystem_scope: Option<String>,
168 pub workdir_exec_allowed: bool,
169 pub state_stores: BTreeSet<String>,
176 pub install_skill: Vec<String>,
180 pub install_tool: Vec<String>,
182 pub install_from_sources: bool,
185 pub containment_floor: ContainmentClass,
188}
189
190impl SpawnEnvelope {
191 pub fn from_runtime_manifest(manifest: &RuntimeManifest) -> Self {
198 let policy = crate::types::capability_policy_from_runtime_manifest(manifest);
199 Self {
200 network_allow: policy.network_allow,
201 unix_sockets_allowed: policy.unix_sockets_allowed,
202 peer_fetch_allow: policy.peer_fetch_allow,
203 shell_allow: policy.shell_allow,
204 spawn_allow: policy.spawn_allow,
205 env_allow: policy.env_allow,
206 filesystem_scope: policy.filesystem_scope,
207 workdir_exec_allowed: policy.workdir_exec_allowed,
208 state_stores: declared_state_stores(manifest),
209 install_skill: policy.install_skill,
210 install_tool: policy.install_tool,
211 install_from_sources: policy.install_from_sources,
212 containment_floor: policy.containment_floor,
213 }
214 }
215
216 pub fn contains(&self, child: &Self) -> Result<(), EnvelopeViolation> {
227 if let Some(entry) = first_uncovered_host(&self.network_allow, &child.network_allow) {
228 return Err(violation(EnvelopeAxis::NetworkAllow, entry));
229 }
230 if child.unix_sockets_allowed && !self.unix_sockets_allowed {
231 return Err(violation(EnvelopeAxis::UnixSockets, "true".to_string()));
232 }
233 if let Some(entry) = first_uncovered_host(&self.peer_fetch_allow, &child.peer_fetch_allow) {
234 return Err(violation(EnvelopeAxis::PeerFetchAllow, entry));
235 }
236 if let Some(entry) = first_absent(&self.shell_allow, &child.shell_allow) {
237 return Err(violation(EnvelopeAxis::ShellAllow, entry));
238 }
239 if let Some(entry) = first_absent(&self.spawn_allow, &child.spawn_allow) {
240 return Err(violation(EnvelopeAxis::SpawnAllow, entry));
241 }
242 if let Some(entry) = first_absent(&self.env_allow, &child.env_allow) {
243 return Err(violation(EnvelopeAxis::EnvAllow, entry));
244 }
245 if let Some(parent_scope) = self.filesystem_scope.as_deref() {
249 let child_scope = child.filesystem_scope.as_deref().unwrap_or_default();
250 if !scope_covers(parent_scope, child_scope) {
251 return Err(EnvelopeViolation {
252 axis: EnvelopeAxis::FilesystemScope,
253 entry: child_scope.to_string(),
254 parent_entry: Some(parent_scope.to_string()),
255 });
256 }
257 }
258 if child.workdir_exec_allowed && !self.workdir_exec_allowed {
259 return Err(violation(EnvelopeAxis::WorkdirExec, "true".to_string()));
260 }
261 if let Some(store) = child
262 .state_stores
263 .difference(&self.state_stores)
264 .next()
265 .cloned()
266 {
267 return Err(violation(EnvelopeAxis::StateStore, store));
268 }
269 if let Some(entry) = first_uncovered_install(&self.install_skill, &child.install_skill) {
270 return Err(violation(EnvelopeAxis::InstallSkill, entry));
271 }
272 if let Some(entry) = first_uncovered_install(&self.install_tool, &child.install_tool) {
273 return Err(violation(EnvelopeAxis::InstallTool, entry));
274 }
275 if child.install_from_sources && !self.install_from_sources {
276 return Err(violation(EnvelopeAxis::InstallFrom, "sources".to_string()));
277 }
278 if child.containment_floor < self.containment_floor {
279 return Err(EnvelopeViolation {
280 axis: EnvelopeAxis::Containment,
281 entry: child.containment_floor.to_string(),
282 parent_entry: Some(self.containment_floor.to_string()),
283 });
284 }
285 Ok(())
286 }
287}
288
289fn violation(axis: EnvelopeAxis, entry: String) -> EnvelopeViolation {
290 EnvelopeViolation {
291 axis,
292 entry,
293 parent_entry: None,
294 }
295}
296
297fn declared_state_stores(manifest: &RuntimeManifest) -> BTreeSet<String> {
305 manifest
306 .artifacts
307 .iter()
308 .filter_map(|artifact| artifact.capabilities.as_ref())
309 .filter_map(|capabilities| capabilities.state.as_ref())
310 .map(|state| crate::state_store::resolve_store_name(state, &manifest.name))
311 .collect()
312}
313
314fn first_uncovered_host(ceiling: &[String], candidate: &[String]) -> Option<String> {
322 let ceiling_rules: Vec<NetworkAllowRule> = ceiling
323 .iter()
324 .filter_map(|entry| NetworkAllowRule::parse(entry).ok())
325 .collect();
326 candidate
327 .iter()
328 .find(|entry| match NetworkAllowRule::parse(entry) {
329 Ok(rule) => !ceiling_rules
330 .iter()
331 .any(|ceiling_rule| ceiling_rule.covers(&rule)),
332 Err(_) => true,
333 })
334 .cloned()
335}
336
337fn first_absent(ceiling: &[String], candidate: &[String]) -> Option<String> {
341 candidate
342 .iter()
343 .find(|entry| !ceiling.contains(entry))
344 .cloned()
345}
346
347fn first_uncovered_install(ceiling: &[String], candidate: &[String]) -> Option<String> {
351 candidate
352 .iter()
353 .find(|entry| {
354 !ceiling
355 .iter()
356 .any(|parent| murmur_artifact::install_pattern::covers(parent, entry))
357 })
358 .cloned()
359}
360
361fn scope_covers(parent: &str, child: &str) -> bool {
372 match (scope_components(parent), scope_components(child)) {
373 (Some(parent), Some(child)) => child.starts_with(&parent),
374 _ => false,
375 }
376}
377
378fn scope_components(scope: &str) -> Option<Vec<String>> {
379 if scope.is_empty() {
380 return Some(Vec::new());
381 }
382 validate_filesystem_scope(scope).ok()?;
383 let mut components = Vec::new();
384 for component in Path::new(scope).components() {
385 match component {
386 Component::CurDir => {}
387 Component::Normal(part) => components.push(part.to_string_lossy().into_owned()),
388 Component::ParentDir => {
391 components.pop();
392 }
393 Component::Prefix(_) | Component::RootDir => return None,
394 }
395 }
396 Some(components)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::network_policy::{parse_network_allow_rules, ToolCapabilityGrant};
403
404 const COVERAGE_CASES: &[(&str, &str, bool)] = &[
411 ("example.com", "https://example.com", true),
412 ("https://example.com", "example.com", false),
413 ("example.com", "example.com", true),
414 ("https://example.com", "https://example.com", true),
415 ("https://example.com", "http://example.com", false),
416 ("example.com:8443", "example.com:8443", true),
417 ("example.com:8443", "example.com:9000", false),
418 ("example.com", "other.example.com", false),
419 ];
420
421 fn envelope(capabilities_yaml: &str) -> SpawnEnvelope {
422 manifest_envelope("cap", capabilities_yaml)
423 }
424
425 fn manifest_envelope(name: &str, capabilities_yaml: &str) -> SpawnEnvelope {
426 let manifest = RuntimeManifest::from_yaml_str(&format!(
427 "name: {name}\nversion: 0.1.0\n{capabilities_yaml}"
428 ))
429 .expect("manifest fixture must parse");
430 SpawnEnvelope::from_runtime_manifest(&manifest)
431 }
432
433 fn network_envelope(entries: &[&str]) -> SpawnEnvelope {
434 SpawnEnvelope {
435 network_allow: entries.iter().map(|entry| entry.to_string()).collect(),
436 ..SpawnEnvelope::default()
437 }
438 }
439
440 #[test]
445 fn the_narrowing_path_and_the_spawn_path_share_one_subset_rule() {
446 for (ceiling, candidate, expected) in COVERAGE_CASES {
447 let ceiling_rules = parse_network_allow_rules(&[ceiling.to_string()])
448 .expect("ceiling fixture must parse");
449 let capabilities = murmur_artifact::Capabilities {
450 network: Some(murmur_artifact::NetworkCapabilities {
451 allow: vec![candidate.to_string()],
452 unix_sockets: false,
453 }),
454 peer_fetch: None,
455 filesystem: None,
456 shell: None,
457 spawn: None,
458 env: None,
459 limits: None,
460 resources: None,
461 state: None,
462 task_io: None,
463 conversation: None,
464 plan: None,
465 install: None,
466 harness: None,
467 containment: None,
468 };
469 let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
470 .expect("candidate fixture must parse");
471 let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
472
473 let spawn_verdict = network_envelope(&[ceiling])
474 .contains(&network_envelope(&[candidate]))
475 .is_ok();
476
477 assert_eq!(
478 narrowing_verdict, spawn_verdict,
479 "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
480 );
481 assert_eq!(
482 spawn_verdict, *expected,
483 "'{ceiling}' vs '{candidate}': expected covered={expected}",
484 );
485 }
486 }
487
488 #[test]
489 fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
490 let parent = envelope("capabilities:\n network:\n allow: [registry.internal]\n");
491 let child = envelope("capabilities:\n network:\n allow: [api.example.com]\n");
492
493 let violation = parent.contains(&child).unwrap_err();
494
495 assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
496 assert_eq!(violation.entry, "api.example.com");
497 let message = violation.to_string();
498 assert!(message.contains("capabilities.network.allow"), "{message}");
499 assert!(message.contains("api.example.com"), "{message}");
500 assert!(message.contains("its parent does not hold"), "{message}");
501 }
502
503 #[test]
506 fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
507 let parent_yaml = "capabilities:\n \
508 network:\n allow: [registry.internal]\n unix_sockets: false\n \
509 peer_fetch:\n allow: [peer.internal]\n \
510 shell:\n allow: [git]\n \
511 spawn:\n allow: [local/worker-a]\n \
512 env:\n allow: [HOME]\n \
513 filesystem:\n scope: data\n workdir_exec: false\n \
514 install:\n skill: [\"local/style-*\"]\n tool: [local/jq]\n";
515 let parent = envelope(parent_yaml);
516
517 let cases: &[(&str, EnvelopeAxis, &str)] = &[
518 (
519 "capabilities:\n network:\n allow: [api.example.com]\n",
520 EnvelopeAxis::NetworkAllow,
521 "api.example.com",
522 ),
523 (
524 "capabilities:\n network:\n unix_sockets: true\n",
525 EnvelopeAxis::UnixSockets,
526 "true",
527 ),
528 (
529 "capabilities:\n peer_fetch:\n allow: [other.internal]\n",
530 EnvelopeAxis::PeerFetchAllow,
531 "other.internal",
532 ),
533 (
534 "capabilities:\n shell:\n allow: [curl]\n",
535 EnvelopeAxis::ShellAllow,
536 "curl",
537 ),
538 (
539 "capabilities:\n spawn:\n allow: [local/worker-b]\n",
540 EnvelopeAxis::SpawnAllow,
541 "local/worker-b",
542 ),
543 (
544 "capabilities:\n env:\n allow: [GITHUB_TOKEN]\n",
545 EnvelopeAxis::EnvAllow,
546 "GITHUB_TOKEN",
547 ),
548 (
549 "capabilities:\n filesystem:\n scope: other\n",
550 EnvelopeAxis::FilesystemScope,
551 "other",
552 ),
553 (
554 "capabilities:\n filesystem:\n scope: data/../other\n",
555 EnvelopeAxis::FilesystemScope,
556 "data/../other",
557 ),
558 (
559 "capabilities:\n filesystem:\n scope: data\n workdir_exec: true\n",
560 EnvelopeAxis::WorkdirExec,
561 "true",
562 ),
563 (
564 "capabilities:\n filesystem:\n scope: data\n install:\n skill: [\"*\"]\n",
565 EnvelopeAxis::InstallSkill,
566 "*",
567 ),
568 (
569 "capabilities:\n filesystem:\n scope: data\n install:\n tool: [local/yq]\n",
570 EnvelopeAxis::InstallTool,
571 "local/yq",
572 ),
573 ];
574
575 for (child_yaml, axis, entry) in cases {
576 let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
577 assert_eq!(violation.axis, *axis, "child: {child_yaml}");
578 assert_eq!(violation.entry, *entry, "child: {child_yaml}");
579 let message = violation.to_string();
580 assert!(message.contains(axis.manifest_key()), "{message}");
581 assert!(message.contains(entry), "{message}");
582 }
583 }
584
585 #[test]
588 fn a_child_install_entry_fits_only_under_a_covering_parent_entry() {
589 let parent = envelope("capabilities:\n install:\n skill: [\"local/style-*\"]\n");
590
591 for (skill, covered) in [
592 ("local/style-rust", true),
593 ("\"local/style-rust-*\"", true),
594 ("\"local/style-*\"", true),
595 ("\"local/style*\"", false),
596 ("\"ana/style-rust\"", false),
597 ("\"*\"", false),
598 ("local/stylist", false),
599 ] {
600 let child = envelope(&format!(
601 "capabilities:\n install:\n skill: [{skill}]\n"
602 ));
603 let verdict = parent.contains(&child);
604 assert_eq!(verdict.is_ok(), covered, "child skill {skill}: {verdict:?}");
605 if let Err(violation) = verdict {
606 assert_eq!(violation.axis, EnvelopeAxis::InstallSkill);
607 assert_eq!(violation.entry, skill.trim_matches('"'));
608 }
609 }
610
611 let child_tool = envelope("capabilities:\n install:\n tool: [local/style-rust]\n");
612 let violation = parent.contains(&child_tool).unwrap_err();
613 assert_eq!(violation.axis, EnvelopeAxis::InstallTool);
614 assert_eq!(violation.entry, "local/style-rust");
615 }
616
617 #[test]
620 fn a_child_fetching_from_sources_is_refused_under_a_parent_that_does_not() {
621 let stores = envelope("capabilities:\n install:\n tool: [\"*\"]\n");
622 let sources =
623 envelope("capabilities:\n install:\n tool: [\"*\"]\n from: [stores, sources]\n");
624 assert!(!stores.install_from_sources && sources.install_from_sources);
625
626 let violation = stores.contains(&sources).unwrap_err();
627 assert_eq!(violation.axis, EnvelopeAxis::InstallFrom);
628 assert_eq!(violation.entry, "sources");
629 assert!(
630 violation.to_string().starts_with(
631 "capabilities.install.from: the child declares 'sources', which its parent does \
632 not hold"
633 ),
634 "{violation}"
635 );
636 assert_eq!(sources.contains(&stores), Ok(()));
637 assert_eq!(sources.contains(&sources), Ok(()));
638 }
639
640 #[test]
642 fn a_child_declaring_no_install_is_within_any_parent() {
643 let parent = envelope("capabilities:\n install:\n skill: [\"*\"]\n tool: [\"*\"]\n");
644 let child = envelope("capabilities:\n env:\n allow: []\n");
645 assert!(child.install_skill.is_empty() && child.install_tool.is_empty());
646 assert_eq!(parent.contains(&child), Ok(()));
647 }
648
649 #[test]
652 fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
653 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
654 let child = envelope("capabilities:\n env:\n allow: []\n");
655
656 let violation = parent.contains(&child).unwrap_err();
657
658 assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
659 assert!(violation.entry.is_empty());
660 let message = violation.to_string();
661 assert!(
662 message.contains("capabilities.filesystem.scope"),
663 "{message}"
664 );
665 assert!(message.contains("declares no scope"), "{message}");
666 assert!(message.contains("'data'"), "{message}");
667 }
668
669 #[test]
670 fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
671 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
672
673 for scope in ["data", "data/in", "data/in/../in"] {
674 let child = envelope(&format!(
675 "capabilities:\n filesystem:\n scope: {scope}\n"
676 ));
677 assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
678 }
679 }
680
681 #[test]
683 fn an_unscoped_parent_covers_any_child_scope() {
684 let parent = envelope("capabilities:\n env:\n allow: []\n");
685 let child = envelope("capabilities:\n filesystem:\n scope: data\n");
686
687 assert!(parent.contains(&child).is_ok());
688 }
689
690 #[test]
693 fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
694 let with_state = |name: &str, store: &str| {
695 manifest_envelope(
696 name,
697 &format!(
698 "artifacts:\n - name: local/writer\n version: 0.1.0\n runtime: tool\n \
699 capabilities:\n state:\n store: {store}\n"
700 ),
701 )
702 };
703 let parent = with_state("parent", "parent-notes");
704 let child = with_state("child", "child-notes");
705
706 let violation = parent.contains(&child).unwrap_err();
707
708 assert_eq!(violation.axis, EnvelopeAxis::StateStore);
709 assert_eq!(violation.entry, "child-notes");
710 assert!(violation.to_string().contains("capabilities.state.store"));
711
712 let shared = with_state("child", "parent-notes");
713 assert!(parent.contains(&shared).is_ok());
714 }
715
716 #[test]
719 fn an_undeclared_store_resolves_to_the_capsule_name() {
720 let envelope = manifest_envelope(
721 "notes-capsule",
722 "artifacts:\n - name: local/writer\n version: 0.1.0\n runtime: tool\n \
723 capabilities:\n state: {}\n",
724 );
725
726 assert_eq!(
727 envelope.state_stores.iter().collect::<Vec<_>>(),
728 vec!["notes-capsule"]
729 );
730 }
731
732 #[test]
735 fn a_containment_floor_may_rise_but_never_fall() {
736 let parent = envelope("capabilities:\n containment: scoped\n");
737
738 for raised in ["scoped", "sealed"] {
739 let child = envelope(&format!("capabilities:\n containment: {raised}\n"));
740 assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
741 }
742
743 let child = envelope("capabilities:\n containment: advisory\n");
744 let violation = parent.contains(&child).unwrap_err();
745
746 assert_eq!(violation.axis, EnvelopeAxis::Containment);
747 assert_eq!(violation.entry, "advisory");
748 let message = violation.to_string();
749 assert!(message.contains("capabilities.containment"), "{message}");
750 assert!(message.contains("advisory"), "{message}");
751 assert!(message.contains("may only rise"), "{message}");
752 }
753
754 #[test]
757 fn a_refusal_names_the_key_the_declaration_and_the_rule() {
758 let list_axis = envelope("capabilities:\n network:\n allow: [registry.internal]\n")
759 .contains(&envelope(
760 "capabilities:\n network:\n allow: [api.example.com]\n",
761 ))
762 .unwrap_err();
763 assert_eq!(
764 list_axis.to_string(),
765 "capabilities.network.allow: the child declares 'api.example.com', which its parent \
766 does not hold — a spawned capsule can never hold more capability than the capsule \
767 that spawned it",
768 );
769
770 let absent_scope = envelope("capabilities:\n filesystem:\n scope: data\n")
771 .contains(&envelope("artifacts: []\n"))
772 .unwrap_err();
773 assert_eq!(
774 absent_scope.to_string(),
775 "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
776 workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
777 hold more capability than the capsule that spawned it",
778 );
779
780 let floor = envelope("capabilities:\n containment: scoped\n")
781 .contains(&envelope("capabilities:\n containment: advisory\n"))
782 .unwrap_err();
783 assert_eq!(
784 floor.to_string(),
785 "capabilities.containment: the child declares containment 'advisory', below its \
786 parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
787 );
788 }
789
790 #[test]
793 fn a_silent_child_is_within_any_parent() {
794 let parent = envelope(
795 "capabilities:\n network:\n allow: [registry.internal]\n \
796 shell:\n allow: [git]\n",
797 );
798
799 assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
800 assert!(envelope("artifacts: []\n")
801 .contains(&envelope("artifacts: []\n"))
802 .is_ok());
803 }
804}