1use std::path::{Component as PathComponent, Path, PathBuf};
2
3use crate::{
4 containment::{PreopenReport, PreopenSurface},
5 errors::RuntimeError,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub(crate) struct NetworkAllowRule {
10 pub(crate) scheme: Option<String>,
11 pub(crate) host: String,
12 pub(crate) port: Option<u16>,
13}
14
15impl NetworkAllowRule {
16 pub(crate) fn parse(entry: &str) -> Result<Self, RuntimeError> {
17 let trimmed = entry.trim();
18 if trimmed.is_empty() {
19 return Err(RuntimeError::InvalidNetworkAllowEntry {
20 entry: entry.to_string(),
21 message: "entry must not be empty".to_string(),
22 });
23 }
24
25 if trimmed.contains("://") {
26 parse_url_allow_rule(trimmed)
27 } else {
28 parse_host_allow_rule(trimmed)
29 }
30 }
31
32 pub(crate) fn covers(&self, narrower: &NetworkAllowRule) -> bool {
42 if self.host != narrower.host {
43 return false;
44 }
45
46 if let Some(expected_scheme) = &self.scheme {
47 if narrower.scheme.as_ref() != Some(expected_scheme) {
48 return false;
49 }
50 }
51
52 match self.port {
53 Some(expected_port) => narrower.port == Some(expected_port),
54 None => true,
55 }
56 }
57
58 pub(crate) fn matches(&self, target: &RequestTarget) -> bool {
59 if self.host != target.host {
60 return false;
61 }
62
63 if let Some(expected_scheme) = &self.scheme {
64 if expected_scheme != &target.scheme {
65 return false;
66 }
67 }
68
69 match self.port {
70 Some(expected_port) => target.port == Some(expected_port),
71 None => true,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub(crate) struct RequestTarget {
78 pub(crate) scheme: String,
79 pub(crate) host: String,
80 pub(crate) port: Option<u16>,
81}
82
83impl RequestTarget {
84 pub(crate) fn from_request(uri: &http::Uri, use_tls: bool) -> Option<Self> {
85 let authority = uri.authority()?;
86 let scheme = uri
87 .scheme_str()
88 .map(|s| s.to_ascii_lowercase())
89 .unwrap_or_else(|| if use_tls { "https" } else { "http" }.to_string());
90
91 let port = authority
92 .port_u16()
93 .or_else(|| default_port_for_scheme(&scheme));
94
95 Some(Self {
96 scheme,
97 host: authority.host().to_ascii_lowercase(),
98 port,
99 })
100 }
101}
102
103pub(crate) fn parse_network_allow_rules(
104 entries: &[String],
105) -> Result<Vec<NetworkAllowRule>, RuntimeError> {
106 entries
107 .iter()
108 .map(|entry| NetworkAllowRule::parse(entry))
109 .collect()
110}
111
112fn parse_url_allow_rule(entry: &str) -> Result<NetworkAllowRule, RuntimeError> {
113 let url = url::Url::parse(entry).map_err(|err| RuntimeError::InvalidNetworkAllowEntry {
114 entry: entry.to_string(),
115 message: format!("invalid URL: {err}"),
116 })?;
117
118 let scheme = url.scheme().to_ascii_lowercase();
119 if scheme != "http" && scheme != "https" {
120 return Err(RuntimeError::InvalidNetworkAllowEntry {
121 entry: entry.to_string(),
122 message: format!("unsupported URL scheme '{scheme}' (expected http or https)"),
123 });
124 }
125
126 if url.query().is_some() || url.fragment().is_some() || (url.path() != "" && url.path() != "/")
127 {
128 return Err(RuntimeError::InvalidNetworkAllowEntry {
129 entry: entry.to_string(),
130 message: "URL allow entries must not include path, query, or fragment".to_string(),
131 });
132 }
133
134 let host = url
135 .host_str()
136 .ok_or_else(|| RuntimeError::InvalidNetworkAllowEntry {
137 entry: entry.to_string(),
138 message: "URL allow entries must include a host".to_string(),
139 })?
140 .to_ascii_lowercase();
141
142 let port = url.port().or_else(|| default_port_for_scheme(&scheme));
143 Ok(NetworkAllowRule {
144 scheme: Some(scheme),
145 host,
146 port,
147 })
148}
149
150fn parse_host_allow_rule(entry: &str) -> Result<NetworkAllowRule, RuntimeError> {
151 let prefixed = format!("http://{entry}");
152 let parsed =
153 url::Url::parse(&prefixed).map_err(|err| RuntimeError::InvalidNetworkAllowEntry {
154 entry: entry.to_string(),
155 message: format!("invalid host allow entry: {err}"),
156 })?;
157
158 if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() {
159 return Err(RuntimeError::InvalidNetworkAllowEntry {
160 entry: entry.to_string(),
161 message: "host allow entries must be host or host:port only".to_string(),
162 });
163 }
164
165 let host = parsed
166 .host_str()
167 .ok_or_else(|| RuntimeError::InvalidNetworkAllowEntry {
168 entry: entry.to_string(),
169 message: "host allow entries must include a host".to_string(),
170 })?
171 .to_ascii_lowercase();
172
173 Ok(NetworkAllowRule {
174 scheme: None,
175 host,
176 port: parsed.port(),
177 })
178}
179
180fn default_port_for_scheme(scheme: &str) -> Option<u16> {
181 match scheme {
182 "http" => Some(80),
183 "https" => Some(443),
184 _ => None,
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub(crate) enum WorkdirSubpathRejection {
194 Absolute,
195 Escapes,
196 Prefixed,
197}
198
199impl WorkdirSubpathRejection {
200 pub(crate) fn message(self, noun: &str) -> String {
203 match self {
204 Self::Absolute => format!("{noun} must be relative to the workdir"),
205 Self::Escapes => format!("{noun} cannot escape the workdir via '..'"),
206 Self::Prefixed => format!("{noun} must not contain absolute or prefixed components"),
207 }
208 }
209}
210
211pub(crate) fn lower_workdir_subpath(raw: &str) -> Result<PathBuf, WorkdirSubpathRejection> {
219 let path = Path::new(raw);
220 if path.is_absolute() {
221 return Err(WorkdirSubpathRejection::Absolute);
222 }
223
224 let mut normalized = PathBuf::new();
225 for component in path.components() {
226 match component {
227 PathComponent::CurDir => {}
228 PathComponent::Normal(part) => normalized.push(part),
229 PathComponent::ParentDir => {
231 if !normalized.pop() {
232 return Err(WorkdirSubpathRejection::Escapes);
233 }
234 }
235 PathComponent::Prefix(_) | PathComponent::RootDir => {
236 return Err(WorkdirSubpathRejection::Prefixed);
237 }
238 }
239 }
240
241 Ok(normalized)
242}
243
244pub(crate) fn validate_filesystem_scope(scope: &str) -> Result<(), RuntimeError> {
245 lower_workdir_subpath(scope)
246 .map(|_| ())
247 .map_err(|rejection| RuntimeError::InvalidFilesystemScope {
248 scope: scope.to_string(),
249 message: rejection.message("scope"),
250 })
251}
252
253fn declared_filesystem_scope(
259 capabilities: &murmur_artifact::Capabilities,
260) -> Result<Option<String>, RuntimeError> {
261 let scope = capabilities
262 .filesystem
263 .as_ref()
264 .and_then(|filesystem| filesystem.scope.clone());
265 if let Some(scope) = scope.as_deref() {
266 validate_filesystem_scope(scope)?;
267 }
268 Ok(scope)
269}
270
271fn declares_records(capabilities: &murmur_artifact::Capabilities) -> bool {
273 capabilities
274 .filesystem
275 .as_ref()
276 .is_some_and(|filesystem| filesystem.records)
277}
278
279pub(crate) fn resolve_scoped_dir(root: &Path, scope: &str) -> Result<PathBuf, RuntimeError> {
287 let scoped_dir = root.join(scope);
288 std::fs::create_dir_all(&scoped_dir).map_err(|err| {
289 RuntimeError::wasi(
290 scoped_dir.clone(),
291 format!("failed to create granted filesystem scope '{scope}': {err}"),
292 )
293 })?;
294 Ok(scoped_dir)
295}
296
297#[derive(Debug, Clone, Default, PartialEq, Eq)]
308pub(crate) struct HookCapabilityGrant {
309 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
311 pub(crate) filesystem_scope: Option<String>,
314 pub(crate) filesystem_records: bool,
318 pub(crate) records_hooks_dir: Option<PathBuf>,
322 pub(crate) task_io_read: bool,
326 pub(crate) conversation_read: bool,
330 pub(crate) state_store: Option<String>,
333 pub(crate) state_dir: Option<PathBuf>,
338 pub(crate) config_json: Option<String>,
348 pub(crate) deadline_seconds: Option<u64>,
352}
353
354impl HookCapabilityGrant {
355 pub(crate) fn derive(
367 capabilities: Option<&murmur_artifact::Capabilities>,
368 capsule_name: &str,
369 ) -> Result<Self, RuntimeError> {
370 let Some(capabilities) = capabilities else {
371 return Ok(Self::default());
372 };
373
374 let network_allow_rules = match capabilities.network.as_ref() {
375 Some(network) => parse_network_allow_rules(&network.allow)?,
376 None => Vec::new(),
377 };
378
379 let filesystem_scope = declared_filesystem_scope(capabilities)?;
380 let filesystem_records = declares_records(capabilities);
381
382 Ok(Self {
383 network_allow_rules,
384 filesystem_scope,
385 filesystem_records,
386 records_hooks_dir: None,
387 task_io_read: capabilities
388 .task_io
389 .as_ref()
390 .is_some_and(|task_io| task_io.read),
391 conversation_read: capabilities
392 .conversation
393 .as_ref()
394 .is_some_and(|conversation| conversation.read),
395 state_store: derive_state_store(capabilities, capsule_name)?,
396 state_dir: None,
397 config_json: None,
398 deadline_seconds: capabilities
399 .limits
400 .as_ref()
401 .and_then(|limits| limits.deadline_seconds),
402 })
403 }
404}
405
406fn derive_state_store(
413 capabilities: &murmur_artifact::Capabilities,
414 capsule_name: &str,
415) -> Result<Option<String>, RuntimeError> {
416 let Some(state) = capabilities.state.as_ref() else {
417 return Ok(None);
418 };
419 let store = crate::state_store::resolve_store_name(state, capsule_name);
420 crate::state_store::validate_store_name(&store)?;
421 Ok(Some(store))
422}
423
424#[derive(Debug, Clone, Default, PartialEq, Eq)]
458pub(crate) struct ToolCapabilityGrant {
459 pub(crate) network_allow_rules: Option<Vec<NetworkAllowRule>>,
464 pub(crate) filesystem_scope: Option<String>,
468 pub(crate) dropped_network_entries: Vec<String>,
472 pub(crate) state_store: Option<String>,
479 pub(crate) state_dir: Option<PathBuf>,
483 pub(crate) config_json: Option<String>,
493}
494
495impl ToolCapabilityGrant {
496 pub(crate) fn derive(
510 capabilities: Option<&murmur_artifact::Capabilities>,
511 ceiling_network_allow_rules: &[NetworkAllowRule],
512 capsule_name: &str,
513 ) -> Result<Self, RuntimeError> {
514 let Some(capabilities) = capabilities else {
515 return Ok(Self::default());
516 };
517
518 let mut dropped_network_entries = Vec::new();
519 let network_allow_rules = match capabilities.network.as_ref() {
520 None => None,
521 Some(network) => {
522 let mut kept = Vec::new();
523 for entry in &network.allow {
524 let rule = NetworkAllowRule::parse(entry)?;
525 if ceiling_network_allow_rules
526 .iter()
527 .any(|ceiling_rule| ceiling_rule.covers(&rule))
528 {
529 kept.push(rule);
530 } else {
531 dropped_network_entries.push(entry.clone());
532 }
533 }
534 Some(kept)
535 }
536 };
537
538 let filesystem_scope = declared_filesystem_scope(capabilities)?;
539
540 Ok(Self {
541 network_allow_rules,
542 filesystem_scope,
543 dropped_network_entries,
544 state_store: derive_state_store(capabilities, capsule_name)?,
545 state_dir: None,
546 config_json: None,
547 })
548 }
549}
550
551pub fn preopen_reports<'a, I>(artifacts: I) -> Result<Vec<PreopenReport>, RuntimeError>
571where
572 I: IntoIterator<
573 Item = (
574 &'a str,
575 &'a murmur_artifact::ArtifactRuntime,
576 Option<&'a murmur_artifact::Capabilities>,
577 ),
578 >,
579{
580 let mut reports = Vec::new();
581
582 for (name, runtime, capabilities) in artifacts {
583 if matches!(runtime, murmur_artifact::ArtifactRuntime::Skill) {
584 continue;
585 }
586
587 let scope = capabilities
588 .map(declared_filesystem_scope)
589 .transpose()?
590 .flatten();
591
592 let records = matches!(runtime, murmur_artifact::ArtifactRuntime::Hook)
593 && capabilities.is_some_and(declares_records);
594 let surface = match (runtime, scope.is_some()) {
595 (_, true) => PreopenSurface::ScopedSubtree,
598 (murmur_artifact::ArtifactRuntime::Hook, false) if records => {
599 PreopenSurface::RecordsHooks
600 }
601 (murmur_artifact::ArtifactRuntime::Hook, false) => PreopenSurface::Nothing,
602 (_, false) => PreopenSurface::WholeWorkdir,
603 };
604
605 let runtime_dir_reach =
608 crate::runtime_dir::gets_runtime_dir_preopen(runtime, scope.is_some())
609 .then_some(crate::containment::RuntimeDirReach::Unresolved);
610 reports.push(PreopenReport {
611 artifact: name.to_string(),
612 role: runtime.as_str().to_string(),
613 scope,
614 surface,
615 runtime_dir_reach,
616 });
617 }
618
619 Ok(reports)
620}
621
622pub(crate) fn effective_tool_network_rules<'a>(
630 grant: Option<&'a ToolCapabilityGrant>,
631 ceiling_network_allow_rules: &'a [NetworkAllowRule],
632) -> &'a [NetworkAllowRule] {
633 grant
634 .and_then(|grant| grant.network_allow_rules.as_deref())
635 .unwrap_or(ceiling_network_allow_rules)
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use murmur_artifact::{Capabilities, FilesystemCapabilities, NetworkCapabilities};
642
643 fn capabilities_block(
645 network: Option<Vec<&str>>,
646 filesystem_scope: Option<&str>,
647 ) -> Capabilities {
648 Capabilities {
649 peer_fetch: None,
650 network: network.map(|allow| NetworkCapabilities {
651 allow: allow.into_iter().map(str::to_string).collect(),
652 unix_sockets: false,
653 }),
654 filesystem: filesystem_scope.map(|scope| FilesystemCapabilities {
655 scope: Some(scope.to_string()),
656 workdir_exec: false,
657 read_only: Vec::new(),
658 records: false,
659 }),
660 shell: None,
661 spawn: None,
662 env: None,
663 limits: None,
664 resources: None,
665 state: None,
666 task_io: None,
667 conversation: None,
668 plan: None,
669 install: None,
670 containment: None,
671 }
672 }
673
674 #[test]
677 fn hook_grant_defaults_to_deny_network_and_filesystem() {
678 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
679
680 assert_eq!(grant, HookCapabilityGrant::default());
681 assert!(grant.network_allow_rules.is_empty());
682 assert!(grant.filesystem_scope.is_none());
683
684 let target = RequestTarget {
685 scheme: "https".to_string(),
686 host: "telemetry.example.com".to_string(),
687 port: Some(443),
688 };
689 assert!(!grant
690 .network_allow_rules
691 .iter()
692 .any(|rule| rule.matches(&target)));
693 }
694
695 #[test]
698 fn hook_grant_carries_the_entrys_deadline() {
699 let mut caps = capabilities_block(None, None);
700 assert_eq!(
701 HookCapabilityGrant::derive(Some(&caps), "test-capsule")
702 .unwrap()
703 .deadline_seconds,
704 None
705 );
706 caps.limits = Some(murmur_artifact::ResourceLimits {
707 memory_bytes: Some(1 << 20),
708 table_elements: None,
709 instances: None,
710 deadline_seconds: Some(2),
711 });
712 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
713 assert_eq!(grant.deadline_seconds, Some(2));
714 }
715
716 #[test]
719 fn hook_grant_empty_capabilities_block_grants_nothing() {
720 let grant =
721 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
722 .unwrap();
723
724 assert_eq!(grant, HookCapabilityGrant::default());
725 }
726
727 #[test]
728 fn hook_grant_network_allows_exactly_the_declared_host() {
729 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
730 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
731
732 let allowed = RequestTarget {
733 scheme: "https".to_string(),
734 host: "telemetry.example.com".to_string(),
735 port: Some(443),
736 };
737 let other = RequestTarget {
738 scheme: "https".to_string(),
739 host: "evil.example.com".to_string(),
740 port: Some(443),
741 };
742
743 assert!(grant
744 .network_allow_rules
745 .iter()
746 .any(|rule| rule.matches(&allowed)));
747 assert!(!grant
748 .network_allow_rules
749 .iter()
750 .any(|rule| rule.matches(&other)));
751 assert!(grant.filesystem_scope.is_none());
753 }
754
755 #[test]
756 fn hook_grant_filesystem_scope_is_carried_through() {
757 let caps = capabilities_block(None, Some("hook-state"));
758 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
759
760 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
761 assert!(grant.network_allow_rules.is_empty());
763 }
764
765 #[test]
768 fn hook_grant_task_io_read_is_carried_through_independently() {
769 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
770 let caps = murmur_artifact::Capabilities {
771 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
772 conversation: None,
773 plan: None,
774 ..capabilities_block(None, None)
775 };
776 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
777 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
778 assert!(grant.network_allow_rules.is_empty());
779 assert!(grant.filesystem_scope.is_none());
780 }
781 }
782
783 #[test]
786 fn hook_grant_conversation_read_is_carried_through_independently() {
787 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
788 let caps = murmur_artifact::Capabilities {
789 conversation: declared
790 .map(|read| murmur_artifact::ConversationCapabilities { read }),
791 ..capabilities_block(None, None)
792 };
793 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
794 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
795 assert!(!grant.task_io_read, "one grant never implies the other");
796 assert!(grant.network_allow_rules.is_empty());
797 assert!(grant.filesystem_scope.is_none());
798 }
799 }
800
801 #[test]
804 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
805 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
806 let caps = Capabilities {
807 state: Some(murmur_artifact::StateCapabilities {
808 store: declared.map(str::to_string),
809 }),
810 ..capabilities_block(None, None)
811 };
812
813 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
814 let tool =
815 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
816
817 assert_eq!(hook.state_store.as_deref(), Some(expected));
818 assert_eq!(tool.state_store.as_deref(), Some(expected));
819 assert!(hook.state_dir.is_none());
821 assert!(tool.state_dir.is_none());
822 assert!(hook.filesystem_scope.is_none());
824 assert!(hook.network_allow_rules.is_empty());
825 assert!(tool.filesystem_scope.is_none());
826 assert!(tool.network_allow_rules.is_none());
827 }
828 }
829
830 #[test]
833 fn an_absent_state_block_grants_no_store_to_either_role() {
834 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
835
836 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
837 .unwrap()
838 .state_store
839 .is_none());
840 assert!(
841 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
842 .unwrap()
843 .state_store
844 .is_none()
845 );
846 }
847
848 #[test]
851 fn a_malformed_store_name_fails_lowering_for_both_roles() {
852 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
853 let caps = Capabilities {
854 state: Some(murmur_artifact::StateCapabilities {
855 store: Some(store.to_string()),
856 }),
857 ..capabilities_block(None, None)
858 };
859
860 for err in [
861 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
862 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
863 ] {
864 assert!(
865 matches!(err, RuntimeError::InvalidStateStore { .. }),
866 "store '{store}' must refuse as InvalidStateStore, got: {err}"
867 );
868 }
869 }
870 }
871
872 #[test]
875 fn an_empty_capsule_name_cannot_become_a_store_name() {
876 let caps = Capabilities {
877 state: Some(murmur_artifact::StateCapabilities { store: None }),
878 ..capabilities_block(None, None)
879 };
880
881 assert!(matches!(
882 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
883 Err(RuntimeError::InvalidStateStore { .. })
884 ));
885 }
886
887 #[test]
888 fn hook_grant_rejects_escaping_filesystem_scope() {
889 for scope in ["../escape", "/etc"] {
890 let caps = capabilities_block(None, Some(scope));
891 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
892 assert!(
893 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
894 "scope {scope} should fail staging, got: {err}"
895 );
896 }
897 }
898
899 #[test]
900 fn hook_grant_rejects_malformed_network_entry() {
901 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
902 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
903
904 assert!(
905 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
906 "got: {err}"
907 );
908 }
909
910 fn ceiling() -> Vec<NetworkAllowRule> {
913 parse_network_allow_rules(&[
914 "https://api.example.com".to_string(),
915 "https://other.example.com".to_string(),
916 ])
917 .unwrap()
918 }
919
920 fn target(host: &str) -> RequestTarget {
921 RequestTarget {
922 scheme: "https".to_string(),
923 host: host.to_string(),
924 port: Some(443),
925 }
926 }
927
928 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
929 rules.iter().any(|rule| rule.matches(&target(host)))
930 }
931
932 #[test]
935 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
936 let ceiling = ceiling();
937 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
938
939 assert_eq!(grant, ToolCapabilityGrant::default());
940 assert!(grant.network_allow_rules.is_none());
941 assert!(grant.filesystem_scope.is_none());
942 assert!(grant.dropped_network_entries.is_empty());
943
944 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
945 assert_eq!(effective, ceiling.as_slice());
946 assert!(reaches(effective, "api.example.com"));
947 assert!(reaches(effective, "other.example.com"));
948 }
949
950 #[test]
952 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
953 let ceiling = ceiling();
954 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
955 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
956
957 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
958 assert!(reaches(effective, "api.example.com"));
959 assert!(!reaches(effective, "other.example.com"));
960 assert!(grant.dropped_network_entries.is_empty());
961 }
962
963 #[test]
966 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
967 let ceiling = ceiling();
968 let caps = capabilities_block(
969 Some(vec!["https://api.example.com", "https://evil.example.com"]),
970 None,
971 );
972 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
973
974 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
975 assert!(reaches(effective, "api.example.com"));
976 assert!(!reaches(effective, "evil.example.com"));
977 assert_eq!(
978 grant.dropped_network_entries,
979 vec!["https://evil.example.com".to_string()]
980 );
981 }
982
983 #[test]
986 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
987 let ceiling = ceiling();
988 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
989 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
990
991 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
992 assert_eq!(
993 grant.dropped_network_entries,
994 vec!["api.example.com".to_string()]
995 );
996 }
997
998 #[test]
1001 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
1002 let ceiling = ceiling();
1003 let caps = capabilities_block(Some(vec![]), None);
1004 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1005
1006 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
1007 assert!(effective.is_empty());
1008 assert!(!reaches(effective, "api.example.com"));
1009 assert!(!reaches(effective, "other.example.com"));
1010 }
1011
1012 #[test]
1015 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
1016 let ceiling = ceiling();
1017 let caps = capabilities_block(None, Some("cache"));
1018 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1019
1020 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
1021 assert!(grant.network_allow_rules.is_none());
1022 assert_eq!(
1023 effective_tool_network_rules(Some(&grant), &ceiling),
1024 ceiling.as_slice()
1025 );
1026 }
1027
1028 #[test]
1029 fn tool_grant_rejects_escaping_filesystem_scope() {
1030 for scope in ["../escape", "/etc"] {
1031 let caps = capabilities_block(None, Some(scope));
1032 let err =
1033 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1034 assert!(
1035 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
1036 "scope {scope} should fail staging, got: {err}"
1037 );
1038 }
1039 }
1040
1041 #[test]
1042 fn tool_grant_rejects_malformed_network_entry() {
1043 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
1044 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1045
1046 assert!(
1047 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
1048 "got: {err}"
1049 );
1050 }
1051
1052 #[test]
1055 fn tool_grant_cannot_widen_an_empty_ceiling() {
1056 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1057 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
1058
1059 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1060 assert_eq!(
1061 grant.dropped_network_entries,
1062 vec!["https://api.example.com".to_string()]
1063 );
1064 }
1065
1066 #[test]
1068 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1069 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1070 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1071 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1072
1073 assert!(grant.dropped_network_entries.is_empty());
1074 assert!(reaches(
1075 effective_tool_network_rules(Some(&grant), &ceiling),
1076 "api.example.com"
1077 ));
1078 }
1079
1080 #[test]
1081 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1082 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1083 assert!(
1084 validate_filesystem_scope(scope).is_ok(),
1085 "scope should be valid: {scope}"
1086 );
1087 }
1088 }
1089
1090 #[test]
1091 fn filesystem_scope_validation_rejects_escape_paths() {
1092 for scope in ["../x", "../../x", "/tmp"] {
1093 assert!(
1094 validate_filesystem_scope(scope).is_err(),
1095 "scope should be invalid: {scope}"
1096 );
1097 }
1098 }
1099
1100 #[test]
1101 fn network_allowlist_empty_denies_all_requests() {
1102 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1103
1104 let target = RequestTarget {
1105 scheme: "https".to_string(),
1106 host: "blocked.example.com".to_string(),
1107 port: Some(443),
1108 };
1109
1110 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1111 }
1112
1113 #[test]
1114 fn network_allowlist_matches_allowed_host_with_default_port() {
1115 let rules =
1116 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1117 let target = RequestTarget {
1118 scheme: "https".to_string(),
1119 host: "allowed.example.com".to_string(),
1120 port: Some(443),
1121 };
1122
1123 assert!(rules.iter().any(|rule| rule.matches(&target)));
1124 }
1125
1126 #[test]
1127 fn network_allowlist_blocks_non_matching_host() {
1128 let rules =
1129 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1130 let target = RequestTarget {
1131 scheme: "https".to_string(),
1132 host: "blocked.example.com".to_string(),
1133 port: Some(443),
1134 };
1135
1136 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1137 }
1138
1139 #[test]
1140 fn network_allowlist_normalizes_default_ports() {
1141 let rules = parse_network_allow_rules(&[
1142 "https://allowed.example.com".to_string(),
1143 "allowed-two.example.com:8080".to_string(),
1144 ])
1145 .unwrap();
1146
1147 let https_default = RequestTarget {
1148 scheme: "https".to_string(),
1149 host: "allowed.example.com".to_string(),
1150 port: Some(443),
1151 };
1152 let custom_port = RequestTarget {
1153 scheme: "http".to_string(),
1154 host: "allowed-two.example.com".to_string(),
1155 port: Some(8080),
1156 };
1157
1158 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1159 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1160 }
1161
1162 #[test]
1166 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1167 let tool = murmur_artifact::ArtifactRuntime::Tool;
1168 let driver = murmur_artifact::ArtifactRuntime::Driver;
1169 let hook = murmur_artifact::ArtifactRuntime::Hook;
1170
1171 let reports = preopen_reports(vec![
1172 ("notes-tool", &tool, None),
1173 ("anthropic-driver", &driver, None),
1174 ("telemetry-hook", &hook, None),
1175 ])
1176 .unwrap();
1177
1178 assert_eq!(
1179 reports,
1180 vec![
1181 PreopenReport {
1182 artifact: "notes-tool".to_string(),
1183 role: "tool".to_string(),
1184 scope: None,
1185 surface: PreopenSurface::WholeWorkdir,
1186 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::Unresolved),
1187 },
1188 PreopenReport {
1189 artifact: "anthropic-driver".to_string(),
1190 role: "driver".to_string(),
1191 scope: None,
1192 surface: PreopenSurface::WholeWorkdir,
1193 runtime_dir_reach: None,
1194 },
1195 PreopenReport {
1196 artifact: "telemetry-hook".to_string(),
1197 role: "hook".to_string(),
1198 scope: None,
1199 surface: PreopenSurface::Nothing,
1200 runtime_dir_reach: None,
1201 },
1202 ]
1203 );
1204 }
1205
1206 #[test]
1209 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1210 let tool = murmur_artifact::ArtifactRuntime::Tool;
1211 let hook = murmur_artifact::ArtifactRuntime::Hook;
1212 let tool_caps = capabilities_block(None, Some("cache"));
1213 let hook_caps = capabilities_block(None, Some("hook-state"));
1214
1215 let reports = preopen_reports(vec![
1216 ("notes-tool", &tool, Some(&tool_caps)),
1217 ("telemetry-hook", &hook, Some(&hook_caps)),
1218 ])
1219 .unwrap();
1220
1221 assert_eq!(
1222 reports,
1223 vec![
1224 PreopenReport {
1225 artifact: "notes-tool".to_string(),
1226 role: "tool".to_string(),
1227 scope: Some("cache".to_string()),
1228 surface: PreopenSurface::ScopedSubtree,
1229 runtime_dir_reach: None,
1230 },
1231 PreopenReport {
1232 artifact: "telemetry-hook".to_string(),
1233 role: "hook".to_string(),
1234 scope: Some("hook-state".to_string()),
1235 surface: PreopenSurface::ScopedSubtree,
1236 runtime_dir_reach: None,
1237 },
1238 ]
1239 );
1240 }
1241
1242 #[test]
1245 fn a_skill_produces_no_entry() {
1246 let skill = murmur_artifact::ArtifactRuntime::Skill;
1247 let tool = murmur_artifact::ArtifactRuntime::Tool;
1248
1249 let reports = preopen_reports(vec![
1250 ("notes-skill", &skill, None),
1251 ("notes-tool", &tool, None),
1252 ])
1253 .unwrap();
1254
1255 assert_eq!(reports.len(), 1);
1256 assert_eq!(reports[0].artifact, "notes-tool");
1257 }
1258
1259 #[test]
1262 fn an_escaping_scope_is_refused() {
1263 let tool = murmur_artifact::ArtifactRuntime::Tool;
1264 let caps = capabilities_block(None, Some("../escape"));
1265
1266 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1267
1268 assert!(
1269 matches!(
1270 err,
1271 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1272 ),
1273 "unexpected error: {err}"
1274 );
1275 assert!(
1276 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1277 "the grant derivation must refuse what the report refuses"
1278 );
1279 }
1280
1281 #[test]
1284 fn preopen_surfaces_serialize_to_stable_wire_names() {
1285 for (surface, wire) in [
1286 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1287 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1288 (PreopenSurface::Nothing, "nothing"),
1289 ] {
1290 assert_eq!(
1291 serde_json::to_value(surface).unwrap(),
1292 serde_json::Value::String(wire.to_string())
1293 );
1294 }
1295 }
1296}