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 Containment,
61}
62
63impl EnvelopeAxis {
64 pub fn manifest_key(self) -> &'static str {
67 match self {
68 Self::NetworkAllow => "capabilities.network.allow",
69 Self::UnixSockets => "capabilities.network.unix_sockets",
70 Self::PeerFetchAllow => "capabilities.peer_fetch.allow",
71 Self::ShellAllow => "capabilities.shell.allow",
72 Self::SpawnAllow => "capabilities.spawn.allow",
73 Self::EnvAllow => "capabilities.env.allow",
74 Self::FilesystemScope => "capabilities.filesystem.scope",
75 Self::WorkdirExec => "capabilities.filesystem.workdir_exec",
76 Self::StateStore => "capabilities.state.store",
77 Self::InstallSkill => "capabilities.install.skill",
78 Self::InstallTool => "capabilities.install.tool",
79 Self::Containment => "capabilities.containment",
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct EnvelopeViolation {
91 pub axis: EnvelopeAxis,
92 pub entry: String,
100 pub parent_entry: Option<String>,
105}
106
107impl fmt::Display for EnvelopeViolation {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 let key = self.axis.manifest_key();
110 let parent = self.parent_entry.as_deref().unwrap_or_default();
111 match self.axis {
112 EnvelopeAxis::Containment => write!(
113 f,
114 "{key}: the child declares containment '{}', below its parent's floor '{parent}' \
115 — a spawned capsule's containment floor may only rise",
116 self.entry,
117 ),
118 EnvelopeAxis::FilesystemScope if self.entry.is_empty() => write!(
119 f,
120 "{key}: the child declares no scope and would reach the whole workdir, which its \
121 parent's scope '{parent}' does not cover — a spawned capsule can never hold more \
122 capability than the capsule that spawned it",
123 ),
124 EnvelopeAxis::FilesystemScope => write!(
125 f,
126 "{key}: the child declares '{}', which its parent's scope '{parent}' does not \
127 cover — a spawned capsule can never hold more capability than the capsule that \
128 spawned it",
129 self.entry,
130 ),
131 _ => write!(
132 f,
133 "{key}: the child declares '{}', which its parent does not hold — a spawned \
134 capsule can never hold more capability than the capsule that spawned it",
135 self.entry,
136 ),
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Default)]
150pub struct SpawnEnvelope {
151 pub network_allow: Vec<String>,
156 pub unix_sockets_allowed: bool,
157 pub peer_fetch_allow: Vec<String>,
158 pub shell_allow: Vec<String>,
159 pub spawn_allow: Vec<String>,
164 pub env_allow: Vec<String>,
165 pub filesystem_scope: Option<String>,
166 pub workdir_exec_allowed: bool,
167 pub state_stores: BTreeSet<String>,
174 pub install_skill: Vec<String>,
178 pub install_tool: Vec<String>,
180 pub containment_floor: ContainmentClass,
183}
184
185impl SpawnEnvelope {
186 pub fn from_runtime_manifest(manifest: &RuntimeManifest) -> Self {
193 let policy = crate::types::capability_policy_from_runtime_manifest(manifest);
194 Self {
195 network_allow: policy.network_allow,
196 unix_sockets_allowed: policy.unix_sockets_allowed,
197 peer_fetch_allow: policy.peer_fetch_allow,
198 shell_allow: policy.shell_allow,
199 spawn_allow: policy.spawn_allow,
200 env_allow: policy.env_allow,
201 filesystem_scope: policy.filesystem_scope,
202 workdir_exec_allowed: policy.workdir_exec_allowed,
203 state_stores: declared_state_stores(manifest),
204 install_skill: policy.install_skill,
205 install_tool: policy.install_tool,
206 containment_floor: policy.containment_floor,
207 }
208 }
209
210 pub fn contains(&self, child: &Self) -> Result<(), EnvelopeViolation> {
221 if let Some(entry) = first_uncovered_host(&self.network_allow, &child.network_allow) {
222 return Err(violation(EnvelopeAxis::NetworkAllow, entry));
223 }
224 if child.unix_sockets_allowed && !self.unix_sockets_allowed {
225 return Err(violation(EnvelopeAxis::UnixSockets, "true".to_string()));
226 }
227 if let Some(entry) = first_uncovered_host(&self.peer_fetch_allow, &child.peer_fetch_allow) {
228 return Err(violation(EnvelopeAxis::PeerFetchAllow, entry));
229 }
230 if let Some(entry) = first_absent(&self.shell_allow, &child.shell_allow) {
231 return Err(violation(EnvelopeAxis::ShellAllow, entry));
232 }
233 if let Some(entry) = first_absent(&self.spawn_allow, &child.spawn_allow) {
234 return Err(violation(EnvelopeAxis::SpawnAllow, entry));
235 }
236 if let Some(entry) = first_absent(&self.env_allow, &child.env_allow) {
237 return Err(violation(EnvelopeAxis::EnvAllow, entry));
238 }
239 if let Some(parent_scope) = self.filesystem_scope.as_deref() {
243 let child_scope = child.filesystem_scope.as_deref().unwrap_or_default();
244 if !scope_covers(parent_scope, child_scope) {
245 return Err(EnvelopeViolation {
246 axis: EnvelopeAxis::FilesystemScope,
247 entry: child_scope.to_string(),
248 parent_entry: Some(parent_scope.to_string()),
249 });
250 }
251 }
252 if child.workdir_exec_allowed && !self.workdir_exec_allowed {
253 return Err(violation(EnvelopeAxis::WorkdirExec, "true".to_string()));
254 }
255 if let Some(store) = child
256 .state_stores
257 .difference(&self.state_stores)
258 .next()
259 .cloned()
260 {
261 return Err(violation(EnvelopeAxis::StateStore, store));
262 }
263 if let Some(entry) = first_uncovered_install(&self.install_skill, &child.install_skill) {
264 return Err(violation(EnvelopeAxis::InstallSkill, entry));
265 }
266 if let Some(entry) = first_uncovered_install(&self.install_tool, &child.install_tool) {
267 return Err(violation(EnvelopeAxis::InstallTool, entry));
268 }
269 if child.containment_floor < self.containment_floor {
270 return Err(EnvelopeViolation {
271 axis: EnvelopeAxis::Containment,
272 entry: child.containment_floor.to_string(),
273 parent_entry: Some(self.containment_floor.to_string()),
274 });
275 }
276 Ok(())
277 }
278}
279
280fn violation(axis: EnvelopeAxis, entry: String) -> EnvelopeViolation {
281 EnvelopeViolation {
282 axis,
283 entry,
284 parent_entry: None,
285 }
286}
287
288fn declared_state_stores(manifest: &RuntimeManifest) -> BTreeSet<String> {
296 manifest
297 .artifacts
298 .iter()
299 .filter_map(|artifact| artifact.capabilities.as_ref())
300 .filter_map(|capabilities| capabilities.state.as_ref())
301 .map(|state| crate::state_store::resolve_store_name(state, &manifest.name))
302 .collect()
303}
304
305fn first_uncovered_host(ceiling: &[String], candidate: &[String]) -> Option<String> {
313 let ceiling_rules: Vec<NetworkAllowRule> = ceiling
314 .iter()
315 .filter_map(|entry| NetworkAllowRule::parse(entry).ok())
316 .collect();
317 candidate
318 .iter()
319 .find(|entry| match NetworkAllowRule::parse(entry) {
320 Ok(rule) => !ceiling_rules
321 .iter()
322 .any(|ceiling_rule| ceiling_rule.covers(&rule)),
323 Err(_) => true,
324 })
325 .cloned()
326}
327
328fn first_absent(ceiling: &[String], candidate: &[String]) -> Option<String> {
332 candidate
333 .iter()
334 .find(|entry| !ceiling.contains(entry))
335 .cloned()
336}
337
338fn first_uncovered_install(ceiling: &[String], candidate: &[String]) -> Option<String> {
342 candidate
343 .iter()
344 .find(|entry| {
345 !ceiling
346 .iter()
347 .any(|parent| murmur_artifact::install_pattern::covers(parent, entry))
348 })
349 .cloned()
350}
351
352fn scope_covers(parent: &str, child: &str) -> bool {
363 match (scope_components(parent), scope_components(child)) {
364 (Some(parent), Some(child)) => child.starts_with(&parent),
365 _ => false,
366 }
367}
368
369fn scope_components(scope: &str) -> Option<Vec<String>> {
370 if scope.is_empty() {
371 return Some(Vec::new());
372 }
373 validate_filesystem_scope(scope).ok()?;
374 let mut components = Vec::new();
375 for component in Path::new(scope).components() {
376 match component {
377 Component::CurDir => {}
378 Component::Normal(part) => components.push(part.to_string_lossy().into_owned()),
379 Component::ParentDir => {
382 components.pop();
383 }
384 Component::Prefix(_) | Component::RootDir => return None,
385 }
386 }
387 Some(components)
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::network_policy::{parse_network_allow_rules, ToolCapabilityGrant};
394
395 const COVERAGE_CASES: &[(&str, &str, bool)] = &[
402 ("example.com", "https://example.com", true),
403 ("https://example.com", "example.com", false),
404 ("example.com", "example.com", true),
405 ("https://example.com", "https://example.com", true),
406 ("https://example.com", "http://example.com", false),
407 ("example.com:8443", "example.com:8443", true),
408 ("example.com:8443", "example.com:9000", false),
409 ("example.com", "other.example.com", false),
410 ];
411
412 fn envelope(capabilities_yaml: &str) -> SpawnEnvelope {
413 manifest_envelope("cap", capabilities_yaml)
414 }
415
416 fn manifest_envelope(name: &str, capabilities_yaml: &str) -> SpawnEnvelope {
417 let manifest = RuntimeManifest::from_yaml_str(&format!(
418 "name: {name}\nversion: 0.1.0\n{capabilities_yaml}"
419 ))
420 .expect("manifest fixture must parse");
421 SpawnEnvelope::from_runtime_manifest(&manifest)
422 }
423
424 fn network_envelope(entries: &[&str]) -> SpawnEnvelope {
425 SpawnEnvelope {
426 network_allow: entries.iter().map(|entry| entry.to_string()).collect(),
427 ..SpawnEnvelope::default()
428 }
429 }
430
431 #[test]
436 fn the_narrowing_path_and_the_spawn_path_share_one_subset_rule() {
437 for (ceiling, candidate, expected) in COVERAGE_CASES {
438 let ceiling_rules = parse_network_allow_rules(&[ceiling.to_string()])
439 .expect("ceiling fixture must parse");
440 let capabilities = murmur_artifact::Capabilities {
441 network: Some(murmur_artifact::NetworkCapabilities {
442 allow: vec![candidate.to_string()],
443 unix_sockets: false,
444 }),
445 peer_fetch: None,
446 filesystem: None,
447 shell: None,
448 spawn: None,
449 env: None,
450 limits: None,
451 resources: None,
452 state: None,
453 task_io: None,
454 conversation: None,
455 plan: None,
456 install: None,
457 containment: None,
458 };
459 let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
460 .expect("candidate fixture must parse");
461 let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
462
463 let spawn_verdict = network_envelope(&[ceiling])
464 .contains(&network_envelope(&[candidate]))
465 .is_ok();
466
467 assert_eq!(
468 narrowing_verdict, spawn_verdict,
469 "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
470 );
471 assert_eq!(
472 spawn_verdict, *expected,
473 "'{ceiling}' vs '{candidate}': expected covered={expected}",
474 );
475 }
476 }
477
478 #[test]
479 fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
480 let parent = envelope("capabilities:\n network:\n allow: [registry.internal]\n");
481 let child = envelope("capabilities:\n network:\n allow: [api.example.com]\n");
482
483 let violation = parent.contains(&child).unwrap_err();
484
485 assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
486 assert_eq!(violation.entry, "api.example.com");
487 let message = violation.to_string();
488 assert!(message.contains("capabilities.network.allow"), "{message}");
489 assert!(message.contains("api.example.com"), "{message}");
490 assert!(message.contains("its parent does not hold"), "{message}");
491 }
492
493 #[test]
496 fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
497 let parent_yaml = "capabilities:\n \
498 network:\n allow: [registry.internal]\n unix_sockets: false\n \
499 peer_fetch:\n allow: [peer.internal]\n \
500 shell:\n allow: [git]\n \
501 spawn:\n allow: [worker-a]\n \
502 env:\n allow: [HOME]\n \
503 filesystem:\n scope: data\n workdir_exec: false\n \
504 install:\n skill: [\"style-*\"]\n tool: [jq]\n";
505 let parent = envelope(parent_yaml);
506
507 let cases: &[(&str, EnvelopeAxis, &str)] = &[
508 (
509 "capabilities:\n network:\n allow: [api.example.com]\n",
510 EnvelopeAxis::NetworkAllow,
511 "api.example.com",
512 ),
513 (
514 "capabilities:\n network:\n unix_sockets: true\n",
515 EnvelopeAxis::UnixSockets,
516 "true",
517 ),
518 (
519 "capabilities:\n peer_fetch:\n allow: [other.internal]\n",
520 EnvelopeAxis::PeerFetchAllow,
521 "other.internal",
522 ),
523 (
524 "capabilities:\n shell:\n allow: [curl]\n",
525 EnvelopeAxis::ShellAllow,
526 "curl",
527 ),
528 (
529 "capabilities:\n spawn:\n allow: [worker-b]\n",
530 EnvelopeAxis::SpawnAllow,
531 "worker-b",
532 ),
533 (
534 "capabilities:\n env:\n allow: [GITHUB_TOKEN]\n",
535 EnvelopeAxis::EnvAllow,
536 "GITHUB_TOKEN",
537 ),
538 (
539 "capabilities:\n filesystem:\n scope: other\n",
540 EnvelopeAxis::FilesystemScope,
541 "other",
542 ),
543 (
544 "capabilities:\n filesystem:\n scope: data/../other\n",
545 EnvelopeAxis::FilesystemScope,
546 "data/../other",
547 ),
548 (
549 "capabilities:\n filesystem:\n scope: data\n workdir_exec: true\n",
550 EnvelopeAxis::WorkdirExec,
551 "true",
552 ),
553 (
554 "capabilities:\n filesystem:\n scope: data\n install:\n skill: [\"*\"]\n",
555 EnvelopeAxis::InstallSkill,
556 "*",
557 ),
558 (
559 "capabilities:\n filesystem:\n scope: data\n install:\n tool: [yq]\n",
560 EnvelopeAxis::InstallTool,
561 "yq",
562 ),
563 ];
564
565 for (child_yaml, axis, entry) in cases {
566 let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
567 assert_eq!(violation.axis, *axis, "child: {child_yaml}");
568 assert_eq!(violation.entry, *entry, "child: {child_yaml}");
569 let message = violation.to_string();
570 assert!(message.contains(axis.manifest_key()), "{message}");
571 assert!(message.contains(entry), "{message}");
572 }
573 }
574
575 #[test]
578 fn a_child_install_entry_fits_only_under_a_covering_parent_entry() {
579 let parent = envelope("capabilities:\n install:\n skill: [\"style-*\"]\n");
580
581 for (skill, covered) in [
582 ("style-rust", true),
583 ("\"style-rust-*\"", true),
584 ("\"style-*\"", true),
585 ("\"style*\"", false),
586 ("\"*\"", false),
587 ("stylist", false),
588 ] {
589 let child = envelope(&format!(
590 "capabilities:\n install:\n skill: [{skill}]\n"
591 ));
592 let verdict = parent.contains(&child);
593 assert_eq!(verdict.is_ok(), covered, "child skill {skill}: {verdict:?}");
594 if let Err(violation) = verdict {
595 assert_eq!(violation.axis, EnvelopeAxis::InstallSkill);
596 assert_eq!(violation.entry, skill.trim_matches('"'));
597 }
598 }
599
600 let child_tool = envelope("capabilities:\n install:\n tool: [style-rust]\n");
601 let violation = parent.contains(&child_tool).unwrap_err();
602 assert_eq!(violation.axis, EnvelopeAxis::InstallTool);
603 assert_eq!(violation.entry, "style-rust");
604 }
605
606 #[test]
608 fn a_child_declaring_no_install_is_within_any_parent() {
609 let parent = envelope("capabilities:\n install:\n skill: [\"*\"]\n tool: [\"*\"]\n");
610 let child = envelope("capabilities:\n env:\n allow: []\n");
611 assert!(child.install_skill.is_empty() && child.install_tool.is_empty());
612 assert_eq!(parent.contains(&child), Ok(()));
613 }
614
615 #[test]
618 fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
619 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
620 let child = envelope("capabilities:\n env:\n allow: []\n");
621
622 let violation = parent.contains(&child).unwrap_err();
623
624 assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
625 assert!(violation.entry.is_empty());
626 let message = violation.to_string();
627 assert!(
628 message.contains("capabilities.filesystem.scope"),
629 "{message}"
630 );
631 assert!(message.contains("declares no scope"), "{message}");
632 assert!(message.contains("'data'"), "{message}");
633 }
634
635 #[test]
636 fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
637 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
638
639 for scope in ["data", "data/in", "data/in/../in"] {
640 let child = envelope(&format!(
641 "capabilities:\n filesystem:\n scope: {scope}\n"
642 ));
643 assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
644 }
645 }
646
647 #[test]
649 fn an_unscoped_parent_covers_any_child_scope() {
650 let parent = envelope("capabilities:\n env:\n allow: []\n");
651 let child = envelope("capabilities:\n filesystem:\n scope: data\n");
652
653 assert!(parent.contains(&child).is_ok());
654 }
655
656 #[test]
659 fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
660 let with_state = |name: &str, store: &str| {
661 manifest_envelope(
662 name,
663 &format!(
664 "artifacts:\n - name: writer\n version: 0.1.0\n runtime: tool\n \
665 capabilities:\n state:\n store: {store}\n"
666 ),
667 )
668 };
669 let parent = with_state("parent", "parent-notes");
670 let child = with_state("child", "child-notes");
671
672 let violation = parent.contains(&child).unwrap_err();
673
674 assert_eq!(violation.axis, EnvelopeAxis::StateStore);
675 assert_eq!(violation.entry, "child-notes");
676 assert!(violation.to_string().contains("capabilities.state.store"));
677
678 let shared = with_state("child", "parent-notes");
679 assert!(parent.contains(&shared).is_ok());
680 }
681
682 #[test]
685 fn an_undeclared_store_resolves_to_the_capsule_name() {
686 let envelope = manifest_envelope(
687 "notes-capsule",
688 "artifacts:\n - name: writer\n version: 0.1.0\n runtime: tool\n \
689 capabilities:\n state: {}\n",
690 );
691
692 assert_eq!(
693 envelope.state_stores.iter().collect::<Vec<_>>(),
694 vec!["notes-capsule"]
695 );
696 }
697
698 #[test]
701 fn a_containment_floor_may_rise_but_never_fall() {
702 let parent = envelope("capabilities:\n containment: scoped\n");
703
704 for raised in ["scoped", "sealed"] {
705 let child = envelope(&format!("capabilities:\n containment: {raised}\n"));
706 assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
707 }
708
709 let child = envelope("capabilities:\n containment: advisory\n");
710 let violation = parent.contains(&child).unwrap_err();
711
712 assert_eq!(violation.axis, EnvelopeAxis::Containment);
713 assert_eq!(violation.entry, "advisory");
714 let message = violation.to_string();
715 assert!(message.contains("capabilities.containment"), "{message}");
716 assert!(message.contains("advisory"), "{message}");
717 assert!(message.contains("may only rise"), "{message}");
718 }
719
720 #[test]
723 fn a_refusal_names_the_key_the_declaration_and_the_rule() {
724 let list_axis = envelope("capabilities:\n network:\n allow: [registry.internal]\n")
725 .contains(&envelope(
726 "capabilities:\n network:\n allow: [api.example.com]\n",
727 ))
728 .unwrap_err();
729 assert_eq!(
730 list_axis.to_string(),
731 "capabilities.network.allow: the child declares 'api.example.com', which its parent \
732 does not hold — a spawned capsule can never hold more capability than the capsule \
733 that spawned it",
734 );
735
736 let absent_scope = envelope("capabilities:\n filesystem:\n scope: data\n")
737 .contains(&envelope("artifacts: []\n"))
738 .unwrap_err();
739 assert_eq!(
740 absent_scope.to_string(),
741 "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
742 workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
743 hold more capability than the capsule that spawned it",
744 );
745
746 let floor = envelope("capabilities:\n containment: scoped\n")
747 .contains(&envelope("capabilities:\n containment: advisory\n"))
748 .unwrap_err();
749 assert_eq!(
750 floor.to_string(),
751 "capabilities.containment: the child declares containment 'advisory', below its \
752 parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
753 );
754 }
755
756 #[test]
759 fn a_silent_child_is_within_any_parent() {
760 let parent = envelope(
761 "capabilities:\n network:\n allow: [registry.internal]\n \
762 shell:\n allow: [git]\n",
763 );
764
765 assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
766 assert!(envelope("artifacts: []\n")
767 .contains(&envelope("artifacts: []\n"))
768 .is_ok());
769 }
770}