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 install: None,
631 containment: None,
632 }
633 }
634
635 #[test]
638 fn hook_grant_defaults_to_deny_network_and_filesystem() {
639 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
640
641 assert_eq!(grant, HookCapabilityGrant::default());
642 assert!(grant.network_allow_rules.is_empty());
643 assert!(grant.filesystem_scope.is_none());
644
645 let target = RequestTarget {
646 scheme: "https".to_string(),
647 host: "telemetry.example.com".to_string(),
648 port: Some(443),
649 };
650 assert!(!grant
651 .network_allow_rules
652 .iter()
653 .any(|rule| rule.matches(&target)));
654 }
655
656 #[test]
659 fn hook_grant_empty_capabilities_block_grants_nothing() {
660 let grant =
661 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
662 .unwrap();
663
664 assert_eq!(grant, HookCapabilityGrant::default());
665 }
666
667 #[test]
668 fn hook_grant_network_allows_exactly_the_declared_host() {
669 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
670 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
671
672 let allowed = RequestTarget {
673 scheme: "https".to_string(),
674 host: "telemetry.example.com".to_string(),
675 port: Some(443),
676 };
677 let other = RequestTarget {
678 scheme: "https".to_string(),
679 host: "evil.example.com".to_string(),
680 port: Some(443),
681 };
682
683 assert!(grant
684 .network_allow_rules
685 .iter()
686 .any(|rule| rule.matches(&allowed)));
687 assert!(!grant
688 .network_allow_rules
689 .iter()
690 .any(|rule| rule.matches(&other)));
691 assert!(grant.filesystem_scope.is_none());
693 }
694
695 #[test]
696 fn hook_grant_filesystem_scope_is_carried_through() {
697 let caps = capabilities_block(None, Some("hook-state"));
698 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
699
700 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
701 assert!(grant.network_allow_rules.is_empty());
703 }
704
705 #[test]
708 fn hook_grant_task_io_read_is_carried_through_independently() {
709 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
710 let caps = murmur_artifact::Capabilities {
711 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
712 conversation: None,
713 plan: None,
714 ..capabilities_block(None, None)
715 };
716 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
717 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
718 assert!(grant.network_allow_rules.is_empty());
719 assert!(grant.filesystem_scope.is_none());
720 }
721 }
722
723 #[test]
726 fn hook_grant_conversation_read_is_carried_through_independently() {
727 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
728 let caps = murmur_artifact::Capabilities {
729 conversation: declared
730 .map(|read| murmur_artifact::ConversationCapabilities { read }),
731 ..capabilities_block(None, None)
732 };
733 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
734 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
735 assert!(!grant.task_io_read, "one grant never implies the other");
736 assert!(grant.network_allow_rules.is_empty());
737 assert!(grant.filesystem_scope.is_none());
738 }
739 }
740
741 #[test]
744 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
745 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
746 let caps = Capabilities {
747 state: Some(murmur_artifact::StateCapabilities {
748 store: declared.map(str::to_string),
749 }),
750 ..capabilities_block(None, None)
751 };
752
753 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
754 let tool =
755 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
756
757 assert_eq!(hook.state_store.as_deref(), Some(expected));
758 assert_eq!(tool.state_store.as_deref(), Some(expected));
759 assert!(hook.state_dir.is_none());
761 assert!(tool.state_dir.is_none());
762 assert!(hook.filesystem_scope.is_none());
764 assert!(hook.network_allow_rules.is_empty());
765 assert!(tool.filesystem_scope.is_none());
766 assert!(tool.network_allow_rules.is_none());
767 }
768 }
769
770 #[test]
773 fn an_absent_state_block_grants_no_store_to_either_role() {
774 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
775
776 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
777 .unwrap()
778 .state_store
779 .is_none());
780 assert!(
781 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
782 .unwrap()
783 .state_store
784 .is_none()
785 );
786 }
787
788 #[test]
791 fn a_malformed_store_name_fails_lowering_for_both_roles() {
792 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
793 let caps = Capabilities {
794 state: Some(murmur_artifact::StateCapabilities {
795 store: Some(store.to_string()),
796 }),
797 ..capabilities_block(None, None)
798 };
799
800 for err in [
801 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
802 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
803 ] {
804 assert!(
805 matches!(err, RuntimeError::InvalidStateStore { .. }),
806 "store '{store}' must refuse as InvalidStateStore, got: {err}"
807 );
808 }
809 }
810 }
811
812 #[test]
815 fn an_empty_capsule_name_cannot_become_a_store_name() {
816 let caps = Capabilities {
817 state: Some(murmur_artifact::StateCapabilities { store: None }),
818 ..capabilities_block(None, None)
819 };
820
821 assert!(matches!(
822 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
823 Err(RuntimeError::InvalidStateStore { .. })
824 ));
825 }
826
827 #[test]
828 fn hook_grant_rejects_escaping_filesystem_scope() {
829 for scope in ["../escape", "/etc"] {
830 let caps = capabilities_block(None, Some(scope));
831 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
832 assert!(
833 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
834 "scope {scope} should fail staging, got: {err}"
835 );
836 }
837 }
838
839 #[test]
840 fn hook_grant_rejects_malformed_network_entry() {
841 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
842 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
843
844 assert!(
845 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
846 "got: {err}"
847 );
848 }
849
850 fn ceiling() -> Vec<NetworkAllowRule> {
853 parse_network_allow_rules(&[
854 "https://api.example.com".to_string(),
855 "https://other.example.com".to_string(),
856 ])
857 .unwrap()
858 }
859
860 fn target(host: &str) -> RequestTarget {
861 RequestTarget {
862 scheme: "https".to_string(),
863 host: host.to_string(),
864 port: Some(443),
865 }
866 }
867
868 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
869 rules.iter().any(|rule| rule.matches(&target(host)))
870 }
871
872 #[test]
875 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
876 let ceiling = ceiling();
877 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
878
879 assert_eq!(grant, ToolCapabilityGrant::default());
880 assert!(grant.network_allow_rules.is_none());
881 assert!(grant.filesystem_scope.is_none());
882 assert!(grant.dropped_network_entries.is_empty());
883
884 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
885 assert_eq!(effective, ceiling.as_slice());
886 assert!(reaches(effective, "api.example.com"));
887 assert!(reaches(effective, "other.example.com"));
888 }
889
890 #[test]
892 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
893 let ceiling = ceiling();
894 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
895 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
896
897 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
898 assert!(reaches(effective, "api.example.com"));
899 assert!(!reaches(effective, "other.example.com"));
900 assert!(grant.dropped_network_entries.is_empty());
901 }
902
903 #[test]
906 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
907 let ceiling = ceiling();
908 let caps = capabilities_block(
909 Some(vec!["https://api.example.com", "https://evil.example.com"]),
910 None,
911 );
912 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
913
914 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
915 assert!(reaches(effective, "api.example.com"));
916 assert!(!reaches(effective, "evil.example.com"));
917 assert_eq!(
918 grant.dropped_network_entries,
919 vec!["https://evil.example.com".to_string()]
920 );
921 }
922
923 #[test]
926 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
927 let ceiling = ceiling();
928 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
929 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
930
931 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
932 assert_eq!(
933 grant.dropped_network_entries,
934 vec!["api.example.com".to_string()]
935 );
936 }
937
938 #[test]
941 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
942 let ceiling = ceiling();
943 let caps = capabilities_block(Some(vec![]), None);
944 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
945
946 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
947 assert!(effective.is_empty());
948 assert!(!reaches(effective, "api.example.com"));
949 assert!(!reaches(effective, "other.example.com"));
950 }
951
952 #[test]
955 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
956 let ceiling = ceiling();
957 let caps = capabilities_block(None, Some("cache"));
958 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
959
960 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
961 assert!(grant.network_allow_rules.is_none());
962 assert_eq!(
963 effective_tool_network_rules(Some(&grant), &ceiling),
964 ceiling.as_slice()
965 );
966 }
967
968 #[test]
969 fn tool_grant_rejects_escaping_filesystem_scope() {
970 for scope in ["../escape", "/etc"] {
971 let caps = capabilities_block(None, Some(scope));
972 let err =
973 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
974 assert!(
975 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
976 "scope {scope} should fail staging, got: {err}"
977 );
978 }
979 }
980
981 #[test]
982 fn tool_grant_rejects_malformed_network_entry() {
983 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
984 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
985
986 assert!(
987 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
988 "got: {err}"
989 );
990 }
991
992 #[test]
995 fn tool_grant_cannot_widen_an_empty_ceiling() {
996 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
997 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
998
999 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1000 assert_eq!(
1001 grant.dropped_network_entries,
1002 vec!["https://api.example.com".to_string()]
1003 );
1004 }
1005
1006 #[test]
1008 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1009 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1010 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1011 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1012
1013 assert!(grant.dropped_network_entries.is_empty());
1014 assert!(reaches(
1015 effective_tool_network_rules(Some(&grant), &ceiling),
1016 "api.example.com"
1017 ));
1018 }
1019
1020 #[test]
1021 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1022 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1023 assert!(
1024 validate_filesystem_scope(scope).is_ok(),
1025 "scope should be valid: {scope}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn filesystem_scope_validation_rejects_escape_paths() {
1032 for scope in ["../x", "../../x", "/tmp"] {
1033 assert!(
1034 validate_filesystem_scope(scope).is_err(),
1035 "scope should be invalid: {scope}"
1036 );
1037 }
1038 }
1039
1040 #[test]
1041 fn network_allowlist_empty_denies_all_requests() {
1042 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1043
1044 let target = RequestTarget {
1045 scheme: "https".to_string(),
1046 host: "blocked.example.com".to_string(),
1047 port: Some(443),
1048 };
1049
1050 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1051 }
1052
1053 #[test]
1054 fn network_allowlist_matches_allowed_host_with_default_port() {
1055 let rules =
1056 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1057 let target = RequestTarget {
1058 scheme: "https".to_string(),
1059 host: "allowed.example.com".to_string(),
1060 port: Some(443),
1061 };
1062
1063 assert!(rules.iter().any(|rule| rule.matches(&target)));
1064 }
1065
1066 #[test]
1067 fn network_allowlist_blocks_non_matching_host() {
1068 let rules =
1069 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1070 let target = RequestTarget {
1071 scheme: "https".to_string(),
1072 host: "blocked.example.com".to_string(),
1073 port: Some(443),
1074 };
1075
1076 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1077 }
1078
1079 #[test]
1080 fn network_allowlist_normalizes_default_ports() {
1081 let rules = parse_network_allow_rules(&[
1082 "https://allowed.example.com".to_string(),
1083 "allowed-two.example.com:8080".to_string(),
1084 ])
1085 .unwrap();
1086
1087 let https_default = RequestTarget {
1088 scheme: "https".to_string(),
1089 host: "allowed.example.com".to_string(),
1090 port: Some(443),
1091 };
1092 let custom_port = RequestTarget {
1093 scheme: "http".to_string(),
1094 host: "allowed-two.example.com".to_string(),
1095 port: Some(8080),
1096 };
1097
1098 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1099 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1100 }
1101
1102 #[test]
1106 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1107 let tool = murmur_artifact::ArtifactRuntime::Tool;
1108 let driver = murmur_artifact::ArtifactRuntime::Driver;
1109 let hook = murmur_artifact::ArtifactRuntime::Hook;
1110
1111 let reports = preopen_reports(vec![
1112 ("notes-tool", &tool, None),
1113 ("anthropic-driver", &driver, None),
1114 ("telemetry-hook", &hook, None),
1115 ])
1116 .unwrap();
1117
1118 assert_eq!(
1119 reports,
1120 vec![
1121 PreopenReport {
1122 artifact: "notes-tool".to_string(),
1123 role: "tool".to_string(),
1124 scope: None,
1125 surface: PreopenSurface::WholeWorkdir,
1126 },
1127 PreopenReport {
1128 artifact: "anthropic-driver".to_string(),
1129 role: "driver".to_string(),
1130 scope: None,
1131 surface: PreopenSurface::WholeWorkdir,
1132 },
1133 PreopenReport {
1134 artifact: "telemetry-hook".to_string(),
1135 role: "hook".to_string(),
1136 scope: None,
1137 surface: PreopenSurface::Nothing,
1138 },
1139 ]
1140 );
1141 }
1142
1143 #[test]
1146 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1147 let tool = murmur_artifact::ArtifactRuntime::Tool;
1148 let hook = murmur_artifact::ArtifactRuntime::Hook;
1149 let tool_caps = capabilities_block(None, Some("cache"));
1150 let hook_caps = capabilities_block(None, Some("hook-state"));
1151
1152 let reports = preopen_reports(vec![
1153 ("notes-tool", &tool, Some(&tool_caps)),
1154 ("telemetry-hook", &hook, Some(&hook_caps)),
1155 ])
1156 .unwrap();
1157
1158 assert_eq!(
1159 reports,
1160 vec![
1161 PreopenReport {
1162 artifact: "notes-tool".to_string(),
1163 role: "tool".to_string(),
1164 scope: Some("cache".to_string()),
1165 surface: PreopenSurface::ScopedSubtree,
1166 },
1167 PreopenReport {
1168 artifact: "telemetry-hook".to_string(),
1169 role: "hook".to_string(),
1170 scope: Some("hook-state".to_string()),
1171 surface: PreopenSurface::ScopedSubtree,
1172 },
1173 ]
1174 );
1175 }
1176
1177 #[test]
1180 fn a_skill_produces_no_entry() {
1181 let skill = murmur_artifact::ArtifactRuntime::Skill;
1182 let tool = murmur_artifact::ArtifactRuntime::Tool;
1183
1184 let reports = preopen_reports(vec![
1185 ("notes-skill", &skill, None),
1186 ("notes-tool", &tool, None),
1187 ])
1188 .unwrap();
1189
1190 assert_eq!(reports.len(), 1);
1191 assert_eq!(reports[0].artifact, "notes-tool");
1192 }
1193
1194 #[test]
1197 fn an_escaping_scope_is_refused() {
1198 let tool = murmur_artifact::ArtifactRuntime::Tool;
1199 let caps = capabilities_block(None, Some("../escape"));
1200
1201 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1202
1203 assert!(
1204 matches!(
1205 err,
1206 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1207 ),
1208 "unexpected error: {err}"
1209 );
1210 assert!(
1211 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1212 "the grant derivation must refuse what the report refuses"
1213 );
1214 }
1215
1216 #[test]
1219 fn preopen_surfaces_serialize_to_stable_wire_names() {
1220 for (surface, wire) in [
1221 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1222 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1223 (PreopenSurface::Nothing, "nothing"),
1224 ] {
1225 assert_eq!(
1226 serde_json::to_value(surface).unwrap(),
1227 serde_json::Value::String(wire.to_string())
1228 );
1229 }
1230 }
1231}