1use std::collections::HashMap;
15use std::pin::Pin;
16use std::sync::Arc;
17
18use zeph_skills::loader::Skill;
19use zeph_skills::registry::SkillRegistry;
20use zeph_tools::ToolCall;
21use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput, extract_fenced_blocks};
22use zeph_tools::registry::{InvocationHint, ToolDef};
23
24use super::def::{SkillFilter, ToolPolicy};
25use super::error::SubAgentError;
26
27fn collect_fenced_tags(executor: &dyn ErasedToolExecutor) -> Vec<&'static str> {
31 executor
32 .tool_definitions_erased()
33 .into_iter()
34 .filter_map(|def| match def.invocation {
35 InvocationHint::FencedBlock(tag) => Some(tag),
36 _ => None,
37 })
38 .collect()
39}
40
41pub(crate) fn normalize_tool_id(s: &str) -> String {
47 let base = s.split('(').next().unwrap_or(s);
48 base.trim().to_lowercase()
49}
50
51pub struct FilteredToolExecutor {
61 inner: Arc<dyn ErasedToolExecutor>,
62 policy: ToolPolicy,
63 disallowed: Vec<String>,
64 fenced_tags: Vec<&'static str>,
67}
68
69impl FilteredToolExecutor {
70 #[must_use]
75 pub fn new(inner: Arc<dyn ErasedToolExecutor>, policy: ToolPolicy) -> Self {
76 let fenced_tags = collect_fenced_tags(&*inner);
77 Self {
78 inner,
79 policy,
80 disallowed: Vec::new(),
81 fenced_tags,
82 }
83 }
84
85 #[must_use]
90 pub fn with_disallowed(
91 inner: Arc<dyn ErasedToolExecutor>,
92 policy: ToolPolicy,
93 disallowed: Vec<String>,
94 ) -> Self {
95 let fenced_tags = collect_fenced_tags(&*inner);
96 Self {
97 inner,
98 policy,
99 disallowed,
100 fenced_tags,
101 }
102 }
103
104 fn has_fenced_tool_invocation(&self, response: &str) -> bool {
106 self.fenced_tags
107 .iter()
108 .any(|tag| !extract_fenced_blocks(response, tag).is_empty())
109 }
110
111 fn is_allowed(&self, tool_id: &str) -> bool {
117 let normalized = normalize_tool_id(tool_id);
118 if self
119 .disallowed
120 .iter()
121 .any(|t| normalize_tool_id(t) == normalized)
122 {
123 return false;
124 }
125 match &self.policy {
126 ToolPolicy::AllowList(list) => list.iter().any(|t| normalize_tool_id(t) == normalized),
127 ToolPolicy::DenyList(list) => !list.iter().any(|t| normalize_tool_id(t) == normalized),
128 _ => true,
129 }
130 }
131}
132
133impl ErasedToolExecutor for FilteredToolExecutor {
134 fn execute_erased<'a>(
135 &'a self,
136 response: &'a str,
137 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
138 {
139 if self.has_fenced_tool_invocation(response) {
150 tracing::warn!("sub-agent attempted fenced-block tool invocation — blocked by policy");
151 return Box::pin(std::future::ready(Err(ToolError::Blocked {
152 command: "fenced-block".into(),
153 })));
154 }
155 Box::pin(std::future::ready(Ok(None)))
156 }
157
158 fn execute_confirmed_erased<'a>(
159 &'a self,
160 response: &'a str,
161 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
162 {
163 if self.has_fenced_tool_invocation(response) {
165 tracing::warn!(
166 "sub-agent attempted confirmed fenced-block tool invocation — blocked by policy"
167 );
168 return Box::pin(std::future::ready(Err(ToolError::Blocked {
169 command: "fenced-block".into(),
170 })));
171 }
172 Box::pin(std::future::ready(Ok(None)))
173 }
174
175 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
176 self.inner
178 .tool_definitions_erased()
179 .into_iter()
180 .filter(|def| self.is_allowed(&def.id))
181 .collect()
182 }
183
184 fn execute_tool_call_erased<'a>(
185 &'a self,
186 call: &'a ToolCall,
187 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
188 {
189 if !self.is_allowed(call.tool_id.as_str()) {
190 tracing::warn!(
191 tool_id = %call.tool_id,
192 "sub-agent tool call rejected by policy"
193 );
194 return Box::pin(std::future::ready(Err(ToolError::Blocked {
195 command: call.tool_id.to_string(),
196 })));
197 }
198 Box::pin(self.inner.execute_tool_call_erased(call))
199 }
200
201 fn execute_tool_call_confirmed_erased<'a>(
206 &'a self,
207 call: &'a ToolCall,
208 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
209 {
210 self.execute_tool_call_erased(call)
211 }
212
213 fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
214 self.inner.set_skill_env(env);
215 }
216
217 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
218 self.inner.set_effective_trust(level);
219 }
220
221 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
222 self.inner.is_tool_retryable_erased(tool_id)
223 }
224
225 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
226 self.inner.requires_confirmation_erased(call)
227 }
228
229 zeph_tools::erased_tool_executor_forward!(inner);
230}
231
232pub struct PlanModeExecutor {
241 inner: Arc<dyn ErasedToolExecutor>,
242}
243
244impl PlanModeExecutor {
245 #[must_use]
247 pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
248 Self { inner }
249 }
250}
251
252impl ErasedToolExecutor for PlanModeExecutor {
253 fn execute_erased<'a>(
254 &'a self,
255 _response: &'a str,
256 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
257 {
258 Box::pin(std::future::ready(Err(ToolError::Blocked {
259 command: "plan_mode".into(),
260 })))
261 }
262
263 fn execute_confirmed_erased<'a>(
264 &'a self,
265 _response: &'a str,
266 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
267 {
268 Box::pin(std::future::ready(Err(ToolError::Blocked {
269 command: "plan_mode".into(),
270 })))
271 }
272
273 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
274 self.inner.tool_definitions_erased()
275 }
276
277 fn execute_tool_call_erased<'a>(
278 &'a self,
279 call: &'a ToolCall,
280 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
281 {
282 tracing::debug!(
283 tool_id = %call.tool_id,
284 "tool execution blocked in plan mode"
285 );
286 Box::pin(std::future::ready(Err(ToolError::Blocked {
287 command: call.tool_id.to_string(),
288 })))
289 }
290
291 fn execute_tool_call_confirmed_erased<'a>(
294 &'a self,
295 call: &'a ToolCall,
296 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
297 {
298 self.execute_tool_call_erased(call)
299 }
300
301 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
302 self.inner.set_skill_env(env);
303 }
304
305 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
308 self.inner.set_effective_trust(level);
309 }
310
311 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
312 false
313 }
314
315 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
316 false
317 }
318
319 zeph_tools::erased_tool_executor_forward!(inner);
325}
326
327const NETWORK_ONLY_TOOL_IDS: &[&str] = &["web_scrape", "fetch", "web_search"];
339
340pub struct NetworkDenyToolExecutor {
359 inner: Arc<dyn ErasedToolExecutor>,
360 blocklist: Vec<String>,
361}
362
363impl NetworkDenyToolExecutor {
364 #[must_use]
366 pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
367 Self {
368 inner,
369 blocklist: zeph_tools::NETWORK_COMMANDS
370 .iter()
371 .map(|s| (*s).to_owned())
372 .collect(),
373 }
374 }
375
376 fn check_call(&self, call: &ToolCall) -> Result<(), ToolError> {
380 let tool_id = normalize_tool_id(call.tool_id.as_str());
381
382 if NETWORK_ONLY_TOOL_IDS.contains(&tool_id.as_str()) {
383 tracing::warn!(
384 tool_id = %tool_id,
385 "network egress denied for sub-agent task (NetworkScope::Deny)"
386 );
387 return Err(ToolError::Blocked { command: tool_id });
388 }
389
390 if tool_id != "bash" {
391 return Ok(());
392 }
393 let Some(command) = call.params.get("command").and_then(|v| v.as_str()) else {
394 return Ok(());
395 };
396 if let Some(matched) = zeph_tools::check_blocklist(command, &self.blocklist) {
397 tracing::warn!(
398 command = %matched,
399 "network egress denied for sub-agent task (NetworkScope::Deny)"
400 );
401 return Err(ToolError::Blocked { command: matched });
402 }
403 Ok(())
404 }
405}
406
407impl ErasedToolExecutor for NetworkDenyToolExecutor {
408 fn execute_erased<'a>(
409 &'a self,
410 response: &'a str,
411 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
412 {
413 self.inner.execute_erased(response)
414 }
415
416 fn execute_confirmed_erased<'a>(
417 &'a self,
418 response: &'a str,
419 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
420 {
421 self.inner.execute_confirmed_erased(response)
422 }
423
424 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
425 self.inner.tool_definitions_erased()
426 }
427
428 fn execute_tool_call_erased<'a>(
429 &'a self,
430 call: &'a ToolCall,
431 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
432 {
433 if let Err(e) = self.check_call(call) {
434 return Box::pin(std::future::ready(Err(e)));
435 }
436 Box::pin(self.inner.execute_tool_call_erased(call))
437 }
438
439 fn execute_tool_call_confirmed_erased<'a>(
440 &'a self,
441 call: &'a ToolCall,
442 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
443 {
444 if let Err(e) = self.check_call(call) {
445 return Box::pin(std::future::ready(Err(e)));
446 }
447 Box::pin(self.inner.execute_tool_call_confirmed_erased(call))
448 }
449
450 fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
451 self.inner.set_skill_env(env);
452 }
453
454 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
455 self.inner.set_effective_trust(level);
456 }
457
458 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
459 self.inner.is_tool_retryable_erased(tool_id)
460 }
461
462 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
463 self.inner.requires_confirmation_erased(call)
464 }
465
466 zeph_tools::erased_tool_executor_forward!(inner);
467}
468
469pub fn filter_skills(
498 registry: &SkillRegistry,
499 filter: &SkillFilter,
500) -> Result<Vec<Skill>, SubAgentError> {
501 let compiled_include = compile_globs(&filter.include)?;
502 let compiled_exclude = compile_globs(&filter.exclude)?;
503
504 let all: Vec<Skill> = registry
505 .all_meta()
506 .into_iter()
507 .filter(|meta| {
508 let name = &meta.name;
509 let included =
510 compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
511 let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
512 included && !excluded
513 })
514 .filter_map(|meta| registry.skill(&meta.name).ok())
515 .collect();
516
517 Ok(all)
518}
519
520struct GlobPattern {
522 raw: String,
523 prefix: String,
524 suffix: Option<String>,
525 is_star: bool,
526}
527
528fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
529 patterns.iter().map(|p| compile_glob(p)).collect()
530}
531
532fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
533 if pattern.contains("**") {
536 return Err(SubAgentError::Invalid(format!(
537 "glob pattern '{pattern}' uses '**' which is not supported"
538 )));
539 }
540
541 let is_star = pattern == "*";
542
543 let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
544 let before = pattern[..pos].to_owned();
545 let after = pattern[pos + 1..].to_owned();
546 (before, Some(after))
547 } else {
548 (pattern.to_owned(), None)
549 };
550
551 Ok(GlobPattern {
552 raw: pattern.to_owned(),
553 prefix,
554 suffix,
555 is_star,
556 })
557}
558
559fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
560 if pattern.is_star {
561 return true;
562 }
563
564 match &pattern.suffix {
565 None => name == pattern.raw,
566 Some(suf) => {
567 name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
568 name.len() >= pattern.prefix.len() + suf.len()
570 }
571 }
572 }
573}
574
575#[cfg(test)]
578mod tests {
579 #![allow(clippy::default_trait_access)]
580 use std::assert_matches;
581
582 use super::*;
583 use crate::def::ToolPolicy;
584
585 struct StubExecutor {
588 tools: Vec<&'static str>,
589 }
590
591 struct StubFencedExecutor {
593 tag: &'static str,
594 }
595
596 impl ErasedToolExecutor for StubFencedExecutor {
597 fn execute_erased<'a>(
598 &'a self,
599 _response: &'a str,
600 ) -> Pin<
601 Box<
602 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
603 >,
604 > {
605 Box::pin(std::future::ready(Ok(None)))
606 }
607
608 fn execute_confirmed_erased<'a>(
609 &'a self,
610 _response: &'a str,
611 ) -> Pin<
612 Box<
613 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
614 >,
615 > {
616 Box::pin(std::future::ready(Ok(None)))
617 }
618
619 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
620 use zeph_tools::registry::InvocationHint;
621 vec![ToolDef {
622 id: self.tag.into(),
623 description: "fenced stub".into(),
624 schema: schemars::Schema::default(),
625 invocation: InvocationHint::FencedBlock(self.tag),
626 output_schema: None,
627 server_id: None,
628 }]
629 }
630
631 fn execute_tool_call_erased<'a>(
632 &'a self,
633 call: &'a ToolCall,
634 ) -> Pin<
635 Box<
636 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
637 >,
638 > {
639 let result = Ok(Some(ToolOutput {
640 tool_name: call.tool_id.clone(),
641 summary: "ok".into(),
642 blocks_executed: 1,
643 filter_stats: None,
644 diff: None,
645 streamed: false,
646 terminal_id: None,
647 locations: None,
648 raw_response: None,
649 claim_source: None,
650 ..Default::default()
651 }));
652 Box::pin(std::future::ready(result))
653 }
654
655 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
656 false
657 }
658
659 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
660 false
661 }
662
663 fn execute_tool_call_confirmed_erased<'a>(
664 &'a self,
665 call: &'a ToolCall,
666 ) -> Pin<
667 Box<
668 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
669 >,
670 > {
671 self.execute_tool_call_erased(call)
672 }
673
674 fn checkpoint_undo_erased(&self, _n: usize) -> zeph_tools::CheckpointActionResult {
675 zeph_tools::CheckpointActionResult::unsupported()
676 }
677
678 fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
679 zeph_tools::CheckpointActionResult::unsupported()
680 }
681
682 fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
683 zeph_tools::CheckpointListResult::default()
684 }
685
686 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
687 false
688 }
689 }
690
691 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
692 Arc::new(StubFencedExecutor { tag })
693 }
694
695 impl ErasedToolExecutor for StubExecutor {
696 fn execute_erased<'a>(
697 &'a self,
698 _response: &'a str,
699 ) -> Pin<
700 Box<
701 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
702 >,
703 > {
704 Box::pin(std::future::ready(Ok(None)))
705 }
706
707 fn execute_confirmed_erased<'a>(
708 &'a self,
709 _response: &'a str,
710 ) -> Pin<
711 Box<
712 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
713 >,
714 > {
715 Box::pin(std::future::ready(Ok(None)))
716 }
717
718 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
719 use zeph_tools::registry::InvocationHint;
721 self.tools
722 .iter()
723 .map(|id| ToolDef {
724 id: (*id).into(),
725 description: "stub".into(),
726 schema: schemars::Schema::default(),
727 invocation: InvocationHint::ToolCall,
728 output_schema: None,
729 server_id: None,
730 })
731 .collect()
732 }
733
734 fn execute_tool_call_erased<'a>(
735 &'a self,
736 call: &'a ToolCall,
737 ) -> Pin<
738 Box<
739 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
740 >,
741 > {
742 let result = Ok(Some(ToolOutput {
743 tool_name: call.tool_id.clone(),
744 summary: "ok".into(),
745 blocks_executed: 1,
746 filter_stats: None,
747 diff: None,
748 streamed: false,
749 terminal_id: None,
750 locations: None,
751 raw_response: None,
752 claim_source: None,
753 ..Default::default()
754 }));
755 Box::pin(std::future::ready(result))
756 }
757
758 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
759 false
760 }
761
762 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
763 false
764 }
765
766 fn execute_tool_call_confirmed_erased<'a>(
767 &'a self,
768 call: &'a ToolCall,
769 ) -> Pin<
770 Box<
771 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
772 >,
773 > {
774 self.execute_tool_call_erased(call)
775 }
776
777 fn checkpoint_undo_erased(&self, _n: usize) -> zeph_tools::CheckpointActionResult {
778 zeph_tools::CheckpointActionResult::unsupported()
779 }
780
781 fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
782 zeph_tools::CheckpointActionResult::unsupported()
783 }
784
785 fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
786 zeph_tools::CheckpointListResult::default()
787 }
788
789 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
790 false
791 }
792 }
793
794 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
795 Arc::new(StubExecutor {
796 tools: tools.to_vec(),
797 })
798 }
799
800 #[tokio::test]
801 async fn allow_list_permits_listed_tool() {
802 let exec = FilteredToolExecutor::new(
803 stub_box(&["shell", "web"]),
804 ToolPolicy::AllowList(vec!["shell".into()]),
805 );
806 let call = ToolCall {
807 tool_id: "shell".into(),
808 params: serde_json::Map::default(),
809 caller_id: None,
810 context: None,
811
812 tool_call_id: String::new(),
813 skill_name: None,
814 };
815 let res = exec.execute_tool_call_erased(&call).await.unwrap();
816 assert!(res.is_some());
817 }
818
819 #[tokio::test]
820 async fn allow_list_blocks_unlisted_tool() {
821 let exec = FilteredToolExecutor::new(
822 stub_box(&["shell", "web"]),
823 ToolPolicy::AllowList(vec!["shell".into()]),
824 );
825 let call = ToolCall {
826 tool_id: "web".into(),
827 params: serde_json::Map::default(),
828 caller_id: None,
829 context: None,
830
831 tool_call_id: String::new(),
832 skill_name: None,
833 };
834 let res = exec.execute_tool_call_erased(&call).await;
835 assert!(res.is_err());
836 }
837
838 #[tokio::test]
839 async fn deny_list_blocks_listed_tool() {
840 let exec = FilteredToolExecutor::new(
841 stub_box(&["shell", "web"]),
842 ToolPolicy::DenyList(vec!["shell".into()]),
843 );
844 let call = ToolCall {
845 tool_id: "shell".into(),
846 params: serde_json::Map::default(),
847 caller_id: None,
848 context: None,
849
850 tool_call_id: String::new(),
851 skill_name: None,
852 };
853 let res = exec.execute_tool_call_erased(&call).await;
854 assert!(res.is_err());
855 }
856
857 #[tokio::test]
858 async fn inherit_all_permits_any_tool() {
859 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
860 let call = ToolCall {
861 tool_id: "shell".into(),
862 params: serde_json::Map::default(),
863 caller_id: None,
864 context: None,
865
866 tool_call_id: String::new(),
867 skill_name: None,
868 };
869 let res = exec.execute_tool_call_erased(&call).await.unwrap();
870 assert!(res.is_some());
871 }
872
873 #[test]
874 fn tool_definitions_filtered_by_allow_list() {
875 let exec = FilteredToolExecutor::new(
876 stub_box(&["shell", "web"]),
877 ToolPolicy::AllowList(vec!["shell".into()]),
878 );
879 let defs = exec.tool_definitions_erased();
880 assert_eq!(defs.len(), 1);
881 assert_eq!(defs[0].id, "shell");
882 }
883
884 fn matches(pattern: &str, name: &str) -> bool {
887 let p = compile_glob(pattern).unwrap();
888 glob_match(&p, name)
889 }
890
891 #[test]
892 fn glob_star_matches_all() {
893 assert!(matches("*", "anything"));
894 assert!(matches("*", ""));
895 }
896
897 #[test]
898 fn glob_prefix_star() {
899 assert!(matches("git-*", "git-commit"));
900 assert!(matches("git-*", "git-status"));
901 assert!(!matches("git-*", "rust-fmt"));
902 }
903
904 #[test]
905 fn glob_literal_exact_match() {
906 assert!(matches("shell", "shell"));
907 assert!(!matches("shell", "shell-extra"));
908 }
909
910 #[test]
911 fn glob_star_suffix() {
912 assert!(matches("*-review", "code-review"));
913 assert!(!matches("*-review", "code-reviewer"));
914 }
915
916 #[test]
917 fn glob_double_star_is_error() {
918 assert!(compile_glob("**").is_err());
919 }
920
921 #[test]
922 fn glob_mid_string_wildcard() {
923 assert!(matches("a*b", "axb"));
925 assert!(matches("a*b", "aXYZb"));
926 assert!(!matches("a*b", "ab-extra"));
927 assert!(!matches("a*b", "xab"));
928 }
929
930 #[tokio::test]
933 async fn deny_list_permits_unlisted_tool() {
934 let exec = FilteredToolExecutor::new(
935 stub_box(&["shell", "web"]),
936 ToolPolicy::DenyList(vec!["shell".into()]),
937 );
938 let call = ToolCall {
939 tool_id: "web".into(), params: serde_json::Map::default(),
941 caller_id: None,
942 context: None,
943
944 tool_call_id: String::new(),
945 skill_name: None,
946 };
947 let res = exec.execute_tool_call_erased(&call).await.unwrap();
948 assert!(res.is_some());
949 }
950
951 #[test]
952 fn tool_definitions_filtered_by_deny_list() {
953 let exec = FilteredToolExecutor::new(
954 stub_box(&["shell", "web"]),
955 ToolPolicy::DenyList(vec!["shell".into()]),
956 );
957 let defs = exec.tool_definitions_erased();
958 assert_eq!(defs.len(), 1);
959 assert_eq!(defs[0].id, "web");
960 }
961
962 #[test]
963 fn tool_definitions_inherit_all_returns_all() {
964 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
965 let defs = exec.tool_definitions_erased();
966 assert_eq!(defs.len(), 2);
967 }
968
969 #[tokio::test]
972 async fn fenced_block_matching_tag_is_blocked() {
973 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
975 let res = exec.execute_erased("```bash\nls\n```").await;
976 assert!(
977 res.is_err(),
978 "actual fenced-block invocation must be blocked"
979 );
980 }
981
982 #[tokio::test]
983 async fn fenced_block_matching_tag_confirmed_is_blocked() {
984 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
985 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
986 assert!(
987 res.is_err(),
988 "actual fenced-block invocation (confirmed) must be blocked"
989 );
990 }
991
992 #[tokio::test]
993 async fn no_fenced_tools_plain_text_returns_ok_none() {
994 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
996 let res = exec.execute_erased("This is a plain text response.").await;
997 assert!(
998 res.unwrap().is_none(),
999 "plain text must not be treated as a tool call"
1000 );
1001 }
1002
1003 #[tokio::test]
1004 async fn markdown_non_tool_fence_returns_ok_none() {
1005 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1007 let res = exec
1008 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
1009 .await;
1010 assert!(
1011 res.unwrap().is_none(),
1012 "non-tool code fence must not trigger blocking"
1013 );
1014 }
1015
1016 #[tokio::test]
1017 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
1018 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
1019 let res = exec
1020 .execute_confirmed_erased("Plain response without any fences.")
1021 .await;
1022 assert!(res.unwrap().is_none());
1023 }
1024
1025 #[tokio::test]
1029 async fn fenced_executor_plain_text_returns_ok_none() {
1030 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1031 let res = exec
1032 .execute_erased("Here is my analysis of the code. No shell commands needed.")
1033 .await;
1034 assert!(
1035 res.unwrap().is_none(),
1036 "plain text with fenced executor must not be treated as a tool call"
1037 );
1038 }
1039
1040 #[tokio::test]
1043 async fn unclosed_fenced_block_returns_ok_none() {
1044 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1045 let res = exec.execute_erased("```bash\nls -la\n").await;
1046 assert!(
1047 res.unwrap().is_none(),
1048 "unclosed fenced block must not be treated as a tool invocation"
1049 );
1050 }
1051
1052 #[tokio::test]
1054 async fn multiple_fences_one_matching_tag_is_blocked() {
1055 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1056 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
1057 let res = exec.execute_erased(response).await;
1058 assert!(
1059 res.is_err(),
1060 "response containing a matching fenced block must be blocked"
1061 );
1062 }
1063
1064 #[tokio::test]
1067 async fn disallowed_blocks_tool_from_allow_list() {
1068 let exec = FilteredToolExecutor::with_disallowed(
1069 stub_box(&["shell", "web"]),
1070 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
1071 vec!["shell".into()],
1072 );
1073 let call = ToolCall {
1074 tool_id: "shell".into(),
1075 params: serde_json::Map::default(),
1076 caller_id: None,
1077 context: None,
1078
1079 tool_call_id: String::new(),
1080 skill_name: None,
1081 };
1082 let res = exec.execute_tool_call_erased(&call).await;
1083 assert!(
1084 res.is_err(),
1085 "disallowed tool must be blocked even if in allow list"
1086 );
1087 }
1088
1089 #[tokio::test]
1090 async fn disallowed_allows_non_disallowed_tool() {
1091 let exec = FilteredToolExecutor::with_disallowed(
1092 stub_box(&["shell", "web"]),
1093 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
1094 vec!["shell".into()],
1095 );
1096 let call = ToolCall {
1097 tool_id: "web".into(),
1098 params: serde_json::Map::default(),
1099 caller_id: None,
1100 context: None,
1101
1102 tool_call_id: String::new(),
1103 skill_name: None,
1104 };
1105 let res = exec.execute_tool_call_erased(&call).await;
1106 assert!(res.is_ok(), "non-disallowed tool must be allowed");
1107 }
1108
1109 #[test]
1110 fn disallowed_empty_list_no_change() {
1111 let exec = FilteredToolExecutor::with_disallowed(
1112 stub_box(&["shell", "web"]),
1113 ToolPolicy::InheritAll,
1114 vec![],
1115 );
1116 let defs = exec.tool_definitions_erased();
1117 assert_eq!(defs.len(), 2);
1118 }
1119
1120 #[test]
1121 fn tool_definitions_filters_disallowed_tools() {
1122 let exec = FilteredToolExecutor::with_disallowed(
1123 stub_box(&["shell", "web", "dangerous"]),
1124 ToolPolicy::InheritAll,
1125 vec!["dangerous".into()],
1126 );
1127 let defs = exec.tool_definitions_erased();
1128 assert_eq!(defs.len(), 2);
1129 assert!(!defs.iter().any(|d| d.id == "dangerous"));
1130 }
1131
1132 fn bash_call(command: &str) -> ToolCall {
1135 let mut params = serde_json::Map::new();
1136 params.insert("command".into(), serde_json::Value::from(command));
1137 ToolCall {
1138 tool_id: "bash".into(),
1139 params,
1140 caller_id: None,
1141 context: None,
1142 tool_call_id: String::new(),
1143 skill_name: None,
1144 }
1145 }
1146
1147 #[tokio::test]
1148 async fn network_deny_blocks_curl() {
1149 let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1150 let res = exec
1151 .execute_tool_call_erased(&bash_call("curl https://evil.example"))
1152 .await;
1153 assert_matches!(res, Err(ToolError::Blocked { .. }));
1154 }
1155
1156 #[tokio::test]
1157 async fn network_deny_blocks_wget_and_nc() {
1158 let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1159 assert!(
1160 exec.execute_tool_call_erased(&bash_call("wget https://evil.example"))
1161 .await
1162 .is_err()
1163 );
1164 assert!(
1165 exec.execute_tool_call_erased(&bash_call("nc -l 4444"))
1166 .await
1167 .is_err()
1168 );
1169 }
1170
1171 #[tokio::test]
1172 async fn network_deny_permits_non_network_bash() {
1173 let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1174 let res = exec.execute_tool_call_erased(&bash_call("ls -la")).await;
1175 assert!(res.is_ok(), "non-network command must pass through");
1176 }
1177
1178 #[tokio::test]
1179 async fn network_deny_ignores_non_bash_tools() {
1180 let exec = NetworkDenyToolExecutor::new(stub_box(&["web"]));
1181 let call = ToolCall {
1182 tool_id: "web".into(),
1183 params: serde_json::Map::default(),
1184 caller_id: None,
1185 context: None,
1186 tool_call_id: String::new(),
1187 skill_name: None,
1188 };
1189 let res = exec.execute_tool_call_erased(&call).await;
1190 assert!(res.is_ok(), "non-bash tool calls must not be inspected");
1191 }
1192
1193 #[tokio::test]
1194 async fn network_deny_confirmed_path_also_enforces() {
1195 let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1196 let res = exec
1197 .execute_tool_call_confirmed_erased(&bash_call("curl https://evil.example"))
1198 .await;
1199 assert_matches!(res, Err(ToolError::Blocked { .. }));
1200 }
1201
1202 fn tool_call(tool_id: &str) -> ToolCall {
1203 ToolCall {
1204 tool_id: tool_id.into(),
1205 params: serde_json::Map::default(),
1206 caller_id: None,
1207 context: None,
1208 tool_call_id: String::new(),
1209 skill_name: None,
1210 }
1211 }
1212
1213 #[tokio::test]
1214 async fn network_deny_blocks_web_scrape_unconditionally() {
1215 let exec = NetworkDenyToolExecutor::new(stub_box(&["web_scrape"]));
1216 let res = exec
1217 .execute_tool_call_erased(&tool_call("web_scrape"))
1218 .await;
1219 assert_matches!(res, Err(ToolError::Blocked { .. }));
1220 }
1221
1222 #[tokio::test]
1223 async fn network_deny_blocks_fetch_unconditionally() {
1224 let exec = NetworkDenyToolExecutor::new(stub_box(&["fetch"]));
1225 let res = exec.execute_tool_call_erased(&tool_call("fetch")).await;
1226 assert_matches!(res, Err(ToolError::Blocked { .. }));
1227 }
1228
1229 #[tokio::test]
1230 async fn network_deny_blocks_fetch_confirmed_path_too() {
1231 let exec = NetworkDenyToolExecutor::new(stub_box(&["fetch"]));
1232 let res = exec
1233 .execute_tool_call_confirmed_erased(&tool_call("fetch"))
1234 .await;
1235 assert_matches!(res, Err(ToolError::Blocked { .. }));
1236 }
1237
1238 #[tokio::test]
1239 async fn network_deny_blocks_web_search_unconditionally() {
1240 let exec = NetworkDenyToolExecutor::new(stub_box(&["web_search"]));
1243 let res = exec
1244 .execute_tool_call_erased(&tool_call("web_search"))
1245 .await;
1246 assert_matches!(res, Err(ToolError::Blocked { .. }));
1247 }
1248
1249 #[test]
1252 fn plan_mode_with_disallowed_excludes_from_catalog() {
1253 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
1256 let exec = FilteredToolExecutor::with_disallowed(
1257 inner,
1258 ToolPolicy::InheritAll,
1259 vec!["shell".into()],
1260 );
1261 let defs = exec.tool_definitions_erased();
1262 assert!(
1263 !defs.iter().any(|d| d.id == "shell"),
1264 "shell must be excluded from catalog"
1265 );
1266 assert!(
1267 defs.iter().any(|d| d.id == "web"),
1268 "web must remain in catalog"
1269 );
1270 }
1271
1272 #[tokio::test]
1275 async fn plan_mode_blocks_execute_erased() {
1276 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1277 let res = exec.execute_erased("response").await;
1278 assert!(res.is_err());
1279 }
1280
1281 #[tokio::test]
1282 async fn plan_mode_blocks_execute_confirmed_erased() {
1283 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1284 let res = exec.execute_confirmed_erased("response").await;
1285 assert!(res.is_err());
1286 }
1287
1288 #[tokio::test]
1289 async fn plan_mode_blocks_tool_call() {
1290 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1291 let call = ToolCall {
1292 tool_id: "shell".into(),
1293 params: serde_json::Map::default(),
1294 caller_id: None,
1295 context: None,
1296
1297 tool_call_id: String::new(),
1298 skill_name: None,
1299 };
1300 let res = exec.execute_tool_call_erased(&call).await;
1301 assert!(res.is_err(), "plan mode must block all tool execution");
1302 }
1303
1304 #[test]
1305 fn plan_mode_exposes_real_tool_definitions() {
1306 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
1307 let defs = exec.tool_definitions_erased();
1308 assert_eq!(defs.len(), 2);
1310 assert!(defs.iter().any(|d| d.id == "shell"));
1311 assert!(defs.iter().any(|d| d.id == "web"));
1312 }
1313
1314 struct CheckpointingStub {
1321 trust_recorded: std::sync::Mutex<Option<zeph_tools::SkillTrustLevel>>,
1322 }
1323
1324 impl ErasedToolExecutor for CheckpointingStub {
1325 fn execute_erased<'a>(
1326 &'a self,
1327 _response: &'a str,
1328 ) -> Pin<
1329 Box<
1330 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1331 >,
1332 > {
1333 Box::pin(std::future::ready(Ok(None)))
1334 }
1335
1336 fn execute_confirmed_erased<'a>(
1337 &'a self,
1338 _response: &'a str,
1339 ) -> Pin<
1340 Box<
1341 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1342 >,
1343 > {
1344 Box::pin(std::future::ready(Ok(None)))
1345 }
1346
1347 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1348 vec![]
1349 }
1350
1351 fn execute_tool_call_erased<'a>(
1352 &'a self,
1353 _call: &'a ToolCall,
1354 ) -> Pin<
1355 Box<
1356 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1357 >,
1358 > {
1359 Box::pin(std::future::ready(Ok(None)))
1360 }
1361
1362 fn execute_tool_call_confirmed_erased<'a>(
1363 &'a self,
1364 call: &'a ToolCall,
1365 ) -> Pin<
1366 Box<
1367 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1368 >,
1369 > {
1370 self.execute_tool_call_erased(call)
1371 }
1372
1373 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1374 false
1375 }
1376
1377 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
1378 false
1379 }
1380
1381 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
1382 true
1383 }
1384
1385 fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
1386 *self.trust_recorded.lock().unwrap() = Some(level);
1387 }
1388
1389 fn checkpoint_undo_erased(&self, n: usize) -> zeph_tools::CheckpointActionResult {
1390 zeph_tools::CheckpointActionResult {
1391 reverted_commands: n,
1392 restored: 0,
1393 deleted: 0,
1394 supported: true,
1395 message: "stub-undo".into(),
1396 }
1397 }
1398
1399 fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
1400 zeph_tools::CheckpointActionResult {
1401 reverted_commands: 0,
1402 restored: 0,
1403 deleted: 0,
1404 supported: true,
1405 message: "stub-redo".into(),
1406 }
1407 }
1408
1409 fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
1410 zeph_tools::CheckpointListResult {
1411 entries: vec![],
1412 redo_depth: 7,
1413 supported: true,
1414 }
1415 }
1416 }
1417
1418 fn checkpointing_stub() -> Arc<CheckpointingStub> {
1419 Arc::new(CheckpointingStub {
1420 trust_recorded: std::sync::Mutex::new(None),
1421 })
1422 }
1423
1424 #[test]
1425 fn filtered_executor_forwards_checkpoint_trio_and_speculatable() {
1426 let inner = checkpointing_stub();
1427 let exec = FilteredToolExecutor::new(Arc::clone(&inner) as _, ToolPolicy::InheritAll);
1428
1429 let undo = exec.checkpoint_undo_erased(7);
1430 assert!(
1431 undo.supported,
1432 "checkpoint_undo_erased must forward to inner"
1433 );
1434 assert_eq!(
1435 undo.reverted_commands, 7,
1436 "n must be forwarded, not hardcoded"
1437 );
1438 assert!(
1439 exec.checkpoint_redo_erased().supported,
1440 "checkpoint_redo_erased must forward to inner"
1441 );
1442 assert_eq!(
1443 exec.checkpoint_list_erased().redo_depth,
1444 7,
1445 "checkpoint_list_erased must forward to inner"
1446 );
1447 assert!(
1448 exec.is_tool_speculatable_erased("anything"),
1449 "is_tool_speculatable_erased must forward to inner, not default to false"
1450 );
1451 }
1452
1453 #[tokio::test]
1454 async fn filtered_executor_confirmed_erased_still_enforces_policy() {
1455 let inner = checkpointing_stub();
1458 let exec = FilteredToolExecutor::with_disallowed(
1459 Arc::clone(&inner) as _,
1460 ToolPolicy::InheritAll,
1461 vec!["blocked_tool".into()],
1462 );
1463 let call = ToolCall {
1464 tool_id: "blocked_tool".into(),
1465 params: serde_json::Map::default(),
1466 caller_id: None,
1467 context: None,
1468
1469 tool_call_id: String::new(),
1470 skill_name: None,
1471 };
1472 let res = exec.execute_tool_call_confirmed_erased(&call).await;
1474 assert_matches!(res, Err(ToolError::Blocked { .. }));
1475 }
1476
1477 #[test]
1478 fn plan_mode_forwards_checkpoint_trio_and_speculatable() {
1479 let inner = checkpointing_stub();
1480 let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1481
1482 let undo = exec.checkpoint_undo_erased(3);
1483 assert!(
1484 undo.supported,
1485 "checkpoint_undo_erased must forward to inner"
1486 );
1487 assert_eq!(undo.reverted_commands, 3);
1488 assert!(exec.checkpoint_redo_erased().supported);
1489 assert_eq!(exec.checkpoint_list_erased().redo_depth, 7);
1490 assert!(
1491 exec.is_tool_speculatable_erased("anything"),
1492 "is_tool_speculatable_erased must forward to inner, not default to false"
1493 );
1494 }
1495
1496 #[test]
1497 fn plan_mode_forwards_set_effective_trust() {
1498 let inner = checkpointing_stub();
1499 let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1500 exec.set_effective_trust(zeph_tools::SkillTrustLevel::Quarantined);
1501 assert_eq!(
1502 *inner.trust_recorded.lock().unwrap(),
1503 Some(zeph_tools::SkillTrustLevel::Quarantined),
1504 "set_effective_trust must forward to inner"
1505 );
1506 }
1507
1508 #[tokio::test]
1509 async fn plan_mode_confirmed_erased_still_blocks_execution() {
1510 let inner = checkpointing_stub();
1511 let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1512 let call = ToolCall {
1513 tool_id: "shell".into(),
1514 params: serde_json::Map::default(),
1515 caller_id: None,
1516 context: None,
1517
1518 tool_call_id: String::new(),
1519 skill_name: None,
1520 };
1521 let res = exec.execute_tool_call_confirmed_erased(&call).await;
1522 assert!(
1523 res.is_err(),
1524 "plan mode must block confirmed execution too, not just unconfirmed"
1525 );
1526 }
1527
1528 #[test]
1531 fn normalize_tool_id_lowercases() {
1532 assert_eq!(normalize_tool_id("Read"), "read");
1533 assert_eq!(normalize_tool_id("Write"), "write");
1534 assert_eq!(normalize_tool_id("Edit"), "edit");
1535 }
1536
1537 #[test]
1538 fn normalize_tool_id_strips_args() {
1539 assert_eq!(normalize_tool_id("Bash(cargo *)"), "bash");
1540 assert_eq!(normalize_tool_id("Bash(git *)"), "bash");
1541 assert_eq!(normalize_tool_id("bash"), "bash");
1542 }
1543
1544 #[test]
1545 fn allow_list_pascal_case_permits_lowercase_runtime_id() {
1546 let exec = FilteredToolExecutor::new(
1547 stub_box(&["read", "write", "bash"]),
1548 ToolPolicy::AllowList(vec!["Read".into(), "Write".into(), "Bash(cargo *)".into()]),
1549 );
1550 assert!(exec.is_allowed("read"));
1552 assert!(exec.is_allowed("write"));
1553 assert!(exec.is_allowed("bash"));
1554 assert!(!exec.is_allowed("web"));
1555 let defs = exec.tool_definitions_erased();
1557 assert_eq!(
1558 defs.len(),
1559 3,
1560 "read, write, bash must all appear in catalog"
1561 );
1562 }
1563
1564 #[test]
1567 fn filter_skills_empty_registry_returns_empty() {
1568 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1569 let filter = SkillFilter::default();
1570 let result = filter_skills(®istry, &filter).unwrap();
1571 assert!(result.is_empty());
1572 }
1573
1574 #[test]
1575 fn filter_skills_empty_include_passes_all() {
1576 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1579 let filter = SkillFilter {
1580 include: vec![],
1581 exclude: vec![],
1582 };
1583 let result = filter_skills(®istry, &filter).unwrap();
1584 assert!(result.is_empty());
1585 }
1586
1587 #[test]
1588 fn filter_skills_double_star_pattern_is_error() {
1589 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1590 let filter = SkillFilter {
1591 include: vec!["**".into()],
1592 exclude: vec![],
1593 };
1594 let err = filter_skills(®istry, &filter).unwrap_err();
1595 assert_matches!(err, SubAgentError::Invalid(_));
1596 }
1597
1598 mod proptest_glob {
1599 use proptest::prelude::*;
1600
1601 use super::{compile_glob, glob_match};
1602
1603 proptest! {
1604 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
1605
1606 #[test]
1608 fn glob_match_never_panics(
1609 pattern in "[a-z*-]{1,10}",
1610 name in "[a-z-]{0,15}",
1611 ) {
1612 if !pattern.contains("**")
1614 && let Ok(p) = compile_glob(&pattern)
1615 {
1616 let _ = glob_match(&p, &name);
1617 }
1618 }
1619
1620 #[test]
1622 fn glob_literal_matches_only_exact(
1623 name in "[a-z-]{1,10}",
1624 ) {
1625 let p = compile_glob(&name).unwrap();
1627 prop_assert!(glob_match(&p, &name));
1628
1629 let other = format!("{name}-x");
1631 prop_assert!(!glob_match(&p, &other));
1632 }
1633
1634 #[test]
1636 fn glob_star_matches_everything(name in ".*") {
1637 let p = compile_glob("*").unwrap();
1638 prop_assert!(glob_match(&p, &name));
1639 }
1640 }
1641 }
1642}