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}
349
350impl HookCapabilityGrant {
351 pub(crate) fn derive(
363 capabilities: Option<&murmur_artifact::Capabilities>,
364 capsule_name: &str,
365 ) -> Result<Self, RuntimeError> {
366 let Some(capabilities) = capabilities else {
367 return Ok(Self::default());
368 };
369
370 let network_allow_rules = match capabilities.network.as_ref() {
371 Some(network) => parse_network_allow_rules(&network.allow)?,
372 None => Vec::new(),
373 };
374
375 let filesystem_scope = declared_filesystem_scope(capabilities)?;
376 let filesystem_records = declares_records(capabilities);
377
378 Ok(Self {
379 network_allow_rules,
380 filesystem_scope,
381 filesystem_records,
382 records_hooks_dir: None,
383 task_io_read: capabilities
384 .task_io
385 .as_ref()
386 .is_some_and(|task_io| task_io.read),
387 conversation_read: capabilities
388 .conversation
389 .as_ref()
390 .is_some_and(|conversation| conversation.read),
391 state_store: derive_state_store(capabilities, capsule_name)?,
392 state_dir: None,
393 config_json: None,
394 })
395 }
396}
397
398fn derive_state_store(
405 capabilities: &murmur_artifact::Capabilities,
406 capsule_name: &str,
407) -> Result<Option<String>, RuntimeError> {
408 let Some(state) = capabilities.state.as_ref() else {
409 return Ok(None);
410 };
411 let store = crate::state_store::resolve_store_name(state, capsule_name);
412 crate::state_store::validate_store_name(&store)?;
413 Ok(Some(store))
414}
415
416#[derive(Debug, Clone, Default, PartialEq, Eq)]
450pub(crate) struct ToolCapabilityGrant {
451 pub(crate) network_allow_rules: Option<Vec<NetworkAllowRule>>,
456 pub(crate) filesystem_scope: Option<String>,
460 pub(crate) dropped_network_entries: Vec<String>,
464 pub(crate) state_store: Option<String>,
471 pub(crate) state_dir: Option<PathBuf>,
475 pub(crate) config_json: Option<String>,
485}
486
487impl ToolCapabilityGrant {
488 pub(crate) fn derive(
502 capabilities: Option<&murmur_artifact::Capabilities>,
503 ceiling_network_allow_rules: &[NetworkAllowRule],
504 capsule_name: &str,
505 ) -> Result<Self, RuntimeError> {
506 let Some(capabilities) = capabilities else {
507 return Ok(Self::default());
508 };
509
510 let mut dropped_network_entries = Vec::new();
511 let network_allow_rules = match capabilities.network.as_ref() {
512 None => None,
513 Some(network) => {
514 let mut kept = Vec::new();
515 for entry in &network.allow {
516 let rule = NetworkAllowRule::parse(entry)?;
517 if ceiling_network_allow_rules
518 .iter()
519 .any(|ceiling_rule| ceiling_rule.covers(&rule))
520 {
521 kept.push(rule);
522 } else {
523 dropped_network_entries.push(entry.clone());
524 }
525 }
526 Some(kept)
527 }
528 };
529
530 let filesystem_scope = declared_filesystem_scope(capabilities)?;
531
532 Ok(Self {
533 network_allow_rules,
534 filesystem_scope,
535 dropped_network_entries,
536 state_store: derive_state_store(capabilities, capsule_name)?,
537 state_dir: None,
538 config_json: None,
539 })
540 }
541}
542
543pub fn preopen_reports<'a, I>(artifacts: I) -> Result<Vec<PreopenReport>, RuntimeError>
563where
564 I: IntoIterator<
565 Item = (
566 &'a str,
567 &'a murmur_artifact::ArtifactRuntime,
568 Option<&'a murmur_artifact::Capabilities>,
569 ),
570 >,
571{
572 let mut reports = Vec::new();
573
574 for (name, runtime, capabilities) in artifacts {
575 if matches!(runtime, murmur_artifact::ArtifactRuntime::Skill) {
576 continue;
577 }
578
579 let scope = capabilities
580 .map(declared_filesystem_scope)
581 .transpose()?
582 .flatten();
583
584 let records = matches!(runtime, murmur_artifact::ArtifactRuntime::Hook)
585 && capabilities.is_some_and(declares_records);
586 let surface = match (runtime, scope.is_some()) {
587 (_, true) => PreopenSurface::ScopedSubtree,
590 (murmur_artifact::ArtifactRuntime::Hook, false) if records => {
591 PreopenSurface::RecordsHooks
592 }
593 (murmur_artifact::ArtifactRuntime::Hook, false) => PreopenSurface::Nothing,
594 (_, false) => PreopenSurface::WholeWorkdir,
595 };
596
597 let runtime_dir_reach =
600 crate::runtime_dir::gets_runtime_dir_preopen(runtime, scope.is_some())
601 .then_some(crate::containment::RuntimeDirReach::Unresolved);
602 reports.push(PreopenReport {
603 artifact: name.to_string(),
604 role: runtime.as_str().to_string(),
605 scope,
606 surface,
607 runtime_dir_reach,
608 });
609 }
610
611 Ok(reports)
612}
613
614pub(crate) fn effective_tool_network_rules<'a>(
622 grant: Option<&'a ToolCapabilityGrant>,
623 ceiling_network_allow_rules: &'a [NetworkAllowRule],
624) -> &'a [NetworkAllowRule] {
625 grant
626 .and_then(|grant| grant.network_allow_rules.as_deref())
627 .unwrap_or(ceiling_network_allow_rules)
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use murmur_artifact::{Capabilities, FilesystemCapabilities, NetworkCapabilities};
634
635 fn capabilities_block(
637 network: Option<Vec<&str>>,
638 filesystem_scope: Option<&str>,
639 ) -> Capabilities {
640 Capabilities {
641 peer_fetch: None,
642 network: network.map(|allow| NetworkCapabilities {
643 allow: allow.into_iter().map(str::to_string).collect(),
644 unix_sockets: false,
645 }),
646 filesystem: filesystem_scope.map(|scope| FilesystemCapabilities {
647 scope: Some(scope.to_string()),
648 workdir_exec: false,
649 read_only: Vec::new(),
650 records: false,
651 }),
652 shell: None,
653 spawn: None,
654 env: None,
655 limits: None,
656 resources: None,
657 state: None,
658 task_io: None,
659 conversation: None,
660 plan: None,
661 install: None,
662 containment: None,
663 }
664 }
665
666 #[test]
669 fn hook_grant_defaults_to_deny_network_and_filesystem() {
670 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
671
672 assert_eq!(grant, HookCapabilityGrant::default());
673 assert!(grant.network_allow_rules.is_empty());
674 assert!(grant.filesystem_scope.is_none());
675
676 let target = RequestTarget {
677 scheme: "https".to_string(),
678 host: "telemetry.example.com".to_string(),
679 port: Some(443),
680 };
681 assert!(!grant
682 .network_allow_rules
683 .iter()
684 .any(|rule| rule.matches(&target)));
685 }
686
687 #[test]
690 fn hook_grant_empty_capabilities_block_grants_nothing() {
691 let grant =
692 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
693 .unwrap();
694
695 assert_eq!(grant, HookCapabilityGrant::default());
696 }
697
698 #[test]
699 fn hook_grant_network_allows_exactly_the_declared_host() {
700 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
701 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
702
703 let allowed = RequestTarget {
704 scheme: "https".to_string(),
705 host: "telemetry.example.com".to_string(),
706 port: Some(443),
707 };
708 let other = RequestTarget {
709 scheme: "https".to_string(),
710 host: "evil.example.com".to_string(),
711 port: Some(443),
712 };
713
714 assert!(grant
715 .network_allow_rules
716 .iter()
717 .any(|rule| rule.matches(&allowed)));
718 assert!(!grant
719 .network_allow_rules
720 .iter()
721 .any(|rule| rule.matches(&other)));
722 assert!(grant.filesystem_scope.is_none());
724 }
725
726 #[test]
727 fn hook_grant_filesystem_scope_is_carried_through() {
728 let caps = capabilities_block(None, Some("hook-state"));
729 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
730
731 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
732 assert!(grant.network_allow_rules.is_empty());
734 }
735
736 #[test]
739 fn hook_grant_task_io_read_is_carried_through_independently() {
740 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
741 let caps = murmur_artifact::Capabilities {
742 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
743 conversation: None,
744 plan: None,
745 ..capabilities_block(None, None)
746 };
747 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
748 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
749 assert!(grant.network_allow_rules.is_empty());
750 assert!(grant.filesystem_scope.is_none());
751 }
752 }
753
754 #[test]
757 fn hook_grant_conversation_read_is_carried_through_independently() {
758 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
759 let caps = murmur_artifact::Capabilities {
760 conversation: declared
761 .map(|read| murmur_artifact::ConversationCapabilities { read }),
762 ..capabilities_block(None, None)
763 };
764 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
765 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
766 assert!(!grant.task_io_read, "one grant never implies the other");
767 assert!(grant.network_allow_rules.is_empty());
768 assert!(grant.filesystem_scope.is_none());
769 }
770 }
771
772 #[test]
775 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
776 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
777 let caps = Capabilities {
778 state: Some(murmur_artifact::StateCapabilities {
779 store: declared.map(str::to_string),
780 }),
781 ..capabilities_block(None, None)
782 };
783
784 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
785 let tool =
786 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
787
788 assert_eq!(hook.state_store.as_deref(), Some(expected));
789 assert_eq!(tool.state_store.as_deref(), Some(expected));
790 assert!(hook.state_dir.is_none());
792 assert!(tool.state_dir.is_none());
793 assert!(hook.filesystem_scope.is_none());
795 assert!(hook.network_allow_rules.is_empty());
796 assert!(tool.filesystem_scope.is_none());
797 assert!(tool.network_allow_rules.is_none());
798 }
799 }
800
801 #[test]
804 fn an_absent_state_block_grants_no_store_to_either_role() {
805 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
806
807 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
808 .unwrap()
809 .state_store
810 .is_none());
811 assert!(
812 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
813 .unwrap()
814 .state_store
815 .is_none()
816 );
817 }
818
819 #[test]
822 fn a_malformed_store_name_fails_lowering_for_both_roles() {
823 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
824 let caps = Capabilities {
825 state: Some(murmur_artifact::StateCapabilities {
826 store: Some(store.to_string()),
827 }),
828 ..capabilities_block(None, None)
829 };
830
831 for err in [
832 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
833 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
834 ] {
835 assert!(
836 matches!(err, RuntimeError::InvalidStateStore { .. }),
837 "store '{store}' must refuse as InvalidStateStore, got: {err}"
838 );
839 }
840 }
841 }
842
843 #[test]
846 fn an_empty_capsule_name_cannot_become_a_store_name() {
847 let caps = Capabilities {
848 state: Some(murmur_artifact::StateCapabilities { store: None }),
849 ..capabilities_block(None, None)
850 };
851
852 assert!(matches!(
853 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
854 Err(RuntimeError::InvalidStateStore { .. })
855 ));
856 }
857
858 #[test]
859 fn hook_grant_rejects_escaping_filesystem_scope() {
860 for scope in ["../escape", "/etc"] {
861 let caps = capabilities_block(None, Some(scope));
862 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
863 assert!(
864 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
865 "scope {scope} should fail staging, got: {err}"
866 );
867 }
868 }
869
870 #[test]
871 fn hook_grant_rejects_malformed_network_entry() {
872 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
873 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
874
875 assert!(
876 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
877 "got: {err}"
878 );
879 }
880
881 fn ceiling() -> Vec<NetworkAllowRule> {
884 parse_network_allow_rules(&[
885 "https://api.example.com".to_string(),
886 "https://other.example.com".to_string(),
887 ])
888 .unwrap()
889 }
890
891 fn target(host: &str) -> RequestTarget {
892 RequestTarget {
893 scheme: "https".to_string(),
894 host: host.to_string(),
895 port: Some(443),
896 }
897 }
898
899 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
900 rules.iter().any(|rule| rule.matches(&target(host)))
901 }
902
903 #[test]
906 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
907 let ceiling = ceiling();
908 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
909
910 assert_eq!(grant, ToolCapabilityGrant::default());
911 assert!(grant.network_allow_rules.is_none());
912 assert!(grant.filesystem_scope.is_none());
913 assert!(grant.dropped_network_entries.is_empty());
914
915 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
916 assert_eq!(effective, ceiling.as_slice());
917 assert!(reaches(effective, "api.example.com"));
918 assert!(reaches(effective, "other.example.com"));
919 }
920
921 #[test]
923 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
924 let ceiling = ceiling();
925 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
926 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
927
928 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
929 assert!(reaches(effective, "api.example.com"));
930 assert!(!reaches(effective, "other.example.com"));
931 assert!(grant.dropped_network_entries.is_empty());
932 }
933
934 #[test]
937 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
938 let ceiling = ceiling();
939 let caps = capabilities_block(
940 Some(vec!["https://api.example.com", "https://evil.example.com"]),
941 None,
942 );
943 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
944
945 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
946 assert!(reaches(effective, "api.example.com"));
947 assert!(!reaches(effective, "evil.example.com"));
948 assert_eq!(
949 grant.dropped_network_entries,
950 vec!["https://evil.example.com".to_string()]
951 );
952 }
953
954 #[test]
957 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
958 let ceiling = ceiling();
959 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
960 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
961
962 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
963 assert_eq!(
964 grant.dropped_network_entries,
965 vec!["api.example.com".to_string()]
966 );
967 }
968
969 #[test]
972 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
973 let ceiling = ceiling();
974 let caps = capabilities_block(Some(vec![]), None);
975 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
976
977 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
978 assert!(effective.is_empty());
979 assert!(!reaches(effective, "api.example.com"));
980 assert!(!reaches(effective, "other.example.com"));
981 }
982
983 #[test]
986 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
987 let ceiling = ceiling();
988 let caps = capabilities_block(None, Some("cache"));
989 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
990
991 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
992 assert!(grant.network_allow_rules.is_none());
993 assert_eq!(
994 effective_tool_network_rules(Some(&grant), &ceiling),
995 ceiling.as_slice()
996 );
997 }
998
999 #[test]
1000 fn tool_grant_rejects_escaping_filesystem_scope() {
1001 for scope in ["../escape", "/etc"] {
1002 let caps = capabilities_block(None, Some(scope));
1003 let err =
1004 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1005 assert!(
1006 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
1007 "scope {scope} should fail staging, got: {err}"
1008 );
1009 }
1010 }
1011
1012 #[test]
1013 fn tool_grant_rejects_malformed_network_entry() {
1014 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
1015 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1016
1017 assert!(
1018 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
1019 "got: {err}"
1020 );
1021 }
1022
1023 #[test]
1026 fn tool_grant_cannot_widen_an_empty_ceiling() {
1027 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1028 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
1029
1030 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1031 assert_eq!(
1032 grant.dropped_network_entries,
1033 vec!["https://api.example.com".to_string()]
1034 );
1035 }
1036
1037 #[test]
1039 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1040 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1041 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1042 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1043
1044 assert!(grant.dropped_network_entries.is_empty());
1045 assert!(reaches(
1046 effective_tool_network_rules(Some(&grant), &ceiling),
1047 "api.example.com"
1048 ));
1049 }
1050
1051 #[test]
1052 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1053 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1054 assert!(
1055 validate_filesystem_scope(scope).is_ok(),
1056 "scope should be valid: {scope}"
1057 );
1058 }
1059 }
1060
1061 #[test]
1062 fn filesystem_scope_validation_rejects_escape_paths() {
1063 for scope in ["../x", "../../x", "/tmp"] {
1064 assert!(
1065 validate_filesystem_scope(scope).is_err(),
1066 "scope should be invalid: {scope}"
1067 );
1068 }
1069 }
1070
1071 #[test]
1072 fn network_allowlist_empty_denies_all_requests() {
1073 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1074
1075 let target = RequestTarget {
1076 scheme: "https".to_string(),
1077 host: "blocked.example.com".to_string(),
1078 port: Some(443),
1079 };
1080
1081 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1082 }
1083
1084 #[test]
1085 fn network_allowlist_matches_allowed_host_with_default_port() {
1086 let rules =
1087 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1088 let target = RequestTarget {
1089 scheme: "https".to_string(),
1090 host: "allowed.example.com".to_string(),
1091 port: Some(443),
1092 };
1093
1094 assert!(rules.iter().any(|rule| rule.matches(&target)));
1095 }
1096
1097 #[test]
1098 fn network_allowlist_blocks_non_matching_host() {
1099 let rules =
1100 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1101 let target = RequestTarget {
1102 scheme: "https".to_string(),
1103 host: "blocked.example.com".to_string(),
1104 port: Some(443),
1105 };
1106
1107 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1108 }
1109
1110 #[test]
1111 fn network_allowlist_normalizes_default_ports() {
1112 let rules = parse_network_allow_rules(&[
1113 "https://allowed.example.com".to_string(),
1114 "allowed-two.example.com:8080".to_string(),
1115 ])
1116 .unwrap();
1117
1118 let https_default = RequestTarget {
1119 scheme: "https".to_string(),
1120 host: "allowed.example.com".to_string(),
1121 port: Some(443),
1122 };
1123 let custom_port = RequestTarget {
1124 scheme: "http".to_string(),
1125 host: "allowed-two.example.com".to_string(),
1126 port: Some(8080),
1127 };
1128
1129 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1130 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1131 }
1132
1133 #[test]
1137 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1138 let tool = murmur_artifact::ArtifactRuntime::Tool;
1139 let driver = murmur_artifact::ArtifactRuntime::Driver;
1140 let hook = murmur_artifact::ArtifactRuntime::Hook;
1141
1142 let reports = preopen_reports(vec![
1143 ("notes-tool", &tool, None),
1144 ("anthropic-driver", &driver, None),
1145 ("telemetry-hook", &hook, None),
1146 ])
1147 .unwrap();
1148
1149 assert_eq!(
1150 reports,
1151 vec![
1152 PreopenReport {
1153 artifact: "notes-tool".to_string(),
1154 role: "tool".to_string(),
1155 scope: None,
1156 surface: PreopenSurface::WholeWorkdir,
1157 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::Unresolved),
1158 },
1159 PreopenReport {
1160 artifact: "anthropic-driver".to_string(),
1161 role: "driver".to_string(),
1162 scope: None,
1163 surface: PreopenSurface::WholeWorkdir,
1164 runtime_dir_reach: None,
1165 },
1166 PreopenReport {
1167 artifact: "telemetry-hook".to_string(),
1168 role: "hook".to_string(),
1169 scope: None,
1170 surface: PreopenSurface::Nothing,
1171 runtime_dir_reach: None,
1172 },
1173 ]
1174 );
1175 }
1176
1177 #[test]
1180 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1181 let tool = murmur_artifact::ArtifactRuntime::Tool;
1182 let hook = murmur_artifact::ArtifactRuntime::Hook;
1183 let tool_caps = capabilities_block(None, Some("cache"));
1184 let hook_caps = capabilities_block(None, Some("hook-state"));
1185
1186 let reports = preopen_reports(vec![
1187 ("notes-tool", &tool, Some(&tool_caps)),
1188 ("telemetry-hook", &hook, Some(&hook_caps)),
1189 ])
1190 .unwrap();
1191
1192 assert_eq!(
1193 reports,
1194 vec![
1195 PreopenReport {
1196 artifact: "notes-tool".to_string(),
1197 role: "tool".to_string(),
1198 scope: Some("cache".to_string()),
1199 surface: PreopenSurface::ScopedSubtree,
1200 runtime_dir_reach: None,
1201 },
1202 PreopenReport {
1203 artifact: "telemetry-hook".to_string(),
1204 role: "hook".to_string(),
1205 scope: Some("hook-state".to_string()),
1206 surface: PreopenSurface::ScopedSubtree,
1207 runtime_dir_reach: None,
1208 },
1209 ]
1210 );
1211 }
1212
1213 #[test]
1216 fn a_skill_produces_no_entry() {
1217 let skill = murmur_artifact::ArtifactRuntime::Skill;
1218 let tool = murmur_artifact::ArtifactRuntime::Tool;
1219
1220 let reports = preopen_reports(vec![
1221 ("notes-skill", &skill, None),
1222 ("notes-tool", &tool, None),
1223 ])
1224 .unwrap();
1225
1226 assert_eq!(reports.len(), 1);
1227 assert_eq!(reports[0].artifact, "notes-tool");
1228 }
1229
1230 #[test]
1233 fn an_escaping_scope_is_refused() {
1234 let tool = murmur_artifact::ArtifactRuntime::Tool;
1235 let caps = capabilities_block(None, Some("../escape"));
1236
1237 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1238
1239 assert!(
1240 matches!(
1241 err,
1242 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1243 ),
1244 "unexpected error: {err}"
1245 );
1246 assert!(
1247 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1248 "the grant derivation must refuse what the report refuses"
1249 );
1250 }
1251
1252 #[test]
1255 fn preopen_surfaces_serialize_to_stable_wire_names() {
1256 for (surface, wire) in [
1257 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1258 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1259 (PreopenSurface::Nothing, "nothing"),
1260 ] {
1261 assert_eq!(
1262 serde_json::to_value(surface).unwrap(),
1263 serde_json::Value::String(wire.to_string())
1264 );
1265 }
1266 }
1267}