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 =
609 crate::runtime_dir::gets_runtime_dir_preopen(runtime, scope.is_some())
610 .then_some(crate::containment::RuntimeDirReach::Unresolved);
611 reports.push(PreopenReport {
612 artifact: name.to_string(),
613 role: runtime.as_str().to_string(),
614 scope,
615 surface,
616 runtime_dir_reach,
617 extra_roots: Vec::new(),
618 });
619 }
620
621 Ok(reports)
622}
623
624pub(crate) fn effective_tool_network_rules<'a>(
632 grant: Option<&'a ToolCapabilityGrant>,
633 ceiling_network_allow_rules: &'a [NetworkAllowRule],
634) -> &'a [NetworkAllowRule] {
635 grant
636 .and_then(|grant| grant.network_allow_rules.as_deref())
637 .unwrap_or(ceiling_network_allow_rules)
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 use murmur_artifact::{Capabilities, FilesystemCapabilities, NetworkCapabilities};
644
645 fn capabilities_block(
647 network: Option<Vec<&str>>,
648 filesystem_scope: Option<&str>,
649 ) -> Capabilities {
650 Capabilities {
651 peer_fetch: None,
652 network: network.map(|allow| NetworkCapabilities {
653 allow: allow.into_iter().map(str::to_string).collect(),
654 unix_sockets: false,
655 }),
656 filesystem: filesystem_scope.map(|scope| FilesystemCapabilities {
657 scope: Some(scope.to_string()),
658 workdir_exec: false,
659 read_only: Vec::new(),
660 records: false,
661 }),
662 shell: None,
663 spawn: None,
664 env: None,
665 limits: None,
666 resources: None,
667 state: None,
668 task_io: None,
669 conversation: None,
670 plan: None,
671 install: None,
672 containment: None,
673 }
674 }
675
676 #[test]
679 fn hook_grant_defaults_to_deny_network_and_filesystem() {
680 let grant = HookCapabilityGrant::derive(None, "test-capsule").unwrap();
681
682 assert_eq!(grant, HookCapabilityGrant::default());
683 assert!(grant.network_allow_rules.is_empty());
684 assert!(grant.filesystem_scope.is_none());
685
686 let target = RequestTarget {
687 scheme: "https".to_string(),
688 host: "telemetry.example.com".to_string(),
689 port: Some(443),
690 };
691 assert!(!grant
692 .network_allow_rules
693 .iter()
694 .any(|rule| rule.matches(&target)));
695 }
696
697 #[test]
700 fn hook_grant_carries_the_entrys_deadline() {
701 let mut caps = capabilities_block(None, None);
702 assert_eq!(
703 HookCapabilityGrant::derive(Some(&caps), "test-capsule")
704 .unwrap()
705 .deadline_seconds,
706 None
707 );
708 caps.limits = Some(murmur_artifact::ResourceLimits {
709 memory_bytes: Some(1 << 20),
710 table_elements: None,
711 instances: None,
712 deadline_seconds: Some(2),
713 });
714 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
715 assert_eq!(grant.deadline_seconds, Some(2));
716 }
717
718 #[test]
721 fn hook_grant_empty_capabilities_block_grants_nothing() {
722 let grant =
723 HookCapabilityGrant::derive(Some(&capabilities_block(None, None)), "test-capsule")
724 .unwrap();
725
726 assert_eq!(grant, HookCapabilityGrant::default());
727 }
728
729 #[test]
730 fn hook_grant_network_allows_exactly_the_declared_host() {
731 let caps = capabilities_block(Some(vec!["https://telemetry.example.com"]), None);
732 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
733
734 let allowed = RequestTarget {
735 scheme: "https".to_string(),
736 host: "telemetry.example.com".to_string(),
737 port: Some(443),
738 };
739 let other = RequestTarget {
740 scheme: "https".to_string(),
741 host: "evil.example.com".to_string(),
742 port: Some(443),
743 };
744
745 assert!(grant
746 .network_allow_rules
747 .iter()
748 .any(|rule| rule.matches(&allowed)));
749 assert!(!grant
750 .network_allow_rules
751 .iter()
752 .any(|rule| rule.matches(&other)));
753 assert!(grant.filesystem_scope.is_none());
755 }
756
757 #[test]
758 fn hook_grant_filesystem_scope_is_carried_through() {
759 let caps = capabilities_block(None, Some("hook-state"));
760 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
761
762 assert_eq!(grant.filesystem_scope.as_deref(), Some("hook-state"));
763 assert!(grant.network_allow_rules.is_empty());
765 }
766
767 #[test]
770 fn hook_grant_task_io_read_is_carried_through_independently() {
771 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
772 let caps = murmur_artifact::Capabilities {
773 task_io: declared.map(|read| murmur_artifact::TaskIoCapabilities { read }),
774 conversation: None,
775 plan: None,
776 ..capabilities_block(None, None)
777 };
778 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
779 assert_eq!(grant.task_io_read, expected, "declared: {declared:?}");
780 assert!(grant.network_allow_rules.is_empty());
781 assert!(grant.filesystem_scope.is_none());
782 }
783 }
784
785 #[test]
788 fn hook_grant_conversation_read_is_carried_through_independently() {
789 for (declared, expected) in [(None, false), (Some(false), false), (Some(true), true)] {
790 let caps = murmur_artifact::Capabilities {
791 conversation: declared
792 .map(|read| murmur_artifact::ConversationCapabilities { read }),
793 ..capabilities_block(None, None)
794 };
795 let grant = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap();
796 assert_eq!(grant.conversation_read, expected, "declared: {declared:?}");
797 assert!(!grant.task_io_read, "one grant never implies the other");
798 assert!(grant.network_allow_rules.is_empty());
799 assert!(grant.filesystem_scope.is_none());
800 }
801 }
802
803 #[test]
806 fn state_store_defaults_to_the_capsule_name_and_an_explicit_name_wins() {
807 for (declared, expected) in [(None, "state-capsule"), (Some("shey"), "shey")] {
808 let caps = Capabilities {
809 state: Some(murmur_artifact::StateCapabilities {
810 store: declared.map(str::to_string),
811 }),
812 ..capabilities_block(None, None)
813 };
814
815 let hook = HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap();
816 let tool =
817 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap();
818
819 assert_eq!(hook.state_store.as_deref(), Some(expected));
820 assert_eq!(tool.state_store.as_deref(), Some(expected));
821 assert!(hook.state_dir.is_none());
823 assert!(tool.state_dir.is_none());
824 assert!(hook.filesystem_scope.is_none());
826 assert!(hook.network_allow_rules.is_empty());
827 assert!(tool.filesystem_scope.is_none());
828 assert!(tool.network_allow_rules.is_none());
829 }
830 }
831
832 #[test]
835 fn an_absent_state_block_grants_no_store_to_either_role() {
836 let caps = capabilities_block(Some(vec!["https://api.example.com"]), Some("cache"));
837
838 assert!(HookCapabilityGrant::derive(Some(&caps), "state-capsule")
839 .unwrap()
840 .state_store
841 .is_none());
842 assert!(
843 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule")
844 .unwrap()
845 .state_store
846 .is_none()
847 );
848 }
849
850 #[test]
853 fn a_malformed_store_name_fails_lowering_for_both_roles() {
854 for store in ["../escape", "/abs/path", "a/b", "", ".hidden"] {
855 let caps = Capabilities {
856 state: Some(murmur_artifact::StateCapabilities {
857 store: Some(store.to_string()),
858 }),
859 ..capabilities_block(None, None)
860 };
861
862 for err in [
863 HookCapabilityGrant::derive(Some(&caps), "state-capsule").unwrap_err(),
864 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "state-capsule").unwrap_err(),
865 ] {
866 assert!(
867 matches!(err, RuntimeError::InvalidStateStore { .. }),
868 "store '{store}' must refuse as InvalidStateStore, got: {err}"
869 );
870 }
871 }
872 }
873
874 #[test]
877 fn an_empty_capsule_name_cannot_become_a_store_name() {
878 let caps = Capabilities {
879 state: Some(murmur_artifact::StateCapabilities { store: None }),
880 ..capabilities_block(None, None)
881 };
882
883 assert!(matches!(
884 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), ""),
885 Err(RuntimeError::InvalidStateStore { .. })
886 ));
887 }
888
889 #[test]
890 fn hook_grant_rejects_escaping_filesystem_scope() {
891 for scope in ["../escape", "/etc"] {
892 let caps = capabilities_block(None, Some(scope));
893 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
894 assert!(
895 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
896 "scope {scope} should fail staging, got: {err}"
897 );
898 }
899 }
900
901 #[test]
902 fn hook_grant_rejects_malformed_network_entry() {
903 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
904 let err = HookCapabilityGrant::derive(Some(&caps), "test-capsule").unwrap_err();
905
906 assert!(
907 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
908 "got: {err}"
909 );
910 }
911
912 fn ceiling() -> Vec<NetworkAllowRule> {
915 parse_network_allow_rules(&[
916 "https://api.example.com".to_string(),
917 "https://other.example.com".to_string(),
918 ])
919 .unwrap()
920 }
921
922 fn target(host: &str) -> RequestTarget {
923 RequestTarget {
924 scheme: "https".to_string(),
925 host: host.to_string(),
926 port: Some(443),
927 }
928 }
929
930 fn reaches(rules: &[NetworkAllowRule], host: &str) -> bool {
931 rules.iter().any(|rule| rule.matches(&target(host)))
932 }
933
934 #[test]
937 fn tool_grant_without_entry_inherits_the_ceiling_unchanged() {
938 let ceiling = ceiling();
939 let grant = ToolCapabilityGrant::derive(None, &ceiling, "test-capsule").unwrap();
940
941 assert_eq!(grant, ToolCapabilityGrant::default());
942 assert!(grant.network_allow_rules.is_none());
943 assert!(grant.filesystem_scope.is_none());
944 assert!(grant.dropped_network_entries.is_empty());
945
946 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
947 assert_eq!(effective, ceiling.as_slice());
948 assert!(reaches(effective, "api.example.com"));
949 assert!(reaches(effective, "other.example.com"));
950 }
951
952 #[test]
954 fn tool_grant_subset_of_ceiling_is_kept_and_narrows() {
955 let ceiling = ceiling();
956 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
957 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
958
959 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
960 assert!(reaches(effective, "api.example.com"));
961 assert!(!reaches(effective, "other.example.com"));
962 assert!(grant.dropped_network_entries.is_empty());
963 }
964
965 #[test]
968 fn tool_grant_entry_outside_the_ceiling_is_dropped_not_granted() {
969 let ceiling = ceiling();
970 let caps = capabilities_block(
971 Some(vec!["https://api.example.com", "https://evil.example.com"]),
972 None,
973 );
974 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
975
976 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
977 assert!(reaches(effective, "api.example.com"));
978 assert!(!reaches(effective, "evil.example.com"));
979 assert_eq!(
980 grant.dropped_network_entries,
981 vec!["https://evil.example.com".to_string()]
982 );
983 }
984
985 #[test]
988 fn tool_grant_entry_broader_than_the_ceiling_rule_is_dropped() {
989 let ceiling = ceiling();
990 let caps = capabilities_block(Some(vec!["api.example.com"]), None);
991 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
992
993 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
994 assert_eq!(
995 grant.dropped_network_entries,
996 vec!["api.example.com".to_string()]
997 );
998 }
999
1000 #[test]
1003 fn tool_grant_empty_network_allow_denies_all_for_that_artifact() {
1004 let ceiling = ceiling();
1005 let caps = capabilities_block(Some(vec![]), None);
1006 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1007
1008 let effective = effective_tool_network_rules(Some(&grant), &ceiling);
1009 assert!(effective.is_empty());
1010 assert!(!reaches(effective, "api.example.com"));
1011 assert!(!reaches(effective, "other.example.com"));
1012 }
1013
1014 #[test]
1017 fn tool_grant_filesystem_scope_is_carried_and_leaves_network_inherited() {
1018 let ceiling = ceiling();
1019 let caps = capabilities_block(None, Some("cache"));
1020 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1021
1022 assert_eq!(grant.filesystem_scope.as_deref(), Some("cache"));
1023 assert!(grant.network_allow_rules.is_none());
1024 assert_eq!(
1025 effective_tool_network_rules(Some(&grant), &ceiling),
1026 ceiling.as_slice()
1027 );
1028 }
1029
1030 #[test]
1031 fn tool_grant_rejects_escaping_filesystem_scope() {
1032 for scope in ["../escape", "/etc"] {
1033 let caps = capabilities_block(None, Some(scope));
1034 let err =
1035 ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1036 assert!(
1037 matches!(err, RuntimeError::InvalidFilesystemScope { .. }),
1038 "scope {scope} should fail staging, got: {err}"
1039 );
1040 }
1041 }
1042
1043 #[test]
1044 fn tool_grant_rejects_malformed_network_entry() {
1045 let caps = capabilities_block(Some(vec!["ftp://files.example.com"]), None);
1046 let err = ToolCapabilityGrant::derive(Some(&caps), &ceiling(), "test-capsule").unwrap_err();
1047
1048 assert!(
1049 matches!(err, RuntimeError::InvalidNetworkAllowEntry { .. }),
1050 "got: {err}"
1051 );
1052 }
1053
1054 #[test]
1057 fn tool_grant_cannot_widen_an_empty_ceiling() {
1058 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1059 let grant = ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").unwrap();
1060
1061 assert_eq!(grant.network_allow_rules.as_deref(), Some(&[][..]));
1062 assert_eq!(
1063 grant.dropped_network_entries,
1064 vec!["https://api.example.com".to_string()]
1065 );
1066 }
1067
1068 #[test]
1070 fn ceiling_rule_with_wildcard_scheme_covers_a_scheme_bound_rule() {
1071 let ceiling = parse_network_allow_rules(&["api.example.com".to_string()]).unwrap();
1072 let caps = capabilities_block(Some(vec!["https://api.example.com"]), None);
1073 let grant = ToolCapabilityGrant::derive(Some(&caps), &ceiling, "test-capsule").unwrap();
1074
1075 assert!(grant.dropped_network_entries.is_empty());
1076 assert!(reaches(
1077 effective_tool_network_rules(Some(&grant), &ceiling),
1078 "api.example.com"
1079 ));
1080 }
1081
1082 #[test]
1083 fn filesystem_scope_validation_accepts_safe_relative_paths() {
1084 for scope in [".", "./workdir", "a/../b", "sandbox/subdir"] {
1085 assert!(
1086 validate_filesystem_scope(scope).is_ok(),
1087 "scope should be valid: {scope}"
1088 );
1089 }
1090 }
1091
1092 #[test]
1093 fn filesystem_scope_validation_rejects_escape_paths() {
1094 for scope in ["../x", "../../x", "/tmp"] {
1095 assert!(
1096 validate_filesystem_scope(scope).is_err(),
1097 "scope should be invalid: {scope}"
1098 );
1099 }
1100 }
1101
1102 #[test]
1103 fn network_allowlist_empty_denies_all_requests() {
1104 let rules = parse_network_allow_rules(&Vec::<String>::new()).unwrap();
1105
1106 let target = RequestTarget {
1107 scheme: "https".to_string(),
1108 host: "blocked.example.com".to_string(),
1109 port: Some(443),
1110 };
1111
1112 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1113 }
1114
1115 #[test]
1116 fn network_allowlist_matches_allowed_host_with_default_port() {
1117 let rules =
1118 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1119 let target = RequestTarget {
1120 scheme: "https".to_string(),
1121 host: "allowed.example.com".to_string(),
1122 port: Some(443),
1123 };
1124
1125 assert!(rules.iter().any(|rule| rule.matches(&target)));
1126 }
1127
1128 #[test]
1129 fn network_allowlist_blocks_non_matching_host() {
1130 let rules =
1131 parse_network_allow_rules(&["https://allowed.example.com".to_string()]).unwrap();
1132 let target = RequestTarget {
1133 scheme: "https".to_string(),
1134 host: "blocked.example.com".to_string(),
1135 port: Some(443),
1136 };
1137
1138 assert!(!rules.iter().any(|rule| rule.matches(&target)));
1139 }
1140
1141 #[test]
1142 fn network_allowlist_normalizes_default_ports() {
1143 let rules = parse_network_allow_rules(&[
1144 "https://allowed.example.com".to_string(),
1145 "allowed-two.example.com:8080".to_string(),
1146 ])
1147 .unwrap();
1148
1149 let https_default = RequestTarget {
1150 scheme: "https".to_string(),
1151 host: "allowed.example.com".to_string(),
1152 port: Some(443),
1153 };
1154 let custom_port = RequestTarget {
1155 scheme: "http".to_string(),
1156 host: "allowed-two.example.com".to_string(),
1157 port: Some(8080),
1158 };
1159
1160 assert!(rules.iter().any(|rule| rule.matches(&https_default)));
1161 assert!(rules.iter().any(|rule| rule.matches(&custom_port)));
1162 }
1163
1164 #[test]
1168 fn undeclared_entries_report_the_baseline_their_role_starts_from() {
1169 let tool = murmur_artifact::ArtifactRuntime::Tool;
1170 let driver = murmur_artifact::ArtifactRuntime::Driver;
1171 let hook = murmur_artifact::ArtifactRuntime::Hook;
1172
1173 let reports = preopen_reports(vec![
1174 ("notes-tool", &tool, None),
1175 ("anthropic-driver", &driver, None),
1176 ("telemetry-hook", &hook, None),
1177 ])
1178 .unwrap();
1179
1180 assert_eq!(
1181 reports,
1182 vec![
1183 PreopenReport {
1184 artifact: "notes-tool".to_string(),
1185 role: "tool".to_string(),
1186 scope: None,
1187 surface: PreopenSurface::WholeWorkdir,
1188 runtime_dir_reach: Some(crate::containment::RuntimeDirReach::Unresolved),
1189 extra_roots: Vec::new(),
1190 },
1191 PreopenReport {
1192 artifact: "anthropic-driver".to_string(),
1193 role: "driver".to_string(),
1194 scope: None,
1195 surface: PreopenSurface::WholeWorkdir,
1196 runtime_dir_reach: None,
1197 extra_roots: Vec::new(),
1198 },
1199 PreopenReport {
1200 artifact: "telemetry-hook".to_string(),
1201 role: "hook".to_string(),
1202 scope: None,
1203 surface: PreopenSurface::Nothing,
1204 runtime_dir_reach: None,
1205 extra_roots: Vec::new(),
1206 },
1207 ]
1208 );
1209 }
1210
1211 #[test]
1214 fn a_declared_scope_narrows_every_role_to_a_subtree() {
1215 let tool = murmur_artifact::ArtifactRuntime::Tool;
1216 let hook = murmur_artifact::ArtifactRuntime::Hook;
1217 let tool_caps = capabilities_block(None, Some("cache"));
1218 let hook_caps = capabilities_block(None, Some("hook-state"));
1219
1220 let reports = preopen_reports(vec![
1221 ("notes-tool", &tool, Some(&tool_caps)),
1222 ("telemetry-hook", &hook, Some(&hook_caps)),
1223 ])
1224 .unwrap();
1225
1226 assert_eq!(
1227 reports,
1228 vec![
1229 PreopenReport {
1230 artifact: "notes-tool".to_string(),
1231 role: "tool".to_string(),
1232 scope: Some("cache".to_string()),
1233 surface: PreopenSurface::ScopedSubtree,
1234 runtime_dir_reach: None,
1235 extra_roots: Vec::new(),
1236 },
1237 PreopenReport {
1238 artifact: "telemetry-hook".to_string(),
1239 role: "hook".to_string(),
1240 scope: Some("hook-state".to_string()),
1241 surface: PreopenSurface::ScopedSubtree,
1242 runtime_dir_reach: None,
1243 extra_roots: Vec::new(),
1244 },
1245 ]
1246 );
1247 }
1248
1249 #[test]
1252 fn a_skill_produces_no_entry() {
1253 let skill = murmur_artifact::ArtifactRuntime::Skill;
1254 let tool = murmur_artifact::ArtifactRuntime::Tool;
1255
1256 let reports = preopen_reports(vec![
1257 ("notes-skill", &skill, None),
1258 ("notes-tool", &tool, None),
1259 ])
1260 .unwrap();
1261
1262 assert_eq!(reports.len(), 1);
1263 assert_eq!(reports[0].artifact, "notes-tool");
1264 }
1265
1266 #[test]
1269 fn an_escaping_scope_is_refused() {
1270 let tool = murmur_artifact::ArtifactRuntime::Tool;
1271 let caps = capabilities_block(None, Some("../escape"));
1272
1273 let err = preopen_reports(vec![("notes-tool", &tool, Some(&caps))]).unwrap_err();
1274
1275 assert!(
1276 matches!(
1277 err,
1278 RuntimeError::InvalidFilesystemScope { ref scope, .. } if scope == "../escape"
1279 ),
1280 "unexpected error: {err}"
1281 );
1282 assert!(
1283 ToolCapabilityGrant::derive(Some(&caps), &[], "test-capsule").is_err(),
1284 "the grant derivation must refuse what the report refuses"
1285 );
1286 }
1287
1288 #[test]
1291 fn preopen_surfaces_serialize_to_stable_wire_names() {
1292 for (surface, wire) in [
1293 (PreopenSurface::WholeWorkdir, "whole-workdir"),
1294 (PreopenSurface::ScopedSubtree, "scoped-subtree"),
1295 (PreopenSurface::Nothing, "nothing"),
1296 ] {
1297 assert_eq!(
1298 serde_json::to_value(surface).unwrap(),
1299 serde_json::Value::String(wire.to_string())
1300 );
1301 }
1302 }
1303}