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 containment: None,
467 };
468 let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
469 .expect("candidate fixture must parse");
470 let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
471
472 let spawn_verdict = network_envelope(&[ceiling])
473 .contains(&network_envelope(&[candidate]))
474 .is_ok();
475
476 assert_eq!(
477 narrowing_verdict, spawn_verdict,
478 "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
479 );
480 assert_eq!(
481 spawn_verdict, *expected,
482 "'{ceiling}' vs '{candidate}': expected covered={expected}",
483 );
484 }
485 }
486
487 #[test]
488 fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
489 let parent = envelope("capabilities:\n network:\n allow: [registry.internal]\n");
490 let child = envelope("capabilities:\n network:\n allow: [api.example.com]\n");
491
492 let violation = parent.contains(&child).unwrap_err();
493
494 assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
495 assert_eq!(violation.entry, "api.example.com");
496 let message = violation.to_string();
497 assert!(message.contains("capabilities.network.allow"), "{message}");
498 assert!(message.contains("api.example.com"), "{message}");
499 assert!(message.contains("its parent does not hold"), "{message}");
500 }
501
502 #[test]
505 fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
506 let parent_yaml = "capabilities:\n \
507 network:\n allow: [registry.internal]\n unix_sockets: false\n \
508 peer_fetch:\n allow: [peer.internal]\n \
509 shell:\n allow: [git]\n \
510 spawn:\n allow: [local/worker-a]\n \
511 env:\n allow: [HOME]\n \
512 filesystem:\n scope: data\n workdir_exec: false\n \
513 install:\n skill: [\"local/style-*\"]\n tool: [local/jq]\n";
514 let parent = envelope(parent_yaml);
515
516 let cases: &[(&str, EnvelopeAxis, &str)] = &[
517 (
518 "capabilities:\n network:\n allow: [api.example.com]\n",
519 EnvelopeAxis::NetworkAllow,
520 "api.example.com",
521 ),
522 (
523 "capabilities:\n network:\n unix_sockets: true\n",
524 EnvelopeAxis::UnixSockets,
525 "true",
526 ),
527 (
528 "capabilities:\n peer_fetch:\n allow: [other.internal]\n",
529 EnvelopeAxis::PeerFetchAllow,
530 "other.internal",
531 ),
532 (
533 "capabilities:\n shell:\n allow: [curl]\n",
534 EnvelopeAxis::ShellAllow,
535 "curl",
536 ),
537 (
538 "capabilities:\n spawn:\n allow: [local/worker-b]\n",
539 EnvelopeAxis::SpawnAllow,
540 "local/worker-b",
541 ),
542 (
543 "capabilities:\n env:\n allow: [GITHUB_TOKEN]\n",
544 EnvelopeAxis::EnvAllow,
545 "GITHUB_TOKEN",
546 ),
547 (
548 "capabilities:\n filesystem:\n scope: other\n",
549 EnvelopeAxis::FilesystemScope,
550 "other",
551 ),
552 (
553 "capabilities:\n filesystem:\n scope: data/../other\n",
554 EnvelopeAxis::FilesystemScope,
555 "data/../other",
556 ),
557 (
558 "capabilities:\n filesystem:\n scope: data\n workdir_exec: true\n",
559 EnvelopeAxis::WorkdirExec,
560 "true",
561 ),
562 (
563 "capabilities:\n filesystem:\n scope: data\n install:\n skill: [\"*\"]\n",
564 EnvelopeAxis::InstallSkill,
565 "*",
566 ),
567 (
568 "capabilities:\n filesystem:\n scope: data\n install:\n tool: [local/yq]\n",
569 EnvelopeAxis::InstallTool,
570 "local/yq",
571 ),
572 ];
573
574 for (child_yaml, axis, entry) in cases {
575 let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
576 assert_eq!(violation.axis, *axis, "child: {child_yaml}");
577 assert_eq!(violation.entry, *entry, "child: {child_yaml}");
578 let message = violation.to_string();
579 assert!(message.contains(axis.manifest_key()), "{message}");
580 assert!(message.contains(entry), "{message}");
581 }
582 }
583
584 #[test]
587 fn a_child_install_entry_fits_only_under_a_covering_parent_entry() {
588 let parent = envelope("capabilities:\n install:\n skill: [\"local/style-*\"]\n");
589
590 for (skill, covered) in [
591 ("local/style-rust", true),
592 ("\"local/style-rust-*\"", true),
593 ("\"local/style-*\"", true),
594 ("\"local/style*\"", false),
595 ("\"ana/style-rust\"", false),
596 ("\"*\"", false),
597 ("local/stylist", false),
598 ] {
599 let child = envelope(&format!(
600 "capabilities:\n install:\n skill: [{skill}]\n"
601 ));
602 let verdict = parent.contains(&child);
603 assert_eq!(verdict.is_ok(), covered, "child skill {skill}: {verdict:?}");
604 if let Err(violation) = verdict {
605 assert_eq!(violation.axis, EnvelopeAxis::InstallSkill);
606 assert_eq!(violation.entry, skill.trim_matches('"'));
607 }
608 }
609
610 let child_tool = envelope("capabilities:\n install:\n tool: [local/style-rust]\n");
611 let violation = parent.contains(&child_tool).unwrap_err();
612 assert_eq!(violation.axis, EnvelopeAxis::InstallTool);
613 assert_eq!(violation.entry, "local/style-rust");
614 }
615
616 #[test]
619 fn a_child_fetching_from_sources_is_refused_under_a_parent_that_does_not() {
620 let stores = envelope("capabilities:\n install:\n tool: [\"*\"]\n");
621 let sources =
622 envelope("capabilities:\n install:\n tool: [\"*\"]\n from: [stores, sources]\n");
623 assert!(!stores.install_from_sources && sources.install_from_sources);
624
625 let violation = stores.contains(&sources).unwrap_err();
626 assert_eq!(violation.axis, EnvelopeAxis::InstallFrom);
627 assert_eq!(violation.entry, "sources");
628 assert!(
629 violation.to_string().starts_with(
630 "capabilities.install.from: the child declares 'sources', which its parent does \
631 not hold"
632 ),
633 "{violation}"
634 );
635 assert_eq!(sources.contains(&stores), Ok(()));
636 assert_eq!(sources.contains(&sources), Ok(()));
637 }
638
639 #[test]
641 fn a_child_declaring_no_install_is_within_any_parent() {
642 let parent = envelope("capabilities:\n install:\n skill: [\"*\"]\n tool: [\"*\"]\n");
643 let child = envelope("capabilities:\n env:\n allow: []\n");
644 assert!(child.install_skill.is_empty() && child.install_tool.is_empty());
645 assert_eq!(parent.contains(&child), Ok(()));
646 }
647
648 #[test]
651 fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
652 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
653 let child = envelope("capabilities:\n env:\n allow: []\n");
654
655 let violation = parent.contains(&child).unwrap_err();
656
657 assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
658 assert!(violation.entry.is_empty());
659 let message = violation.to_string();
660 assert!(
661 message.contains("capabilities.filesystem.scope"),
662 "{message}"
663 );
664 assert!(message.contains("declares no scope"), "{message}");
665 assert!(message.contains("'data'"), "{message}");
666 }
667
668 #[test]
669 fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
670 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
671
672 for scope in ["data", "data/in", "data/in/../in"] {
673 let child = envelope(&format!(
674 "capabilities:\n filesystem:\n scope: {scope}\n"
675 ));
676 assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
677 }
678 }
679
680 #[test]
682 fn an_unscoped_parent_covers_any_child_scope() {
683 let parent = envelope("capabilities:\n env:\n allow: []\n");
684 let child = envelope("capabilities:\n filesystem:\n scope: data\n");
685
686 assert!(parent.contains(&child).is_ok());
687 }
688
689 #[test]
692 fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
693 let with_state = |name: &str, store: &str| {
694 manifest_envelope(
695 name,
696 &format!(
697 "artifacts:\n - name: local/writer\n version: 0.1.0\n runtime: tool\n \
698 capabilities:\n state:\n store: {store}\n"
699 ),
700 )
701 };
702 let parent = with_state("parent", "parent-notes");
703 let child = with_state("child", "child-notes");
704
705 let violation = parent.contains(&child).unwrap_err();
706
707 assert_eq!(violation.axis, EnvelopeAxis::StateStore);
708 assert_eq!(violation.entry, "child-notes");
709 assert!(violation.to_string().contains("capabilities.state.store"));
710
711 let shared = with_state("child", "parent-notes");
712 assert!(parent.contains(&shared).is_ok());
713 }
714
715 #[test]
718 fn an_undeclared_store_resolves_to_the_capsule_name() {
719 let envelope = manifest_envelope(
720 "notes-capsule",
721 "artifacts:\n - name: local/writer\n version: 0.1.0\n runtime: tool\n \
722 capabilities:\n state: {}\n",
723 );
724
725 assert_eq!(
726 envelope.state_stores.iter().collect::<Vec<_>>(),
727 vec!["notes-capsule"]
728 );
729 }
730
731 #[test]
734 fn a_containment_floor_may_rise_but_never_fall() {
735 let parent = envelope("capabilities:\n containment: scoped\n");
736
737 for raised in ["scoped", "sealed"] {
738 let child = envelope(&format!("capabilities:\n containment: {raised}\n"));
739 assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
740 }
741
742 let child = envelope("capabilities:\n containment: advisory\n");
743 let violation = parent.contains(&child).unwrap_err();
744
745 assert_eq!(violation.axis, EnvelopeAxis::Containment);
746 assert_eq!(violation.entry, "advisory");
747 let message = violation.to_string();
748 assert!(message.contains("capabilities.containment"), "{message}");
749 assert!(message.contains("advisory"), "{message}");
750 assert!(message.contains("may only rise"), "{message}");
751 }
752
753 #[test]
756 fn a_refusal_names_the_key_the_declaration_and_the_rule() {
757 let list_axis = envelope("capabilities:\n network:\n allow: [registry.internal]\n")
758 .contains(&envelope(
759 "capabilities:\n network:\n allow: [api.example.com]\n",
760 ))
761 .unwrap_err();
762 assert_eq!(
763 list_axis.to_string(),
764 "capabilities.network.allow: the child declares 'api.example.com', which its parent \
765 does not hold — a spawned capsule can never hold more capability than the capsule \
766 that spawned it",
767 );
768
769 let absent_scope = envelope("capabilities:\n filesystem:\n scope: data\n")
770 .contains(&envelope("artifacts: []\n"))
771 .unwrap_err();
772 assert_eq!(
773 absent_scope.to_string(),
774 "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
775 workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
776 hold more capability than the capsule that spawned it",
777 );
778
779 let floor = envelope("capabilities:\n containment: scoped\n")
780 .contains(&envelope("capabilities:\n containment: advisory\n"))
781 .unwrap_err();
782 assert_eq!(
783 floor.to_string(),
784 "capabilities.containment: the child declares containment 'advisory', below its \
785 parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
786 );
787 }
788
789 #[test]
792 fn a_silent_child_is_within_any_parent() {
793 let parent = envelope(
794 "capabilities:\n network:\n allow: [registry.internal]\n \
795 shell:\n allow: [git]\n",
796 );
797
798 assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
799 assert!(envelope("artifacts: []\n")
800 .contains(&envelope("artifacts: []\n"))
801 .is_ok());
802 }
803}