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
271pub(crate) fn resolve_scoped_dir(root: &Path, scope: &str) -> Result<PathBuf, RuntimeError> {
279 let scoped_dir = root.join(scope);
280 std::fs::create_dir_all(&scoped_dir).map_err(|err| {
281 RuntimeError::wasi(
282 scoped_dir.clone(),
283 format!("failed to create granted filesystem scope '{scope}': {err}"),
284 )
285 })?;
286 Ok(scoped_dir)
287}
288
289#[derive(Debug, Clone, Default, PartialEq, Eq)]
300pub(crate) struct HookCapabilityGrant {
301 pub(crate) network_allow_rules: Vec<NetworkAllowRule>,
303 pub(crate) filesystem_scope: Option<String>,
306 pub(crate) task_io_read: bool,
310 pub(crate) conversation_read: bool,
314 pub(crate) state_store: Option<String>,
317 pub(crate) state_dir: Option<PathBuf>,
322 pub(crate) config_json: Option<String>,
332}
333
334impl HookCapabilityGrant {
335 pub(crate) fn derive(
347 capabilities: Option<&murmur_artifact::Capabilities>,
348 capsule_name: &str,
349 ) -> Result<Self, RuntimeError> {
350 let Some(capabilities) = capabilities else {
351 return Ok(Self::default());
352 };
353
354 let network_allow_rules = match capabilities.network.as_ref() {
355 Some(network) => parse_network_allow_rules(&network.allow)?,
356 None => Vec::new(),
357 };
358
359 let filesystem_scope = declared_filesystem_scope(capabilities)?;
360
361 Ok(Self {
362 network_allow_rules,
363 filesystem_scope,
364 task_io_read: capabilities
365 .task_io
366 .as_ref()
367 .is_some_and(|task_io| task_io.read),
368 conversation_read: capabilities
369 .conversation
370 .as_ref()
371 .is_some_and(|conversation| conversation.read),
372 state_store: derive_state_store(capabilities, capsule_name)?,
373 state_dir: None,
374 config_json: None,
375 })
376 }
377}
378
379fn derive_state_store(
386 capabilities: &murmur_artifact::Capabilities,
387 capsule_name: &str,
388) -> Result<Option<String>, RuntimeError> {
389 let Some(state) = capabilities.state.as_ref() else {
390 return Ok(None);
391 };
392 let store = crate::state_store::resolve_store_name(state, capsule_name);
393 crate::state_store::validate_store_name(&store)?;
394 Ok(Some(store))
395}
396
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
431pub(crate) struct ToolCapabilityGrant {
432 pub(crate) network_allow_rules: Option<Vec<NetworkAllowRule>>,
437 pub(crate) filesystem_scope: Option<String>,
441 pub(crate) dropped_network_entries: Vec<String>,
445 pub(crate) state_store: Option<String>,
452 pub(crate) state_dir: Option<PathBuf>,
456 pub(crate) config_json: Option<String>,
466}
467
468impl ToolCapabilityGrant {
469 pub(crate) fn derive(
483 capabilities: Option<&murmur_artifact::Capabilities>,
484 ceiling_network_allow_rules: &[NetworkAllowRule],
485 capsule_name: &str,
486 ) -> Result<Self, RuntimeError> {
487 let Some(capabilities) = capabilities else {
488 return Ok(Self::default());
489 };
490
491 let mut dropped_network_entries = Vec::new();
492 let network_allow_rules = match capabilities.network.as_ref() {
493 None => None,
494 Some(network) => {
495 let mut kept = Vec::new();
496 for entry in &network.allow {
497 let rule = NetworkAllowRule::parse(entry)?;
498 if ceiling_network_allow_rules
499 .iter()
500 .any(|ceiling_rule| ceiling_rule.covers(&rule))
501 {
502 kept.push(rule);
503 } else {
504 dropped_network_entries.push(entry.clone());
505 }
506 }
507 Some(kept)
508 }
509 };
510
511 let filesystem_scope = declared_filesystem_scope(capabilities)?;
512
513 Ok(Self {
514 network_allow_rules,
515 filesystem_scope,
516 dropped_network_entries,
517 state_store: derive_state_store(capabilities, capsule_name)?,
518 state_dir: None,
519 config_json: None,
520 })
521 }
522}
523
524pub fn preopen_reports<'a, I>(artifacts: I) -> Result<Vec<PreopenReport>, RuntimeError>
544where
545 I: IntoIterator<
546 Item = (
547 &'a str,
548 &'a murmur_artifact::ArtifactRuntime,
549 Option<&'a murmur_artifact::Capabilities>,
550 ),
551 >,
552{
553 let mut reports = Vec::new();
554
555 for (name, runtime, capabilities) in artifacts {
556 if matches!(runtime, murmur_artifact::ArtifactRuntime::Skill) {
557 continue;
558 }
559
560 let scope = capabilities
561 .map(declared_filesystem_scope)
562 .transpose()?
563 .flatten();
564
565 let surface = match (runtime, scope.is_some()) {
566 (_, true) => PreopenSurface::ScopedSubtree,
569 (murmur_artifact::ArtifactRuntime::Hook, false) => PreopenSurface::Nothing,
570 (_, false) => PreopenSurface::WholeWorkdir,
571 };
572
573 reports.push(PreopenReport {
574 artifact: name.to_string(),
575 role: runtime.as_str().to_string(),
576 scope,
577 surface,
578 });
579 }
580
581 Ok(reports)
582}
583
584pub(crate) fn effective_tool_network_rules<'a>(
592 grant: Option<&'a ToolCapabilityGrant>,
593 ceiling_network_allow_rules: &'a [NetworkAllowRule],
594) -> &'a [NetworkAllowRule] {
595 grant
596 .and_then(|grant| grant.network_allow_rules.as_deref())
597 .unwrap_or(ceiling_network_allow_rules)
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use murmur_artifact::{Capabilities, FilesystemCapabilities, NetworkCapabilities};
604
605 fn capabilities_block(
607 network: Option<Vec<&str>>,
608 filesystem_scope: Option<&str>,
609 ) -> Capabilities {
610 Capabilities {
611 peer_fetch: None,
612 network: network.map(|allow| NetworkCapabilities {
613 allow: allow.into_iter().map(str::to_string).collect(),
614 unix_sockets: false,
615 }),
616 filesystem: filesystem_scope.map(|scope| FilesystemCapabilities {
617 scope: Some(scope.to_string()),
618 workdir_exec: false,
619 read_only: Vec::new(),
620 }),
621 shell: None,
622 spawn: None,
623 env: None,
624 limits: None,
625 resources: None,
626 state: None,
627 task_io: None,
628 conversation: None,
629 plan: None,
630 containment: None,
631 }
632 }
633
634 #[test]
637 fn hook_grant_defaults_to_deny_network_and_filesystem() {
638 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
639
640 assert_eq!(grant, HookCapabilityGrant::default());
641 assert!(grant.network_allow_rules.is_empty());
642 assert!(grant.filesystem_scope.is_none());
643
644 let target = RequestTarget {
645 scheme: "https".to_string(),
646 host: "telemetry.example.com".to_string(),
647 port: Some(443),
648 };
649 assert!(!grant
650 .network_allow_rules
651 .iter()
652 .any(|rule| rule.matches(&target)));
653 }
654
655 #[test]
658 fn hook_grant_empty_capabilities_block_grants_nothing() {
659 let grant =
660 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
661 .unwrap();
662
663 assert_eq!(grant, HookCapabilityGrant::default());
664 }
665
666 #[test]
667 fn hook_grant_network_allows_exactly_the_declared_host() {
668 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
669 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
670
671 let allowed = RequestTarget {
672 scheme: "https".to_string(),
673 host: "telemetry.example.com".to_string(),
674 port: Some(443),
675 };
676 let other = RequestTarget {
677 scheme: "https".to_string(),
678 host: "evil.example.com".to_string(),
679 port: Some(443),
680 };
681
682 assert!(grant
683 .network_allow_rules
684 .iter()
685 .any(|rule| rule.matches(&allowed)));
686 assert!(!grant
687 .network_allow_rules
688 .iter()
689 .any(|rule| rule.matches(&other)));
690 assert!(grant.filesystem_scope.is_none());
692 }
693
694 #[test]
695 fn hook_grant_filesystem_scope_is_carried_through() {
696 let caps = capabilities_block(None, Some("hook-state"));
697 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
698
699 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
700 assert!(grant.network_allow_rules.is_empty());
702 }
703
704 #[test]
707 fn hook_grant_task_io_read_is_carried_through_independently() {
708 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
709 let caps = murmur_artifact::Capabilities {
710 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
711 conversation: None,
712 plan: None,
713 ..capabilities_block(None, None)
714 };
715 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
716 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
717 assert!(grant.network_allow_rules.is_empty());
718 assert!(grant.filesystem_scope.is_none());
719 }
720 }
721
722 #[test]
725 fn hook_grant_conversation_read_is_carried_through_independently() {
726 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
727 let caps = murmur_artifact::Capabilities {
728 conversation: declared
729 .map(|read| murmur_artifact::ConversationCapabilities { read }),
730 ..capabilities_block(None, None)
731 };
732 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
733 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
734 assert!(!grant.task_io_read, "one grant never implies the other");
735 assert!(grant.network_allow_rules.is_empty());
736 assert!(grant.filesystem_scope.is_none());
737 }
738 }
739
740 #[test]
743 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
744 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
745 let caps = Capabilities {
746 state: Some(murmur_artifact::StateCapabilities {
747 store: declared.map(str::to_string),
748 }),
749 ..capabilities_block(None, None)
750 };
751
752 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
753 let tool =
754 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
755
756 assert_eq!(hook.state_store.as_deref(), Some(expected));
757 assert_eq!(tool.state_store.as_deref(), Some(expected));
758 assert!(hook.state_dir.is_none());
760 assert!(tool.state_dir.is_none());
761 assert!(hook.filesystem_scope.is_none());
763 assert!(hook.network_allow_rules.is_empty());
764 assert!(tool.filesystem_scope.is_none());
765 assert!(tool.network_allow_rules.is_none());
766 }
767 }
768
769 #[test]
772 fn an_absent_state_block_grants_no_store_to_either_role() {
773 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
774
775 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
776 .unwrap()
777 .state_store
778 .is_none());
779 assert!(
780 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
781 .unwrap()
782 .state_store
783 .is_none()
784 );
785 }
786
787 #[test]
790 fn a_malformed_store_name_fails_lowering_for_both_roles() {
791 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
792 let caps = Capabilities {
793 state: Some(murmur_artifact::StateCapabilities {
794 store: Some(store.to_string()),
795 }),
796 ..capabilities_block(None, None)
797 };
798
799 for err in [
800 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
801 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
802 ] {
803 assert!(
804 matches!(err, RuntimeError::InvalidStateStore { .. }),
805 "store '{store}' must refuse as InvalidStateStore, got: {err}"
806 );
807 }
808 }
809 }
810
811 #[test]
814 fn an_empty_capsule_name_cannot_become_a_store_name() {
815 let caps = Capabilities {
816 state: Some(murmur_artifact::StateCapabilities { store: None }),
817 ..capabilities_block(None, None)
818 };
819
820 assert!(matches!(
821 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
822 Err(RuntimeError::InvalidStateStore { .. })
823 ));
824 }
825
826 #[test]
827 fn hook_grant_rejects_escaping_filesystem_scope() {
828 for scope in ["../escape", "/etc"] {
829 let caps = capabilities_block(None, Some(scope));
830 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
831 assert!(
832 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
833 "scope {scope} should fail staging, got: {err}"
834 );
835 }
836 }
837
838 #[test]
839 fn hook_grant_rejects_malformed_network_entry() {
840 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
841 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
842
843 assert!(
844 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
845 "got: {err}"
846 );
847 }
848
849 fn ceiling() -> Vec<NetworkAllowRule> {
852 parse_network_allow_rules(&[
853 "https://api.example.com".to_string(),
854 "https://other.example.com".to_string(),
855 ])
856 .unwrap()
857 }
858
859 fn target(host: &str) -> RequestTarget {
860 RequestTarget {
861 scheme: "https".to_string(),
862 host: host.to_string(),
863 port: Some(443),
864 }
865 }
866
867 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
868 rules.iter().any(|rule| rule.matches(&target(host)))
869 }
870
871 #[test]
874 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
875 let ceiling = ceiling();
876 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
877
878 assert_eq!(grant, ToolCapabilityGrant::default());
879 assert!(grant.network_allow_rules.is_none());
880 assert!(grant.filesystem_scope.is_none());
881 assert!(grant.dropped_network_entries.is_empty());
882
883 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
884 assert_eq!(effective, ceiling.as_slice());
885 assert!(reaches(effective, "api.example.com"));
886 assert!(reaches(effective, "other.example.com"));
887 }
888
889 #[test]
891 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
892 let ceiling = ceiling();
893 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
894 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
895
896 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
897 assert!(reaches(effective, "api.example.com"));
898 assert!(!reaches(effective, "other.example.com"));
899 assert!(grant.dropped_network_entries.is_empty());
900 }
901
902 #[test]
905 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
906 let ceiling = ceiling();
907 let caps = capabilities_block(
908 Some(vec!["https://api.example.com", "https://evil.example.com"]),
909 None,
910 );
911 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
912
913 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
914 assert!(reaches(effective, "api.example.com"));
915 assert!(!reaches(effective, "evil.example.com"));
916 assert_eq!(
917 grant.dropped_network_entries,
918 vec!["https://evil.example.com".to_string()]
919 );
920 }
921
922 #[test]
925 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
926 let ceiling = ceiling();
927 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
928 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
929
930 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
931 assert_eq!(
932 grant.dropped_network_entries,
933 vec!["api.example.com".to_string()]
934 );
935 }
936
937 #[test]
940 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
941 let ceiling = ceiling();
942 let caps = capabilities_block(Some(vec![]), None);
943 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
944
945 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
946 assert!(effective.is_empty());
947 assert!(!reaches(effective, "api.example.com"));
948 assert!(!reaches(effective, "other.example.com"));
949 }
950
951 #[test]
954 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
955 let ceiling = ceiling();
956 let caps = capabilities_block(None, Some("cache"));
957 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
958
959 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
960 assert!(grant.network_allow_rules.is_none());
961 assert_eq!(
962 effective_tool_network_rules(Some(&grant), &ceiling),
963 ceiling.as_slice()
964 );
965 }
966
967 #[test]
968 fn tool_grant_rejects_escaping_filesystem_scope() {
969 for scope in ["../escape", "/etc"] {
970 let caps = capabilities_block(None, Some(scope));
971 let err =
972 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
973 assert!(
974 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
975 "scope {scope} should fail staging, got: {err}"
976 );
977 }
978 }
979
980 #[test]
981 fn tool_grant_rejects_malformed_network_entry() {
982 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
983 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
984
985 assert!(
986 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
987 "got: {err}"
988 );
989 }
990
991 #[test]
994 fn tool_grant_cannot_widen_an_empty_ceiling() {
995 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
996 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
997
998 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
999 assert_eq!(
1000 grant.dropped_network_entries,
1001 vec!["https://api.example.com".to_string()]
1002 );
1003 }
1004
1005 #[test]
1007 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1008 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1009 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1010 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1011
1012 assert!(grant.dropped_network_entries.is_empty());
1013 assert!(reaches(
1014 effective_tool_network_rules(Some(&grant), &ceiling),
1015 "api.example.com"
1016 ));
1017 }
1018
1019 #[test]
1020 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1021 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1022 assert!(
1023 validate_filesystem_scope(scope).is_ok(),
1024 "scope should be valid: {scope}"
1025 );
1026 }
1027 }
1028
1029 #[test]
1030 fn filesystem_scope_validation_rejects_escape_paths() {
1031 for scope in ["../x", "../../x", "/tmp"] {
1032 assert!(
1033 validate_filesystem_scope(scope).is_err(),
1034 "scope should be invalid: {scope}"
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn network_allowlist_empty_denies_all_requests() {
1041 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1042
1043 let target = RequestTarget {
1044 scheme: "https".to_string(),
1045 host: "blocked.example.com".to_string(),
1046 port: Some(443),
1047 };
1048
1049 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1050 }
1051
1052 #[test]
1053 fn network_allowlist_matches_allowed_host_with_default_port() {
1054 let rules =
1055 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1056 let target = RequestTarget {
1057 scheme: "https".to_string(),
1058 host: "allowed.example.com".to_string(),
1059 port: Some(443),
1060 };
1061
1062 assert!(rules.iter().any(|rule| rule.matches(&target)));
1063 }
1064
1065 #[test]
1066 fn network_allowlist_blocks_non_matching_host() {
1067 let rules =
1068 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1069 let target = RequestTarget {
1070 scheme: "https".to_string(),
1071 host: "blocked.example.com".to_string(),
1072 port: Some(443),
1073 };
1074
1075 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1076 }
1077
1078 #[test]
1079 fn network_allowlist_normalizes_default_ports() {
1080 let rules = parse_network_allow_rules(&[
1081 "https://allowed.example.com".to_string(),
1082 "allowed-two.example.com:8080".to_string(),
1083 ])
1084 .unwrap();
1085
1086 let https_default = RequestTarget {
1087 scheme: "https".to_string(),
1088 host: "allowed.example.com".to_string(),
1089 port: Some(443),
1090 };
1091 let custom_port = RequestTarget {
1092 scheme: "http".to_string(),
1093 host: "allowed-two.example.com".to_string(),
1094 port: Some(8080),
1095 };
1096
1097 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1098 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1099 }
1100
1101 #[test]
1105 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1106 let tool = murmur_artifact::ArtifactRuntime::Tool;
1107 let driver = murmur_artifact::ArtifactRuntime::Driver;
1108 let hook = murmur_artifact::ArtifactRuntime::Hook;
1109
1110 let reports = preopen_reports(vec![
1111 ("notes-tool", &tool, None),
1112 ("anthropic-driver", &driver, None),
1113 ("telemetry-hook", &hook, None),
1114 ])
1115 .unwrap();
1116
1117 assert_eq!(
1118 reports,
1119 vec![
1120 PreopenReport {
1121 artifact: "notes-tool".to_string(),
1122 role: "tool".to_string(),
1123 scope: None,
1124 surface: PreopenSurface::WholeWorkdir,
1125 },
1126 PreopenReport {
1127 artifact: "anthropic-driver".to_string(),
1128 role: "driver".to_string(),
1129 scope: None,
1130 surface: PreopenSurface::WholeWorkdir,
1131 },
1132 PreopenReport {
1133 artifact: "telemetry-hook".to_string(),
1134 role: "hook".to_string(),
1135 scope: None,
1136 surface: PreopenSurface::Nothing,
1137 },
1138 ]
1139 );
1140 }
1141
1142 #[test]
1145 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1146 let tool = murmur_artifact::ArtifactRuntime::Tool;
1147 let hook = murmur_artifact::ArtifactRuntime::Hook;
1148 let tool_caps = capabilities_block(None, Some("cache"));
1149 let hook_caps = capabilities_block(None, Some("hook-state"));
1150
1151 let reports = preopen_reports(vec![
1152 ("notes-tool", &tool, Some(&tool_caps)),
1153 ("telemetry-hook", &hook, Some(&hook_caps)),
1154 ])
1155 .unwrap();
1156
1157 assert_eq!(
1158 reports,
1159 vec![
1160 PreopenReport {
1161 artifact: "notes-tool".to_string(),
1162 role: "tool".to_string(),
1163 scope: Some("cache".to_string()),
1164 surface: PreopenSurface::ScopedSubtree,
1165 },
1166 PreopenReport {
1167 artifact: "telemetry-hook".to_string(),
1168 role: "hook".to_string(),
1169 scope: Some("hook-state".to_string()),
1170 surface: PreopenSurface::ScopedSubtree,
1171 },
1172 ]
1173 );
1174 }
1175
1176 #[test]
1179 fn a_skill_produces_no_entry() {
1180 let skill = murmur_artifact::ArtifactRuntime::Skill;
1181 let tool = murmur_artifact::ArtifactRuntime::Tool;
1182
1183 let reports = preopen_reports(vec![
1184 ("notes-skill", &skill, None),
1185 ("notes-tool", &tool, None),
1186 ])
1187 .unwrap();
1188
1189 assert_eq!(reports.len(), 1);
1190 assert_eq!(reports[0].artifact, "notes-tool");
1191 }
1192
1193 #[test]
1196 fn an_escaping_scope_is_refused() {
1197 let tool = murmur_artifact::ArtifactRuntime::Tool;
1198 let caps = capabilities_block(None, Some("../escape"));
1199
1200 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1201
1202 assert!(
1203 matches!(
1204 err,
1205 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1206 ),
1207 "unexpected error: {err}"
1208 );
1209 assert!(
1210 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1211 "the grant derivation must refuse what the report refuses"
1212 );
1213 }
1214
1215 #[test]
1218 fn preopen_surfaces_serialize_to_stable_wire_names() {
1219 for (surface, wire) in [
1220 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1221 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1222 (PreopenSurface::Nothing, "nothing"),
1223 ] {
1224 assert_eq!(
1225 serde_json::to_value(surface).unwrap(),
1226 serde_json::Value::String(wire.to_string())
1227 );
1228 }
1229 }
1230}