1use std::collections::BTreeSet;
27use std::fmt;
28use std::path::{Component, Path};
29
30use murmur_artifact::{ContainmentClass, RuntimeManifest};
31
32use crate::network_policy::{validate_filesystem_scope, NetworkAllowRule};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum EnvelopeAxis {
43 NetworkAllow,
44 UnixSockets,
45 PeerFetchAllow,
46 ShellAllow,
47 SpawnAllow,
48 EnvAllow,
49 FilesystemScope,
50 WorkdirExec,
51 StateStore,
52 Containment,
53}
54
55impl EnvelopeAxis {
56 pub fn manifest_key(self) -> &'static str {
59 match self {
60 Self::NetworkAllow => "capabilities.network.allow",
61 Self::UnixSockets => "capabilities.network.unix_sockets",
62 Self::PeerFetchAllow => "capabilities.peer_fetch.allow",
63 Self::ShellAllow => "capabilities.shell.allow",
64 Self::SpawnAllow => "capabilities.spawn.allow",
65 Self::EnvAllow => "capabilities.env.allow",
66 Self::FilesystemScope => "capabilities.filesystem.scope",
67 Self::WorkdirExec => "capabilities.filesystem.workdir_exec",
68 Self::StateStore => "capabilities.state.store",
69 Self::Containment => "capabilities.containment",
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct EnvelopeViolation {
81 pub axis: EnvelopeAxis,
82 pub entry: String,
90 pub parent_entry: Option<String>,
95}
96
97impl fmt::Display for EnvelopeViolation {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 let key = self.axis.manifest_key();
100 let parent = self.parent_entry.as_deref().unwrap_or_default();
101 match self.axis {
102 EnvelopeAxis::Containment => write!(
103 f,
104 "{key}: the child declares containment '{}', below its parent's floor '{parent}' \
105 — a spawned capsule's containment floor may only rise",
106 self.entry,
107 ),
108 EnvelopeAxis::FilesystemScope if self.entry.is_empty() => write!(
109 f,
110 "{key}: the child declares no scope and would reach the whole workdir, which its \
111 parent's scope '{parent}' does not cover — a spawned capsule can never hold more \
112 capability than the capsule that spawned it",
113 ),
114 EnvelopeAxis::FilesystemScope => write!(
115 f,
116 "{key}: the child declares '{}', which its parent's scope '{parent}' does not \
117 cover — a spawned capsule can never hold more capability than the capsule that \
118 spawned it",
119 self.entry,
120 ),
121 _ => write!(
122 f,
123 "{key}: the child declares '{}', which its parent does not hold — a spawned \
124 capsule can never hold more capability than the capsule that spawned it",
125 self.entry,
126 ),
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Default)]
140pub struct SpawnEnvelope {
141 pub network_allow: Vec<String>,
146 pub unix_sockets_allowed: bool,
147 pub peer_fetch_allow: Vec<String>,
148 pub shell_allow: Vec<String>,
149 pub spawn_allow: Vec<String>,
154 pub env_allow: Vec<String>,
155 pub filesystem_scope: Option<String>,
156 pub workdir_exec_allowed: bool,
157 pub state_stores: BTreeSet<String>,
164 pub containment_floor: ContainmentClass,
167}
168
169impl SpawnEnvelope {
170 pub fn from_runtime_manifest(manifest: &RuntimeManifest) -> Self {
177 let policy = crate::types::capability_policy_from_runtime_manifest(manifest);
178 Self {
179 network_allow: policy.network_allow,
180 unix_sockets_allowed: policy.unix_sockets_allowed,
181 peer_fetch_allow: policy.peer_fetch_allow,
182 shell_allow: policy.shell_allow,
183 spawn_allow: policy.spawn_allow,
184 env_allow: policy.env_allow,
185 filesystem_scope: policy.filesystem_scope,
186 workdir_exec_allowed: policy.workdir_exec_allowed,
187 state_stores: declared_state_stores(manifest),
188 containment_floor: policy.containment_floor,
189 }
190 }
191
192 pub fn contains(&self, child: &Self) -> Result<(), EnvelopeViolation> {
203 if let Some(entry) = first_uncovered_host(&self.network_allow, &child.network_allow) {
204 return Err(violation(EnvelopeAxis::NetworkAllow, entry));
205 }
206 if child.unix_sockets_allowed && !self.unix_sockets_allowed {
207 return Err(violation(EnvelopeAxis::UnixSockets, "true".to_string()));
208 }
209 if let Some(entry) = first_uncovered_host(&self.peer_fetch_allow, &child.peer_fetch_allow) {
210 return Err(violation(EnvelopeAxis::PeerFetchAllow, entry));
211 }
212 if let Some(entry) = first_absent(&self.shell_allow, &child.shell_allow) {
213 return Err(violation(EnvelopeAxis::ShellAllow, entry));
214 }
215 if let Some(entry) = first_absent(&self.spawn_allow, &child.spawn_allow) {
216 return Err(violation(EnvelopeAxis::SpawnAllow, entry));
217 }
218 if let Some(entry) = first_absent(&self.env_allow, &child.env_allow) {
219 return Err(violation(EnvelopeAxis::EnvAllow, entry));
220 }
221 if let Some(parent_scope) = self.filesystem_scope.as_deref() {
225 let child_scope = child.filesystem_scope.as_deref().unwrap_or_default();
226 if !scope_covers(parent_scope, child_scope) {
227 return Err(EnvelopeViolation {
228 axis: EnvelopeAxis::FilesystemScope,
229 entry: child_scope.to_string(),
230 parent_entry: Some(parent_scope.to_string()),
231 });
232 }
233 }
234 if child.workdir_exec_allowed && !self.workdir_exec_allowed {
235 return Err(violation(EnvelopeAxis::WorkdirExec, "true".to_string()));
236 }
237 if let Some(store) = child
238 .state_stores
239 .difference(&self.state_stores)
240 .next()
241 .cloned()
242 {
243 return Err(violation(EnvelopeAxis::StateStore, store));
244 }
245 if child.containment_floor < self.containment_floor {
246 return Err(EnvelopeViolation {
247 axis: EnvelopeAxis::Containment,
248 entry: child.containment_floor.to_string(),
249 parent_entry: Some(self.containment_floor.to_string()),
250 });
251 }
252 Ok(())
253 }
254}
255
256fn violation(axis: EnvelopeAxis, entry: String) -> EnvelopeViolation {
257 EnvelopeViolation {
258 axis,
259 entry,
260 parent_entry: None,
261 }
262}
263
264fn declared_state_stores(manifest: &RuntimeManifest) -> BTreeSet<String> {
272 manifest
273 .artifacts
274 .iter()
275 .filter_map(|artifact| artifact.capabilities.as_ref())
276 .filter_map(|capabilities| capabilities.state.as_ref())
277 .map(|state| crate::state_store::resolve_store_name(state, &manifest.name))
278 .collect()
279}
280
281fn first_uncovered_host(ceiling: &[String], candidate: &[String]) -> Option<String> {
289 let ceiling_rules: Vec<NetworkAllowRule> = ceiling
290 .iter()
291 .filter_map(|entry| NetworkAllowRule::parse(entry).ok())
292 .collect();
293 candidate
294 .iter()
295 .find(|entry| match NetworkAllowRule::parse(entry) {
296 Ok(rule) => !ceiling_rules
297 .iter()
298 .any(|ceiling_rule| ceiling_rule.covers(&rule)),
299 Err(_) => true,
300 })
301 .cloned()
302}
303
304fn first_absent(ceiling: &[String], candidate: &[String]) -> Option<String> {
308 candidate
309 .iter()
310 .find(|entry| !ceiling.contains(entry))
311 .cloned()
312}
313
314fn scope_covers(parent: &str, child: &str) -> bool {
325 match (scope_components(parent), scope_components(child)) {
326 (Some(parent), Some(child)) => child.starts_with(&parent),
327 _ => false,
328 }
329}
330
331fn scope_components(scope: &str) -> Option<Vec<String>> {
332 if scope.is_empty() {
333 return Some(Vec::new());
334 }
335 validate_filesystem_scope(scope).ok()?;
336 let mut components = Vec::new();
337 for component in Path::new(scope).components() {
338 match component {
339 Component::CurDir => {}
340 Component::Normal(part) => components.push(part.to_string_lossy().into_owned()),
341 Component::ParentDir => {
344 components.pop();
345 }
346 Component::Prefix(_) | Component::RootDir => return None,
347 }
348 }
349 Some(components)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::network_policy::{parse_network_allow_rules, ToolCapabilityGrant};
356
357 const COVERAGE_CASES: &[(&str, &str, bool)] = &[
364 ("example.com", "https://example.com", true),
365 ("https://example.com", "example.com", false),
366 ("example.com", "example.com", true),
367 ("https://example.com", "https://example.com", true),
368 ("https://example.com", "http://example.com", false),
369 ("example.com:8443", "example.com:8443", true),
370 ("example.com:8443", "example.com:9000", false),
371 ("example.com", "other.example.com", false),
372 ];
373
374 fn envelope(capabilities_yaml: &str) -> SpawnEnvelope {
375 manifest_envelope("cap", capabilities_yaml)
376 }
377
378 fn manifest_envelope(name: &str, capabilities_yaml: &str) -> SpawnEnvelope {
379 let manifest = RuntimeManifest::from_yaml_str(&format!(
380 "name: {name}\nversion: 0.1.0\n{capabilities_yaml}"
381 ))
382 .expect("manifest fixture must parse");
383 SpawnEnvelope::from_runtime_manifest(&manifest)
384 }
385
386 fn network_envelope(entries: &[&str]) -> SpawnEnvelope {
387 SpawnEnvelope {
388 network_allow: entries.iter().map(|entry| entry.to_string()).collect(),
389 ..SpawnEnvelope::default()
390 }
391 }
392
393 #[test]
398 fn the_narrowing_path_and_the_spawn_path_share_one_subset_rule() {
399 for (ceiling, candidate, expected) in COVERAGE_CASES {
400 let ceiling_rules = parse_network_allow_rules(&[ceiling.to_string()])
401 .expect("ceiling fixture must parse");
402 let capabilities = murmur_artifact::Capabilities {
403 network: Some(murmur_artifact::NetworkCapabilities {
404 allow: vec![candidate.to_string()],
405 unix_sockets: false,
406 }),
407 peer_fetch: None,
408 filesystem: None,
409 shell: None,
410 spawn: None,
411 env: None,
412 limits: None,
413 resources: None,
414 state: None,
415 task_io: None,
416 conversation: None,
417 plan: None,
418 containment: None,
419 };
420 let narrowed = ToolCapabilityGrant::derive(Some(&capabilities), &ceiling_rules, "cap")
421 .expect("candidate fixture must parse");
422 let narrowing_verdict = narrowed.dropped_network_entries.is_empty();
423
424 let spawn_verdict = network_envelope(&[ceiling])
425 .contains(&network_envelope(&[candidate]))
426 .is_ok();
427
428 assert_eq!(
429 narrowing_verdict, spawn_verdict,
430 "'{ceiling}' vs '{candidate}': the narrowing path and the spawn path disagree",
431 );
432 assert_eq!(
433 spawn_verdict, *expected,
434 "'{ceiling}' vs '{candidate}': expected covered={expected}",
435 );
436 }
437 }
438
439 #[test]
440 fn a_network_host_the_parent_does_not_hold_is_refused_by_name() {
441 let parent = envelope("capabilities:\n network:\n allow: [registry.internal]\n");
442 let child = envelope("capabilities:\n network:\n allow: [api.example.com]\n");
443
444 let violation = parent.contains(&child).unwrap_err();
445
446 assert_eq!(violation.axis, EnvelopeAxis::NetworkAllow);
447 assert_eq!(violation.entry, "api.example.com");
448 let message = violation.to_string();
449 assert!(message.contains("capabilities.network.allow"), "{message}");
450 assert!(message.contains("api.example.com"), "{message}");
451 assert!(message.contains("its parent does not hold"), "{message}");
452 }
453
454 #[test]
457 fn every_axis_refuses_naming_its_own_manifest_key_and_entry() {
458 let parent_yaml = "capabilities:\n \
459 network:\n allow: [registry.internal]\n unix_sockets: false\n \
460 peer_fetch:\n allow: [peer.internal]\n \
461 shell:\n allow: [git]\n \
462 spawn:\n allow: [worker-a]\n \
463 env:\n allow: [HOME]\n \
464 filesystem:\n scope: data\n workdir_exec: false\n";
465 let parent = envelope(parent_yaml);
466
467 let cases: &[(&str, EnvelopeAxis, &str)] = &[
468 (
469 "capabilities:\n network:\n allow: [api.example.com]\n",
470 EnvelopeAxis::NetworkAllow,
471 "api.example.com",
472 ),
473 (
474 "capabilities:\n network:\n unix_sockets: true\n",
475 EnvelopeAxis::UnixSockets,
476 "true",
477 ),
478 (
479 "capabilities:\n peer_fetch:\n allow: [other.internal]\n",
480 EnvelopeAxis::PeerFetchAllow,
481 "other.internal",
482 ),
483 (
484 "capabilities:\n shell:\n allow: [curl]\n",
485 EnvelopeAxis::ShellAllow,
486 "curl",
487 ),
488 (
489 "capabilities:\n spawn:\n allow: [worker-b]\n",
490 EnvelopeAxis::SpawnAllow,
491 "worker-b",
492 ),
493 (
494 "capabilities:\n env:\n allow: [GITHUB_TOKEN]\n",
495 EnvelopeAxis::EnvAllow,
496 "GITHUB_TOKEN",
497 ),
498 (
499 "capabilities:\n filesystem:\n scope: other\n",
500 EnvelopeAxis::FilesystemScope,
501 "other",
502 ),
503 (
504 "capabilities:\n filesystem:\n scope: data/../other\n",
505 EnvelopeAxis::FilesystemScope,
506 "data/../other",
507 ),
508 (
509 "capabilities:\n filesystem:\n scope: data\n workdir_exec: true\n",
510 EnvelopeAxis::WorkdirExec,
511 "true",
512 ),
513 ];
514
515 for (child_yaml, axis, entry) in cases {
516 let violation = parent.contains(&envelope(child_yaml)).unwrap_err();
517 assert_eq!(violation.axis, *axis, "child: {child_yaml}");
518 assert_eq!(violation.entry, *entry, "child: {child_yaml}");
519 let message = violation.to_string();
520 assert!(message.contains(axis.manifest_key()), "{message}");
521 assert!(message.contains(entry), "{message}");
522 }
523 }
524
525 #[test]
528 fn a_child_declaring_no_scope_is_refused_under_a_scoped_parent() {
529 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
530 let child = envelope("capabilities:\n env:\n allow: []\n");
531
532 let violation = parent.contains(&child).unwrap_err();
533
534 assert_eq!(violation.axis, EnvelopeAxis::FilesystemScope);
535 assert!(violation.entry.is_empty());
536 let message = violation.to_string();
537 assert!(
538 message.contains("capabilities.filesystem.scope"),
539 "{message}"
540 );
541 assert!(message.contains("declares no scope"), "{message}");
542 assert!(message.contains("'data'"), "{message}");
543 }
544
545 #[test]
546 fn a_scope_at_or_beneath_the_parents_is_within_the_envelope() {
547 let parent = envelope("capabilities:\n filesystem:\n scope: data\n");
548
549 for scope in ["data", "data/in", "data/in/../in"] {
550 let child = envelope(&format!(
551 "capabilities:\n filesystem:\n scope: {scope}\n"
552 ));
553 assert!(parent.contains(&child).is_ok(), "scope '{scope}'");
554 }
555 }
556
557 #[test]
559 fn an_unscoped_parent_covers_any_child_scope() {
560 let parent = envelope("capabilities:\n env:\n allow: []\n");
561 let child = envelope("capabilities:\n filesystem:\n scope: data\n");
562
563 assert!(parent.contains(&child).is_ok());
564 }
565
566 #[test]
569 fn a_state_store_the_parents_artifacts_do_not_open_is_refused() {
570 let with_state = |name: &str, store: &str| {
571 manifest_envelope(
572 name,
573 &format!(
574 "artifacts:\n - name: writer\n version: 0.1.0\n runtime: tool\n \
575 capabilities:\n state:\n store: {store}\n"
576 ),
577 )
578 };
579 let parent = with_state("parent", "parent-notes");
580 let child = with_state("child", "child-notes");
581
582 let violation = parent.contains(&child).unwrap_err();
583
584 assert_eq!(violation.axis, EnvelopeAxis::StateStore);
585 assert_eq!(violation.entry, "child-notes");
586 assert!(violation.to_string().contains("capabilities.state.store"));
587
588 let shared = with_state("child", "parent-notes");
589 assert!(parent.contains(&shared).is_ok());
590 }
591
592 #[test]
595 fn an_undeclared_store_resolves_to_the_capsule_name() {
596 let envelope = manifest_envelope(
597 "notes-capsule",
598 "artifacts:\n - name: writer\n version: 0.1.0\n runtime: tool\n \
599 capabilities:\n state: {}\n",
600 );
601
602 assert_eq!(
603 envelope.state_stores.iter().collect::<Vec<_>>(),
604 vec!["notes-capsule"]
605 );
606 }
607
608 #[test]
611 fn a_containment_floor_may_rise_but_never_fall() {
612 let parent = envelope("capabilities:\n containment: scoped\n");
613
614 for raised in ["scoped", "sealed"] {
615 let child = envelope(&format!("capabilities:\n containment: {raised}\n"));
616 assert!(parent.contains(&child).is_ok(), "floor '{raised}'");
617 }
618
619 let child = envelope("capabilities:\n containment: advisory\n");
620 let violation = parent.contains(&child).unwrap_err();
621
622 assert_eq!(violation.axis, EnvelopeAxis::Containment);
623 assert_eq!(violation.entry, "advisory");
624 let message = violation.to_string();
625 assert!(message.contains("capabilities.containment"), "{message}");
626 assert!(message.contains("advisory"), "{message}");
627 assert!(message.contains("may only rise"), "{message}");
628 }
629
630 #[test]
633 fn a_refusal_names_the_key_the_declaration_and_the_rule() {
634 let list_axis = envelope("capabilities:\n network:\n allow: [registry.internal]\n")
635 .contains(&envelope(
636 "capabilities:\n network:\n allow: [api.example.com]\n",
637 ))
638 .unwrap_err();
639 assert_eq!(
640 list_axis.to_string(),
641 "capabilities.network.allow: the child declares 'api.example.com', which its parent \
642 does not hold — a spawned capsule can never hold more capability than the capsule \
643 that spawned it",
644 );
645
646 let absent_scope = envelope("capabilities:\n filesystem:\n scope: data\n")
647 .contains(&envelope("artifacts: []\n"))
648 .unwrap_err();
649 assert_eq!(
650 absent_scope.to_string(),
651 "capabilities.filesystem.scope: the child declares no scope and would reach the whole \
652 workdir, which its parent's scope 'data' does not cover — a spawned capsule can never \
653 hold more capability than the capsule that spawned it",
654 );
655
656 let floor = envelope("capabilities:\n containment: scoped\n")
657 .contains(&envelope("capabilities:\n containment: advisory\n"))
658 .unwrap_err();
659 assert_eq!(
660 floor.to_string(),
661 "capabilities.containment: the child declares containment 'advisory', below its \
662 parent's floor 'scoped' — a spawned capsule's containment floor may only rise",
663 );
664 }
665
666 #[test]
669 fn a_silent_child_is_within_any_parent() {
670 let parent = envelope(
671 "capabilities:\n network:\n allow: [registry.internal]\n \
672 shell:\n allow: [git]\n",
673 );
674
675 assert!(parent.contains(&envelope("artifacts: []\n")).is_ok());
676 assert!(envelope("artifacts: []\n")
677 .contains(&envelope("artifacts: []\n"))
678 .is_ok());
679 }
680}