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
406pub(crate) fn derive_state_store(
414 capabilities: &murmur_artifact::Capabilities,
415 capsule_name: &str,
416) -> Result<Option<String>, RuntimeError> {
417 let Some(state) = capabilities.state.as_ref() else {
418 return Ok(None);
419 };
420 let store = crate::state_store::resolve_store_name(state, capsule_name);
421 crate::state_store::validate_store_name(&store)?;
422 Ok(Some(store))
423}
424
425#[derive(Debug, Clone, Default, PartialEq, Eq)]
459pub(crate) struct ToolCapabilityGrant {
460 pub(crate) network_allow_rules: Option<Vec<NetworkAllowRule>>,
465 pub(crate) filesystem_scope: Option<String>,
469 pub(crate) dropped_network_entries: Vec<String>,
473 pub(crate) state_store: Option<String>,
480 pub(crate) state_dir: Option<PathBuf>,
484 pub(crate) config_json: Option<String>,
494}
495
496impl ToolCapabilityGrant {
497 pub(crate) fn derive(
511 capabilities: Option<&murmur_artifact::Capabilities>,
512 ceiling_network_allow_rules: &[NetworkAllowRule],
513 capsule_name: &str,
514 ) -> Result<Self, RuntimeError> {
515 let Some(capabilities) = capabilities else {
516 return Ok(Self::default());
517 };
518
519 let mut dropped_network_entries = Vec::new();
520 let network_allow_rules = match capabilities.network.as_ref() {
521 None => None,
522 Some(network) => {
523 let mut kept = Vec::new();
524 for entry in &network.allow {
525 let rule = NetworkAllowRule::parse(entry)?;
526 if ceiling_network_allow_rules
527 .iter()
528 .any(|ceiling_rule| ceiling_rule.covers(&rule))
529 {
530 kept.push(rule);
531 } else {
532 dropped_network_entries.push(entry.clone());
533 }
534 }
535 Some(kept)
536 }
537 };
538
539 let filesystem_scope = declared_filesystem_scope(capabilities)?;
540
541 Ok(Self {
542 network_allow_rules,
543 filesystem_scope,
544 dropped_network_entries,
545 state_store: derive_state_store(capabilities, capsule_name)?,
546 state_dir: None,
547 config_json: None,
548 })
549 }
550}
551
552pub fn preopen_reports<'a, I>(artifacts: I) -> Result<Vec<PreopenReport>, RuntimeError>
572where
573 I: IntoIterator<
574 Item = (
575 &'a str,
576 &'a murmur_artifact::ArtifactRuntime,
577 Option<&'a murmur_artifact::Capabilities>,
578 ),
579 >,
580{
581 let mut reports = Vec::new();
582
583 for (name, runtime, capabilities) in artifacts {
584 if matches!(runtime, murmur_artifact::ArtifactRuntime::Skill) {
585 continue;
586 }
587
588 let scope = capabilities
589 .map(declared_filesystem_scope)
590 .transpose()?
591 .flatten();
592
593 let records = matches!(runtime, murmur_artifact::ArtifactRuntime::Hook)
594 && capabilities.is_some_and(declares_records);
595 let surface = match (runtime, scope.is_some()) {
596 (_, true) => PreopenSurface::ScopedSubtree,
599 (murmur_artifact::ArtifactRuntime::Hook, false) if records => {
600 PreopenSurface::RecordsHooks
601 }
602 (murmur_artifact::ArtifactRuntime::Hook, false) => PreopenSurface::Nothing,
603 (_, false) => PreopenSurface::WholeWorkdir,
604 };
605
606 let runtime_dir_reach =
607 crate::runtime_dir::gets_runtime_dir_preopen(runtime, scope.is_some())
608 .then_some(crate::containment::RuntimeDirReach::MurmurRuntime);
609 reports.push(PreopenReport {
610 artifact: name.to_string(),
611 role: runtime.as_str().to_string(),
612 scope,
613 surface,
614 runtime_dir_reach,
615 extra_roots: Vec::new(),
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 harness: None,
671 containment: None,
672 }
673 }
674
675 #[test]
678 fn hook_grant_defaults_to_deny_network_and_filesystem() {
679 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
680
681 assert_eq!(grant, HookCapabilityGrant::default());
682 assert!(grant.network_allow_rules.is_empty());
683 assert!(grant.filesystem_scope.is_none());
684
685 let target = RequestTarget {
686 scheme: "https".to_string(),
687 host: "telemetry.example.com".to_string(),
688 port: Some(443),
689 };
690 assert!(!grant
691 .network_allow_rules
692 .iter()
693 .any(|rule| rule.matches(&target)));
694 }
695
696 #[test]
699 fn hook_grant_carries_the_entrys_deadline() {
700 let mut caps = capabilities_block(None, None);
701 assert_eq!(
702 HookCapabilityGrant::derive(Some(&caps), "test-capsule")
703 .unwrap()
704 .deadline_seconds,
705 None
706 );
707 caps.limits = Some(murmur_artifact::ResourceLimits {
708 memory_bytes: Some(1 << 20),
709 table_elements: None,
710 instances: None,
711 deadline_seconds: Some(2),
712 });
713 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
714 assert_eq!(grant.deadline_seconds, Some(2));
715 }
716
717 #[test]
720 fn hook_grant_empty_capabilities_block_grants_nothing() {
721 let grant =
722 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
723 .unwrap();
724
725 assert_eq!(grant, HookCapabilityGrant::default());
726 }
727
728 #[test]
729 fn hook_grant_network_allows_exactly_the_declared_host() {
730 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
731 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
732
733 let allowed = RequestTarget {
734 scheme: "https".to_string(),
735 host: "telemetry.example.com".to_string(),
736 port: Some(443),
737 };
738 let other = RequestTarget {
739 scheme: "https".to_string(),
740 host: "evil.example.com".to_string(),
741 port: Some(443),
742 };
743
744 assert!(grant
745 .network_allow_rules
746 .iter()
747 .any(|rule| rule.matches(&allowed)));
748 assert!(!grant
749 .network_allow_rules
750 .iter()
751 .any(|rule| rule.matches(&other)));
752 assert!(grant.filesystem_scope.is_none());
754 }
755
756 #[test]
757 fn hook_grant_filesystem_scope_is_carried_through() {
758 let caps = capabilities_block(None, Some("hook-state"));
759 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
760
761 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
762 assert!(grant.network_allow_rules.is_empty());
764 }
765
766 #[test]
769 fn hook_grant_task_io_read_is_carried_through_independently() {
770 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
771 let caps = murmur_artifact::Capabilities {
772 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
773 conversation: None,
774 plan: None,
775 ..capabilities_block(None, None)
776 };
777 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
778 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
779 assert!(grant.network_allow_rules.is_empty());
780 assert!(grant.filesystem_scope.is_none());
781 }
782 }
783
784 #[test]
787 fn hook_grant_conversation_read_is_carried_through_independently() {
788 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
789 let caps = murmur_artifact::Capabilities {
790 conversation: declared
791 .map(|read| murmur_artifact::ConversationCapabilities { read }),
792 ..capabilities_block(None, None)
793 };
794 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
795 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
796 assert!(!grant.task_io_read, "one grant never implies the other");
797 assert!(grant.network_allow_rules.is_empty());
798 assert!(grant.filesystem_scope.is_none());
799 }
800 }
801
802 #[test]
805 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
806 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
807 let caps = Capabilities {
808 state: Some(murmur_artifact::StateCapabilities {
809 store: declared.map(str::to_string),
810 }),
811 ..capabilities_block(None, None)
812 };
813
814 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
815 let tool =
816 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
817
818 assert_eq!(hook.state_store.as_deref(), Some(expected));
819 assert_eq!(tool.state_store.as_deref(), Some(expected));
820 assert!(hook.state_dir.is_none());
822 assert!(tool.state_dir.is_none());
823 assert!(hook.filesystem_scope.is_none());
825 assert!(hook.network_allow_rules.is_empty());
826 assert!(tool.filesystem_scope.is_none());
827 assert!(tool.network_allow_rules.is_none());
828 }
829 }
830
831 #[test]
834 fn an_absent_state_block_grants_no_store_to_either_role() {
835 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
836
837 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
838 .unwrap()
839 .state_store
840 .is_none());
841 assert!(
842 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
843 .unwrap()
844 .state_store
845 .is_none()
846 );
847 }
848
849 #[test]
852 fn a_malformed_store_name_fails_lowering_for_both_roles() {
853 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
854 let caps = Capabilities {
855 state: Some(murmur_artifact::StateCapabilities {
856 store: Some(store.to_string()),
857 }),
858 ..capabilities_block(None, None)
859 };
860
861 for err in [
862 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
863 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
864 ] {
865 assert!(
866 matches!(err, RuntimeError::InvalidStateStore { .. }),
867 "store '{store}' must refuse as InvalidStateStore, got: {err}"
868 );
869 }
870 }
871 }
872
873 #[test]
876 fn an_empty_capsule_name_cannot_become_a_store_name() {
877 let caps = Capabilities {
878 state: Some(murmur_artifact::StateCapabilities { store: None }),
879 ..capabilities_block(None, None)
880 };
881
882 assert!(matches!(
883 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
884 Err(RuntimeError::InvalidStateStore { .. })
885 ));
886 }
887
888 #[test]
889 fn hook_grant_rejects_escaping_filesystem_scope() {
890 for scope in ["../escape", "/etc"] {
891 let caps = capabilities_block(None, Some(scope));
892 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
893 assert!(
894 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
895 "scope {scope} should fail staging, got: {err}"
896 );
897 }
898 }
899
900 #[test]
901 fn hook_grant_rejects_malformed_network_entry() {
902 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
903 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
904
905 assert!(
906 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
907 "got: {err}"
908 );
909 }
910
911 fn ceiling() -> Vec<NetworkAllowRule> {
914 parse_network_allow_rules(&[
915 "https://api.example.com".to_string(),
916 "https://other.example.com".to_string(),
917 ])
918 .unwrap()
919 }
920
921 fn target(host: &str) -> RequestTarget {
922 RequestTarget {
923 scheme: "https".to_string(),
924 host: host.to_string(),
925 port: Some(443),
926 }
927 }
928
929 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
930 rules.iter().any(|rule| rule.matches(&target(host)))
931 }
932
933 #[test]
936 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
937 let ceiling = ceiling();
938 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
939
940 assert_eq!(grant, ToolCapabilityGrant::default());
941 assert!(grant.network_allow_rules.is_none());
942 assert!(grant.filesystem_scope.is_none());
943 assert!(grant.dropped_network_entries.is_empty());
944
945 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
946 assert_eq!(effective, ceiling.as_slice());
947 assert!(reaches(effective, "api.example.com"));
948 assert!(reaches(effective, "other.example.com"));
949 }
950
951 #[test]
953 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
954 let ceiling = ceiling();
955 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
956 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
957
958 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
959 assert!(reaches(effective, "api.example.com"));
960 assert!(!reaches(effective, "other.example.com"));
961 assert!(grant.dropped_network_entries.is_empty());
962 }
963
964 #[test]
967 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
968 let ceiling = ceiling();
969 let caps = capabilities_block(
970 Some(vec!["https://api.example.com", "https://evil.example.com"]),
971 None,
972 );
973 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
974
975 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
976 assert!(reaches(effective, "api.example.com"));
977 assert!(!reaches(effective, "evil.example.com"));
978 assert_eq!(
979 grant.dropped_network_entries,
980 vec!["https://evil.example.com".to_string()]
981 );
982 }
983
984 #[test]
987 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
988 let ceiling = ceiling();
989 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
990 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
991
992 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
993 assert_eq!(
994 grant.dropped_network_entries,
995 vec!["api.example.com".to_string()]
996 );
997 }
998
999 #[test]
1002 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
1003 let ceiling = ceiling();
1004 let caps = capabilities_block(Some(vec![]), None);
1005 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1006
1007 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
1008 assert!(effective.is_empty());
1009 assert!(!reaches(effective, "api.example.com"));
1010 assert!(!reaches(effective, "other.example.com"));
1011 }
1012
1013 #[test]
1016 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
1017 let ceiling = ceiling();
1018 let caps = capabilities_block(None, Some("cache"));
1019 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1020
1021 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
1022 assert!(grant.network_allow_rules.is_none());
1023 assert_eq!(
1024 effective_tool_network_rules(Some(&grant), &ceiling),
1025 ceiling.as_slice()
1026 );
1027 }
1028
1029 #[test]
1030 fn tool_grant_rejects_escaping_filesystem_scope() {
1031 for scope in ["../escape", "/etc"] {
1032 let caps = capabilities_block(None, Some(scope));
1033 let err =
1034 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1035 assert!(
1036 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
1037 "scope {scope} should fail staging, got: {err}"
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn tool_grant_rejects_malformed_network_entry() {
1044 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
1045 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1046
1047 assert!(
1048 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
1049 "got: {err}"
1050 );
1051 }
1052
1053 #[test]
1056 fn tool_grant_cannot_widen_an_empty_ceiling() {
1057 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1058 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
1059
1060 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1061 assert_eq!(
1062 grant.dropped_network_entries,
1063 vec!["https://api.example.com".to_string()]
1064 );
1065 }
1066
1067 #[test]
1069 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1070 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1071 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1072 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1073
1074 assert!(grant.dropped_network_entries.is_empty());
1075 assert!(reaches(
1076 effective_tool_network_rules(Some(&grant), &ceiling),
1077 "api.example.com"
1078 ));
1079 }
1080
1081 #[test]
1082 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1083 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1084 assert!(
1085 validate_filesystem_scope(scope).is_ok(),
1086 "scope should be valid: {scope}"
1087 );
1088 }
1089 }
1090
1091 #[test]
1092 fn filesystem_scope_validation_rejects_escape_paths() {
1093 for scope in ["../x", "../../x", "/tmp"] {
1094 assert!(
1095 validate_filesystem_scope(scope).is_err(),
1096 "scope should be invalid: {scope}"
1097 );
1098 }
1099 }
1100
1101 #[test]
1102 fn network_allowlist_empty_denies_all_requests() {
1103 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1104
1105 let target = RequestTarget {
1106 scheme: "https".to_string(),
1107 host: "blocked.example.com".to_string(),
1108 port: Some(443),
1109 };
1110
1111 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1112 }
1113
1114 #[test]
1115 fn network_allowlist_matches_allowed_host_with_default_port() {
1116 let rules =
1117 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1118 let target = RequestTarget {
1119 scheme: "https".to_string(),
1120 host: "allowed.example.com".to_string(),
1121 port: Some(443),
1122 };
1123
1124 assert!(rules.iter().any(|rule| rule.matches(&target)));
1125 }
1126
1127 #[test]
1128 fn network_allowlist_blocks_non_matching_host() {
1129 let rules =
1130 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1131 let target = RequestTarget {
1132 scheme: "https".to_string(),
1133 host: "blocked.example.com".to_string(),
1134 port: Some(443),
1135 };
1136
1137 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1138 }
1139
1140 #[test]
1141 fn network_allowlist_normalizes_default_ports() {
1142 let rules = parse_network_allow_rules(&[
1143 "https://allowed.example.com".to_string(),
1144 "allowed-two.example.com:8080".to_string(),
1145 ])
1146 .unwrap();
1147
1148 let https_default = RequestTarget {
1149 scheme: "https".to_string(),
1150 host: "allowed.example.com".to_string(),
1151 port: Some(443),
1152 };
1153 let custom_port = RequestTarget {
1154 scheme: "http".to_string(),
1155 host: "allowed-two.example.com".to_string(),
1156 port: Some(8080),
1157 };
1158
1159 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1160 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1161 }
1162
1163 #[test]
1167 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1168 let tool = murmur_artifact::ArtifactRuntime::Tool;
1169 let driver = murmur_artifact::ArtifactRuntime::Driver;
1170 let hook = murmur_artifact::ArtifactRuntime::Hook;
1171
1172 let reports = preopen_reports(vec![
1173 ("notes-tool", &tool, None),
1174 ("anthropic-driver", &driver, None),
1175 ("telemetry-hook", &hook, None),
1176 ])
1177 .unwrap();
1178
1179 assert_eq!(
1180 reports,
1181 vec![
1182 PreopenReport {
1183 artifact: "notes-tool".to_string(),
1184 role: "tool".to_string(),
1185 scope: None,
1186 surface: PreopenSurface::WholeWorkdir,
1187 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::MurmurRuntime),
1188 extra_roots: Vec::new(),
1189 },
1190 PreopenReport {
1191 artifact: "anthropic-driver".to_string(),
1192 role: "driver".to_string(),
1193 scope: None,
1194 surface: PreopenSurface::WholeWorkdir,
1195 runtime_dir_reach: None,
1196 extra_roots: Vec::new(),
1197 },
1198 PreopenReport {
1199 artifact: "telemetry-hook".to_string(),
1200 role: "hook".to_string(),
1201 scope: None,
1202 surface: PreopenSurface::Nothing,
1203 runtime_dir_reach: None,
1204 extra_roots: Vec::new(),
1205 },
1206 ]
1207 );
1208 }
1209
1210 #[test]
1213 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1214 let tool = murmur_artifact::ArtifactRuntime::Tool;
1215 let hook = murmur_artifact::ArtifactRuntime::Hook;
1216 let tool_caps = capabilities_block(None, Some("cache"));
1217 let hook_caps = capabilities_block(None, Some("hook-state"));
1218
1219 let reports = preopen_reports(vec![
1220 ("notes-tool", &tool, Some(&tool_caps)),
1221 ("telemetry-hook", &hook, Some(&hook_caps)),
1222 ])
1223 .unwrap();
1224
1225 assert_eq!(
1226 reports,
1227 vec![
1228 PreopenReport {
1229 artifact: "notes-tool".to_string(),
1230 role: "tool".to_string(),
1231 scope: Some("cache".to_string()),
1232 surface: PreopenSurface::ScopedSubtree,
1233 runtime_dir_reach: None,
1234 extra_roots: Vec::new(),
1235 },
1236 PreopenReport {
1237 artifact: "telemetry-hook".to_string(),
1238 role: "hook".to_string(),
1239 scope: Some("hook-state".to_string()),
1240 surface: PreopenSurface::ScopedSubtree,
1241 runtime_dir_reach: None,
1242 extra_roots: Vec::new(),
1243 },
1244 ]
1245 );
1246 }
1247
1248 #[test]
1251 fn a_skill_produces_no_entry() {
1252 let skill = murmur_artifact::ArtifactRuntime::Skill;
1253 let tool = murmur_artifact::ArtifactRuntime::Tool;
1254
1255 let reports = preopen_reports(vec![
1256 ("notes-skill", &skill, None),
1257 ("notes-tool", &tool, None),
1258 ])
1259 .unwrap();
1260
1261 assert_eq!(reports.len(), 1);
1262 assert_eq!(reports[0].artifact, "notes-tool");
1263 }
1264
1265 #[test]
1268 fn an_escaping_scope_is_refused() {
1269 let tool = murmur_artifact::ArtifactRuntime::Tool;
1270 let caps = capabilities_block(None, Some("../escape"));
1271
1272 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1273
1274 assert!(
1275 matches!(
1276 err,
1277 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1278 ),
1279 "unexpected error: {err}"
1280 );
1281 assert!(
1282 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1283 "the grant derivation must refuse what the report refuses"
1284 );
1285 }
1286
1287 #[test]
1290 fn preopen_surfaces_serialize_to_stable_wire_names() {
1291 for (surface, wire) in [
1292 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1293 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1294 (PreopenSurface::Nothing, "nothing"),
1295 ] {
1296 assert_eq!(
1297 serde_json::to_value(surface).unwrap(),
1298 serde_json::Value::String(wire.to_string())
1299 );
1300 }
1301 }
1302}