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 InvocationHint::ToolCall => None,
37 })
38 .collect()
39}
40
41pub struct FilteredToolExecutor {
51 inner: Arc<dyn ErasedToolExecutor>,
52 policy: ToolPolicy,
53 disallowed: Vec<String>,
54 fenced_tags: Vec<&'static str>,
57}
58
59impl FilteredToolExecutor {
60 #[must_use]
65 pub fn new(inner: Arc<dyn ErasedToolExecutor>, policy: ToolPolicy) -> Self {
66 let fenced_tags = collect_fenced_tags(&*inner);
67 Self {
68 inner,
69 policy,
70 disallowed: Vec::new(),
71 fenced_tags,
72 }
73 }
74
75 #[must_use]
80 pub fn with_disallowed(
81 inner: Arc<dyn ErasedToolExecutor>,
82 policy: ToolPolicy,
83 disallowed: Vec<String>,
84 ) -> Self {
85 let fenced_tags = collect_fenced_tags(&*inner);
86 Self {
87 inner,
88 policy,
89 disallowed,
90 fenced_tags,
91 }
92 }
93
94 fn has_fenced_tool_invocation(&self, response: &str) -> bool {
96 self.fenced_tags
97 .iter()
98 .any(|tag| !extract_fenced_blocks(response, tag).is_empty())
99 }
100
101 fn is_allowed(&self, tool_id: &str) -> bool {
106 if self.disallowed.iter().any(|t| t == tool_id) {
107 return false;
108 }
109 match &self.policy {
110 ToolPolicy::InheritAll => true,
111 ToolPolicy::AllowList(list) => list.iter().any(|t| t == tool_id),
112 ToolPolicy::DenyList(list) => !list.iter().any(|t| t == tool_id),
113 }
114 }
115}
116
117impl ErasedToolExecutor for FilteredToolExecutor {
118 fn execute_erased<'a>(
119 &'a self,
120 response: &'a str,
121 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
122 {
123 if self.has_fenced_tool_invocation(response) {
134 tracing::warn!("sub-agent attempted fenced-block tool invocation — blocked by policy");
135 return Box::pin(std::future::ready(Err(ToolError::Blocked {
136 command: "fenced-block".into(),
137 })));
138 }
139 Box::pin(std::future::ready(Ok(None)))
140 }
141
142 fn execute_confirmed_erased<'a>(
143 &'a self,
144 response: &'a str,
145 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
146 {
147 if self.has_fenced_tool_invocation(response) {
149 tracing::warn!(
150 "sub-agent attempted confirmed fenced-block tool invocation — blocked by policy"
151 );
152 return Box::pin(std::future::ready(Err(ToolError::Blocked {
153 command: "fenced-block".into(),
154 })));
155 }
156 Box::pin(std::future::ready(Ok(None)))
157 }
158
159 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
160 self.inner
162 .tool_definitions_erased()
163 .into_iter()
164 .filter(|def| self.is_allowed(&def.id))
165 .collect()
166 }
167
168 fn execute_tool_call_erased<'a>(
169 &'a self,
170 call: &'a ToolCall,
171 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
172 {
173 if !self.is_allowed(call.tool_id.as_str()) {
174 tracing::warn!(
175 tool_id = %call.tool_id,
176 "sub-agent tool call rejected by policy"
177 );
178 return Box::pin(std::future::ready(Err(ToolError::Blocked {
179 command: call.tool_id.to_string(),
180 })));
181 }
182 Box::pin(self.inner.execute_tool_call_erased(call))
183 }
184
185 fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
186 self.inner.set_skill_env(env);
187 }
188
189 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
190 self.inner.is_tool_retryable_erased(tool_id)
191 }
192
193 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
194 self.inner.requires_confirmation_erased(call)
195 }
196}
197
198pub struct PlanModeExecutor {
207 inner: Arc<dyn ErasedToolExecutor>,
208}
209
210impl PlanModeExecutor {
211 #[must_use]
213 pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
214 Self { inner }
215 }
216}
217
218impl ErasedToolExecutor for PlanModeExecutor {
219 fn execute_erased<'a>(
220 &'a self,
221 _response: &'a str,
222 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
223 {
224 Box::pin(std::future::ready(Err(ToolError::Blocked {
225 command: "plan_mode".into(),
226 })))
227 }
228
229 fn execute_confirmed_erased<'a>(
230 &'a self,
231 _response: &'a str,
232 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
233 {
234 Box::pin(std::future::ready(Err(ToolError::Blocked {
235 command: "plan_mode".into(),
236 })))
237 }
238
239 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
240 self.inner.tool_definitions_erased()
241 }
242
243 fn execute_tool_call_erased<'a>(
244 &'a self,
245 call: &'a ToolCall,
246 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
247 {
248 tracing::debug!(
249 tool_id = %call.tool_id,
250 "tool execution blocked in plan mode"
251 );
252 Box::pin(std::future::ready(Err(ToolError::Blocked {
253 command: call.tool_id.to_string(),
254 })))
255 }
256
257 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
258 self.inner.set_skill_env(env);
259 }
260
261 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
262 false
263 }
264
265 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
266 false
267 }
268}
269
270pub fn filter_skills(
299 registry: &SkillRegistry,
300 filter: &SkillFilter,
301) -> Result<Vec<Skill>, SubAgentError> {
302 let compiled_include = compile_globs(&filter.include)?;
303 let compiled_exclude = compile_globs(&filter.exclude)?;
304
305 let all: Vec<Skill> = registry
306 .all_meta()
307 .into_iter()
308 .filter(|meta| {
309 let name = &meta.name;
310 let included =
311 compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
312 let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
313 included && !excluded
314 })
315 .filter_map(|meta| registry.skill(&meta.name).ok())
316 .collect();
317
318 Ok(all)
319}
320
321struct GlobPattern {
323 raw: String,
324 prefix: String,
325 suffix: Option<String>,
326 is_star: bool,
327}
328
329fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
330 patterns.iter().map(|p| compile_glob(p)).collect()
331}
332
333fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
334 if pattern.contains("**") {
337 return Err(SubAgentError::Invalid(format!(
338 "glob pattern '{pattern}' uses '**' which is not supported"
339 )));
340 }
341
342 let is_star = pattern == "*";
343
344 let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
345 let before = pattern[..pos].to_owned();
346 let after = pattern[pos + 1..].to_owned();
347 (before, Some(after))
348 } else {
349 (pattern.to_owned(), None)
350 };
351
352 Ok(GlobPattern {
353 raw: pattern.to_owned(),
354 prefix,
355 suffix,
356 is_star,
357 })
358}
359
360fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
361 if pattern.is_star {
362 return true;
363 }
364
365 match &pattern.suffix {
366 None => name == pattern.raw,
367 Some(suf) => {
368 name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
369 name.len() >= pattern.prefix.len() + suf.len()
371 }
372 }
373 }
374}
375
376#[cfg(test)]
379mod tests {
380 #![allow(clippy::default_trait_access)]
381
382 use super::*;
383 use crate::def::ToolPolicy;
384
385 struct StubExecutor {
388 tools: Vec<&'static str>,
389 }
390
391 struct StubFencedExecutor {
393 tag: &'static str,
394 }
395
396 impl ErasedToolExecutor for StubFencedExecutor {
397 fn execute_erased<'a>(
398 &'a self,
399 _response: &'a str,
400 ) -> Pin<
401 Box<
402 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
403 >,
404 > {
405 Box::pin(std::future::ready(Ok(None)))
406 }
407
408 fn execute_confirmed_erased<'a>(
409 &'a self,
410 _response: &'a str,
411 ) -> Pin<
412 Box<
413 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
414 >,
415 > {
416 Box::pin(std::future::ready(Ok(None)))
417 }
418
419 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
420 use zeph_tools::registry::InvocationHint;
421 vec![ToolDef {
422 id: self.tag.into(),
423 description: "fenced stub".into(),
424 schema: schemars::Schema::default(),
425 invocation: InvocationHint::FencedBlock(self.tag),
426 output_schema: None,
427 }]
428 }
429
430 fn execute_tool_call_erased<'a>(
431 &'a self,
432 call: &'a ToolCall,
433 ) -> Pin<
434 Box<
435 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
436 >,
437 > {
438 let result = Ok(Some(ToolOutput {
439 tool_name: call.tool_id.clone(),
440 summary: "ok".into(),
441 blocks_executed: 1,
442 filter_stats: None,
443 diff: None,
444 streamed: false,
445 terminal_id: None,
446 locations: None,
447 raw_response: None,
448 claim_source: None,
449 }));
450 Box::pin(std::future::ready(result))
451 }
452
453 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
454 false
455 }
456
457 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
458 false
459 }
460 }
461
462 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
463 Arc::new(StubFencedExecutor { tag })
464 }
465
466 impl ErasedToolExecutor for StubExecutor {
467 fn execute_erased<'a>(
468 &'a self,
469 _response: &'a str,
470 ) -> Pin<
471 Box<
472 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
473 >,
474 > {
475 Box::pin(std::future::ready(Ok(None)))
476 }
477
478 fn execute_confirmed_erased<'a>(
479 &'a self,
480 _response: &'a str,
481 ) -> Pin<
482 Box<
483 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
484 >,
485 > {
486 Box::pin(std::future::ready(Ok(None)))
487 }
488
489 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
490 use zeph_tools::registry::InvocationHint;
492 self.tools
493 .iter()
494 .map(|id| ToolDef {
495 id: (*id).into(),
496 description: "stub".into(),
497 schema: schemars::Schema::default(),
498 invocation: InvocationHint::ToolCall,
499 output_schema: None,
500 })
501 .collect()
502 }
503
504 fn execute_tool_call_erased<'a>(
505 &'a self,
506 call: &'a ToolCall,
507 ) -> Pin<
508 Box<
509 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
510 >,
511 > {
512 let result = Ok(Some(ToolOutput {
513 tool_name: call.tool_id.clone(),
514 summary: "ok".into(),
515 blocks_executed: 1,
516 filter_stats: None,
517 diff: None,
518 streamed: false,
519 terminal_id: None,
520 locations: None,
521 raw_response: None,
522 claim_source: None,
523 }));
524 Box::pin(std::future::ready(result))
525 }
526
527 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
528 false
529 }
530
531 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
532 false
533 }
534 }
535
536 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
537 Arc::new(StubExecutor {
538 tools: tools.to_vec(),
539 })
540 }
541
542 #[tokio::test]
543 async fn allow_list_permits_listed_tool() {
544 let exec = FilteredToolExecutor::new(
545 stub_box(&["shell", "web"]),
546 ToolPolicy::AllowList(vec!["shell".into()]),
547 );
548 let call = ToolCall {
549 tool_id: "shell".into(),
550 params: serde_json::Map::default(),
551 caller_id: None,
552 context: None,
553
554 tool_call_id: String::new(),
555 };
556 let res = exec.execute_tool_call_erased(&call).await.unwrap();
557 assert!(res.is_some());
558 }
559
560 #[tokio::test]
561 async fn allow_list_blocks_unlisted_tool() {
562 let exec = FilteredToolExecutor::new(
563 stub_box(&["shell", "web"]),
564 ToolPolicy::AllowList(vec!["shell".into()]),
565 );
566 let call = ToolCall {
567 tool_id: "web".into(),
568 params: serde_json::Map::default(),
569 caller_id: None,
570 context: None,
571
572 tool_call_id: String::new(),
573 };
574 let res = exec.execute_tool_call_erased(&call).await;
575 assert!(res.is_err());
576 }
577
578 #[tokio::test]
579 async fn deny_list_blocks_listed_tool() {
580 let exec = FilteredToolExecutor::new(
581 stub_box(&["shell", "web"]),
582 ToolPolicy::DenyList(vec!["shell".into()]),
583 );
584 let call = ToolCall {
585 tool_id: "shell".into(),
586 params: serde_json::Map::default(),
587 caller_id: None,
588 context: None,
589
590 tool_call_id: String::new(),
591 };
592 let res = exec.execute_tool_call_erased(&call).await;
593 assert!(res.is_err());
594 }
595
596 #[tokio::test]
597 async fn inherit_all_permits_any_tool() {
598 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
599 let call = ToolCall {
600 tool_id: "shell".into(),
601 params: serde_json::Map::default(),
602 caller_id: None,
603 context: None,
604
605 tool_call_id: String::new(),
606 };
607 let res = exec.execute_tool_call_erased(&call).await.unwrap();
608 assert!(res.is_some());
609 }
610
611 #[test]
612 fn tool_definitions_filtered_by_allow_list() {
613 let exec = FilteredToolExecutor::new(
614 stub_box(&["shell", "web"]),
615 ToolPolicy::AllowList(vec!["shell".into()]),
616 );
617 let defs = exec.tool_definitions_erased();
618 assert_eq!(defs.len(), 1);
619 assert_eq!(defs[0].id, "shell");
620 }
621
622 fn matches(pattern: &str, name: &str) -> bool {
625 let p = compile_glob(pattern).unwrap();
626 glob_match(&p, name)
627 }
628
629 #[test]
630 fn glob_star_matches_all() {
631 assert!(matches("*", "anything"));
632 assert!(matches("*", ""));
633 }
634
635 #[test]
636 fn glob_prefix_star() {
637 assert!(matches("git-*", "git-commit"));
638 assert!(matches("git-*", "git-status"));
639 assert!(!matches("git-*", "rust-fmt"));
640 }
641
642 #[test]
643 fn glob_literal_exact_match() {
644 assert!(matches("shell", "shell"));
645 assert!(!matches("shell", "shell-extra"));
646 }
647
648 #[test]
649 fn glob_star_suffix() {
650 assert!(matches("*-review", "code-review"));
651 assert!(!matches("*-review", "code-reviewer"));
652 }
653
654 #[test]
655 fn glob_double_star_is_error() {
656 assert!(compile_glob("**").is_err());
657 }
658
659 #[test]
660 fn glob_mid_string_wildcard() {
661 assert!(matches("a*b", "axb"));
663 assert!(matches("a*b", "aXYZb"));
664 assert!(!matches("a*b", "ab-extra"));
665 assert!(!matches("a*b", "xab"));
666 }
667
668 #[tokio::test]
671 async fn deny_list_permits_unlisted_tool() {
672 let exec = FilteredToolExecutor::new(
673 stub_box(&["shell", "web"]),
674 ToolPolicy::DenyList(vec!["shell".into()]),
675 );
676 let call = ToolCall {
677 tool_id: "web".into(), params: serde_json::Map::default(),
679 caller_id: None,
680 context: None,
681
682 tool_call_id: String::new(),
683 };
684 let res = exec.execute_tool_call_erased(&call).await.unwrap();
685 assert!(res.is_some());
686 }
687
688 #[test]
689 fn tool_definitions_filtered_by_deny_list() {
690 let exec = FilteredToolExecutor::new(
691 stub_box(&["shell", "web"]),
692 ToolPolicy::DenyList(vec!["shell".into()]),
693 );
694 let defs = exec.tool_definitions_erased();
695 assert_eq!(defs.len(), 1);
696 assert_eq!(defs[0].id, "web");
697 }
698
699 #[test]
700 fn tool_definitions_inherit_all_returns_all() {
701 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
702 let defs = exec.tool_definitions_erased();
703 assert_eq!(defs.len(), 2);
704 }
705
706 #[tokio::test]
709 async fn fenced_block_matching_tag_is_blocked() {
710 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
712 let res = exec.execute_erased("```bash\nls\n```").await;
713 assert!(
714 res.is_err(),
715 "actual fenced-block invocation must be blocked"
716 );
717 }
718
719 #[tokio::test]
720 async fn fenced_block_matching_tag_confirmed_is_blocked() {
721 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
722 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
723 assert!(
724 res.is_err(),
725 "actual fenced-block invocation (confirmed) must be blocked"
726 );
727 }
728
729 #[tokio::test]
730 async fn no_fenced_tools_plain_text_returns_ok_none() {
731 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
733 let res = exec.execute_erased("This is a plain text response.").await;
734 assert!(
735 res.unwrap().is_none(),
736 "plain text must not be treated as a tool call"
737 );
738 }
739
740 #[tokio::test]
741 async fn markdown_non_tool_fence_returns_ok_none() {
742 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
744 let res = exec
745 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
746 .await;
747 assert!(
748 res.unwrap().is_none(),
749 "non-tool code fence must not trigger blocking"
750 );
751 }
752
753 #[tokio::test]
754 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
755 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
756 let res = exec
757 .execute_confirmed_erased("Plain response without any fences.")
758 .await;
759 assert!(res.unwrap().is_none());
760 }
761
762 #[tokio::test]
766 async fn fenced_executor_plain_text_returns_ok_none() {
767 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
768 let res = exec
769 .execute_erased("Here is my analysis of the code. No shell commands needed.")
770 .await;
771 assert!(
772 res.unwrap().is_none(),
773 "plain text with fenced executor must not be treated as a tool call"
774 );
775 }
776
777 #[tokio::test]
780 async fn unclosed_fenced_block_returns_ok_none() {
781 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
782 let res = exec.execute_erased("```bash\nls -la\n").await;
783 assert!(
784 res.unwrap().is_none(),
785 "unclosed fenced block must not be treated as a tool invocation"
786 );
787 }
788
789 #[tokio::test]
791 async fn multiple_fences_one_matching_tag_is_blocked() {
792 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
793 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
794 let res = exec.execute_erased(response).await;
795 assert!(
796 res.is_err(),
797 "response containing a matching fenced block must be blocked"
798 );
799 }
800
801 #[tokio::test]
804 async fn disallowed_blocks_tool_from_allow_list() {
805 let exec = FilteredToolExecutor::with_disallowed(
806 stub_box(&["shell", "web"]),
807 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
808 vec!["shell".into()],
809 );
810 let call = ToolCall {
811 tool_id: "shell".into(),
812 params: serde_json::Map::default(),
813 caller_id: None,
814 context: None,
815
816 tool_call_id: String::new(),
817 };
818 let res = exec.execute_tool_call_erased(&call).await;
819 assert!(
820 res.is_err(),
821 "disallowed tool must be blocked even if in allow list"
822 );
823 }
824
825 #[tokio::test]
826 async fn disallowed_allows_non_disallowed_tool() {
827 let exec = FilteredToolExecutor::with_disallowed(
828 stub_box(&["shell", "web"]),
829 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
830 vec!["shell".into()],
831 );
832 let call = ToolCall {
833 tool_id: "web".into(),
834 params: serde_json::Map::default(),
835 caller_id: None,
836 context: None,
837
838 tool_call_id: String::new(),
839 };
840 let res = exec.execute_tool_call_erased(&call).await;
841 assert!(res.is_ok(), "non-disallowed tool must be allowed");
842 }
843
844 #[test]
845 fn disallowed_empty_list_no_change() {
846 let exec = FilteredToolExecutor::with_disallowed(
847 stub_box(&["shell", "web"]),
848 ToolPolicy::InheritAll,
849 vec![],
850 );
851 let defs = exec.tool_definitions_erased();
852 assert_eq!(defs.len(), 2);
853 }
854
855 #[test]
856 fn tool_definitions_filters_disallowed_tools() {
857 let exec = FilteredToolExecutor::with_disallowed(
858 stub_box(&["shell", "web", "dangerous"]),
859 ToolPolicy::InheritAll,
860 vec!["dangerous".into()],
861 );
862 let defs = exec.tool_definitions_erased();
863 assert_eq!(defs.len(), 2);
864 assert!(!defs.iter().any(|d| d.id == "dangerous"));
865 }
866
867 #[test]
870 fn plan_mode_with_disallowed_excludes_from_catalog() {
871 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
874 let exec = FilteredToolExecutor::with_disallowed(
875 inner,
876 ToolPolicy::InheritAll,
877 vec!["shell".into()],
878 );
879 let defs = exec.tool_definitions_erased();
880 assert!(
881 !defs.iter().any(|d| d.id == "shell"),
882 "shell must be excluded from catalog"
883 );
884 assert!(
885 defs.iter().any(|d| d.id == "web"),
886 "web must remain in catalog"
887 );
888 }
889
890 #[tokio::test]
893 async fn plan_mode_blocks_execute_erased() {
894 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
895 let res = exec.execute_erased("response").await;
896 assert!(res.is_err());
897 }
898
899 #[tokio::test]
900 async fn plan_mode_blocks_execute_confirmed_erased() {
901 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
902 let res = exec.execute_confirmed_erased("response").await;
903 assert!(res.is_err());
904 }
905
906 #[tokio::test]
907 async fn plan_mode_blocks_tool_call() {
908 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
909 let call = ToolCall {
910 tool_id: "shell".into(),
911 params: serde_json::Map::default(),
912 caller_id: None,
913 context: None,
914
915 tool_call_id: String::new(),
916 };
917 let res = exec.execute_tool_call_erased(&call).await;
918 assert!(res.is_err(), "plan mode must block all tool execution");
919 }
920
921 #[test]
922 fn plan_mode_exposes_real_tool_definitions() {
923 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
924 let defs = exec.tool_definitions_erased();
925 assert_eq!(defs.len(), 2);
927 assert!(defs.iter().any(|d| d.id == "shell"));
928 assert!(defs.iter().any(|d| d.id == "web"));
929 }
930
931 #[test]
934 fn filter_skills_empty_registry_returns_empty() {
935 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
936 let filter = SkillFilter::default();
937 let result = filter_skills(®istry, &filter).unwrap();
938 assert!(result.is_empty());
939 }
940
941 #[test]
942 fn filter_skills_empty_include_passes_all() {
943 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
946 let filter = SkillFilter {
947 include: vec![],
948 exclude: vec![],
949 };
950 let result = filter_skills(®istry, &filter).unwrap();
951 assert!(result.is_empty());
952 }
953
954 #[test]
955 fn filter_skills_double_star_pattern_is_error() {
956 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
957 let filter = SkillFilter {
958 include: vec!["**".into()],
959 exclude: vec![],
960 };
961 let err = filter_skills(®istry, &filter).unwrap_err();
962 assert!(matches!(err, SubAgentError::Invalid(_)));
963 }
964
965 mod proptest_glob {
966 use proptest::prelude::*;
967
968 use super::{compile_glob, glob_match};
969
970 proptest! {
971 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
972
973 #[test]
975 fn glob_match_never_panics(
976 pattern in "[a-z*-]{1,10}",
977 name in "[a-z-]{0,15}",
978 ) {
979 if !pattern.contains("**")
981 && let Ok(p) = compile_glob(&pattern)
982 {
983 let _ = glob_match(&p, &name);
984 }
985 }
986
987 #[test]
989 fn glob_literal_matches_only_exact(
990 name in "[a-z-]{1,10}",
991 ) {
992 let p = compile_glob(&name).unwrap();
994 prop_assert!(glob_match(&p, &name));
995
996 let other = format!("{name}-x");
998 prop_assert!(!glob_match(&p, &other));
999 }
1000
1001 #[test]
1003 fn glob_star_matches_everything(name in ".*") {
1004 let p = compile_glob("*").unwrap();
1005 prop_assert!(glob_match(&p, &name));
1006 }
1007 }
1008 }
1009}