1use std::collections::HashMap;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use zeph_skills::loader::Skill;
9use zeph_skills::registry::SkillRegistry;
10use zeph_tools::ToolCall;
11use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput, extract_fenced_blocks};
12use zeph_tools::registry::{InvocationHint, ToolDef};
13
14use super::def::{SkillFilter, ToolPolicy};
15use super::error::SubAgentError;
16
17fn collect_fenced_tags(executor: &dyn ErasedToolExecutor) -> Vec<&'static str> {
21 executor
22 .tool_definitions_erased()
23 .into_iter()
24 .filter_map(|def| match def.invocation {
25 InvocationHint::FencedBlock(tag) => Some(tag),
26 InvocationHint::ToolCall => None,
27 })
28 .collect()
29}
30
31pub struct FilteredToolExecutor {
41 inner: Arc<dyn ErasedToolExecutor>,
42 policy: ToolPolicy,
43 disallowed: Vec<String>,
44 fenced_tags: Vec<&'static str>,
47}
48
49impl FilteredToolExecutor {
50 #[must_use]
52 pub fn new(inner: Arc<dyn ErasedToolExecutor>, policy: ToolPolicy) -> Self {
53 let fenced_tags = collect_fenced_tags(&*inner);
54 Self {
55 inner,
56 policy,
57 disallowed: Vec::new(),
58 fenced_tags,
59 }
60 }
61
62 #[must_use]
67 pub fn with_disallowed(
68 inner: Arc<dyn ErasedToolExecutor>,
69 policy: ToolPolicy,
70 disallowed: Vec<String>,
71 ) -> Self {
72 let fenced_tags = collect_fenced_tags(&*inner);
73 Self {
74 inner,
75 policy,
76 disallowed,
77 fenced_tags,
78 }
79 }
80
81 fn has_fenced_tool_invocation(&self, response: &str) -> bool {
83 self.fenced_tags
84 .iter()
85 .any(|tag| !extract_fenced_blocks(response, tag).is_empty())
86 }
87
88 fn is_allowed(&self, tool_id: &str) -> bool {
93 if self.disallowed.iter().any(|t| t == tool_id) {
94 return false;
95 }
96 match &self.policy {
97 ToolPolicy::InheritAll => true,
98 ToolPolicy::AllowList(list) => list.iter().any(|t| t == tool_id),
99 ToolPolicy::DenyList(list) => !list.iter().any(|t| t == tool_id),
100 }
101 }
102}
103
104impl ErasedToolExecutor for FilteredToolExecutor {
105 fn execute_erased<'a>(
106 &'a self,
107 response: &'a str,
108 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
109 {
110 if self.has_fenced_tool_invocation(response) {
121 tracing::warn!("sub-agent attempted fenced-block tool invocation — blocked by policy");
122 return Box::pin(std::future::ready(Err(ToolError::Blocked {
123 command: "fenced-block".into(),
124 })));
125 }
126 Box::pin(std::future::ready(Ok(None)))
127 }
128
129 fn execute_confirmed_erased<'a>(
130 &'a self,
131 response: &'a str,
132 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
133 {
134 if self.has_fenced_tool_invocation(response) {
136 tracing::warn!(
137 "sub-agent attempted confirmed fenced-block tool invocation — blocked by policy"
138 );
139 return Box::pin(std::future::ready(Err(ToolError::Blocked {
140 command: "fenced-block".into(),
141 })));
142 }
143 Box::pin(std::future::ready(Ok(None)))
144 }
145
146 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
147 self.inner
149 .tool_definitions_erased()
150 .into_iter()
151 .filter(|def| self.is_allowed(&def.id))
152 .collect()
153 }
154
155 fn execute_tool_call_erased<'a>(
156 &'a self,
157 call: &'a ToolCall,
158 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
159 {
160 if !self.is_allowed(&call.tool_id) {
161 tracing::warn!(
162 tool_id = %call.tool_id,
163 "sub-agent tool call rejected by policy"
164 );
165 return Box::pin(std::future::ready(Err(ToolError::Blocked {
166 command: call.tool_id.clone(),
167 })));
168 }
169 Box::pin(self.inner.execute_tool_call_erased(call))
170 }
171
172 fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
173 self.inner.set_skill_env(env);
174 }
175
176 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
177 self.inner.is_tool_retryable_erased(tool_id)
178 }
179}
180
181pub struct PlanModeExecutor {
190 inner: Arc<dyn ErasedToolExecutor>,
191}
192
193impl PlanModeExecutor {
194 #[must_use]
196 pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
197 Self { inner }
198 }
199}
200
201impl ErasedToolExecutor for PlanModeExecutor {
202 fn execute_erased<'a>(
203 &'a self,
204 _response: &'a str,
205 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
206 {
207 Box::pin(std::future::ready(Err(ToolError::Blocked {
208 command: "plan_mode".into(),
209 })))
210 }
211
212 fn execute_confirmed_erased<'a>(
213 &'a self,
214 _response: &'a str,
215 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
216 {
217 Box::pin(std::future::ready(Err(ToolError::Blocked {
218 command: "plan_mode".into(),
219 })))
220 }
221
222 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
223 self.inner.tool_definitions_erased()
224 }
225
226 fn execute_tool_call_erased<'a>(
227 &'a self,
228 call: &'a ToolCall,
229 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
230 {
231 tracing::debug!(
232 tool_id = %call.tool_id,
233 "tool execution blocked in plan mode"
234 );
235 Box::pin(std::future::ready(Err(ToolError::Blocked {
236 command: call.tool_id.clone(),
237 })))
238 }
239
240 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
241 self.inner.set_skill_env(env);
242 }
243
244 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
245 false
246 }
247}
248
249pub fn filter_skills(
260 registry: &SkillRegistry,
261 filter: &SkillFilter,
262) -> Result<Vec<Skill>, SubAgentError> {
263 let compiled_include = compile_globs(&filter.include)?;
264 let compiled_exclude = compile_globs(&filter.exclude)?;
265
266 let all: Vec<Skill> = registry
267 .all_meta()
268 .into_iter()
269 .filter(|meta| {
270 let name = &meta.name;
271 let included =
272 compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
273 let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
274 included && !excluded
275 })
276 .filter_map(|meta| registry.get_skill(&meta.name).ok())
277 .collect();
278
279 Ok(all)
280}
281
282struct GlobPattern {
284 raw: String,
285 prefix: String,
286 suffix: Option<String>,
287 is_star: bool,
288}
289
290fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
291 patterns.iter().map(|p| compile_glob(p)).collect()
292}
293
294fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
295 if pattern.contains("**") {
298 return Err(SubAgentError::Invalid(format!(
299 "glob pattern '{pattern}' uses '**' which is not supported"
300 )));
301 }
302
303 let is_star = pattern == "*";
304
305 let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
306 let before = pattern[..pos].to_owned();
307 let after = pattern[pos + 1..].to_owned();
308 (before, Some(after))
309 } else {
310 (pattern.to_owned(), None)
311 };
312
313 Ok(GlobPattern {
314 raw: pattern.to_owned(),
315 prefix,
316 suffix,
317 is_star,
318 })
319}
320
321fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
322 if pattern.is_star {
323 return true;
324 }
325
326 match &pattern.suffix {
327 None => name == pattern.raw,
328 Some(suf) => {
329 name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
330 name.len() >= pattern.prefix.len() + suf.len()
332 }
333 }
334 }
335}
336
337#[cfg(test)]
340mod tests {
341 #![allow(clippy::default_trait_access)]
342
343 use super::*;
344 use crate::def::ToolPolicy;
345
346 struct StubExecutor {
349 tools: Vec<&'static str>,
350 }
351
352 struct StubFencedExecutor {
354 tag: &'static str,
355 }
356
357 impl ErasedToolExecutor for StubFencedExecutor {
358 fn execute_erased<'a>(
359 &'a self,
360 _response: &'a str,
361 ) -> Pin<
362 Box<
363 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
364 >,
365 > {
366 Box::pin(std::future::ready(Ok(None)))
367 }
368
369 fn execute_confirmed_erased<'a>(
370 &'a self,
371 _response: &'a str,
372 ) -> Pin<
373 Box<
374 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
375 >,
376 > {
377 Box::pin(std::future::ready(Ok(None)))
378 }
379
380 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
381 use zeph_tools::registry::InvocationHint;
382 vec![ToolDef {
383 id: self.tag.into(),
384 description: "fenced stub".into(),
385 schema: schemars::Schema::default(),
386 invocation: InvocationHint::FencedBlock(self.tag),
387 }]
388 }
389
390 fn execute_tool_call_erased<'a>(
391 &'a self,
392 call: &'a ToolCall,
393 ) -> Pin<
394 Box<
395 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
396 >,
397 > {
398 let result = Ok(Some(ToolOutput {
399 tool_name: call.tool_id.clone(),
400 summary: "ok".into(),
401 blocks_executed: 1,
402 filter_stats: None,
403 diff: None,
404 streamed: false,
405 terminal_id: None,
406 locations: None,
407 raw_response: None,
408 }));
409 Box::pin(std::future::ready(result))
410 }
411
412 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
413 false
414 }
415 }
416
417 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
418 Arc::new(StubFencedExecutor { tag })
419 }
420
421 impl ErasedToolExecutor for StubExecutor {
422 fn execute_erased<'a>(
423 &'a self,
424 _response: &'a str,
425 ) -> Pin<
426 Box<
427 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
428 >,
429 > {
430 Box::pin(std::future::ready(Ok(None)))
431 }
432
433 fn execute_confirmed_erased<'a>(
434 &'a self,
435 _response: &'a str,
436 ) -> Pin<
437 Box<
438 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
439 >,
440 > {
441 Box::pin(std::future::ready(Ok(None)))
442 }
443
444 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
445 use zeph_tools::registry::InvocationHint;
447 self.tools
448 .iter()
449 .map(|id| ToolDef {
450 id: (*id).into(),
451 description: "stub".into(),
452 schema: schemars::Schema::default(),
453 invocation: InvocationHint::ToolCall,
454 })
455 .collect()
456 }
457
458 fn execute_tool_call_erased<'a>(
459 &'a self,
460 call: &'a ToolCall,
461 ) -> Pin<
462 Box<
463 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
464 >,
465 > {
466 let result = Ok(Some(ToolOutput {
467 tool_name: call.tool_id.clone(),
468 summary: "ok".into(),
469 blocks_executed: 1,
470 filter_stats: None,
471 diff: None,
472 streamed: false,
473 terminal_id: None,
474 locations: None,
475 raw_response: None,
476 }));
477 Box::pin(std::future::ready(result))
478 }
479
480 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
481 false
482 }
483 }
484
485 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
486 Arc::new(StubExecutor {
487 tools: tools.to_vec(),
488 })
489 }
490
491 #[tokio::test]
492 async fn allow_list_permits_listed_tool() {
493 let exec = FilteredToolExecutor::new(
494 stub_box(&["shell", "web"]),
495 ToolPolicy::AllowList(vec!["shell".into()]),
496 );
497 let call = ToolCall {
498 tool_id: "shell".into(),
499 params: serde_json::Map::default(),
500 };
501 let res = exec.execute_tool_call_erased(&call).await.unwrap();
502 assert!(res.is_some());
503 }
504
505 #[tokio::test]
506 async fn allow_list_blocks_unlisted_tool() {
507 let exec = FilteredToolExecutor::new(
508 stub_box(&["shell", "web"]),
509 ToolPolicy::AllowList(vec!["shell".into()]),
510 );
511 let call = ToolCall {
512 tool_id: "web".into(),
513 params: serde_json::Map::default(),
514 };
515 let res = exec.execute_tool_call_erased(&call).await;
516 assert!(res.is_err());
517 }
518
519 #[tokio::test]
520 async fn deny_list_blocks_listed_tool() {
521 let exec = FilteredToolExecutor::new(
522 stub_box(&["shell", "web"]),
523 ToolPolicy::DenyList(vec!["shell".into()]),
524 );
525 let call = ToolCall {
526 tool_id: "shell".into(),
527 params: serde_json::Map::default(),
528 };
529 let res = exec.execute_tool_call_erased(&call).await;
530 assert!(res.is_err());
531 }
532
533 #[tokio::test]
534 async fn inherit_all_permits_any_tool() {
535 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
536 let call = ToolCall {
537 tool_id: "shell".into(),
538 params: serde_json::Map::default(),
539 };
540 let res = exec.execute_tool_call_erased(&call).await.unwrap();
541 assert!(res.is_some());
542 }
543
544 #[test]
545 fn tool_definitions_filtered_by_allow_list() {
546 let exec = FilteredToolExecutor::new(
547 stub_box(&["shell", "web"]),
548 ToolPolicy::AllowList(vec!["shell".into()]),
549 );
550 let defs = exec.tool_definitions_erased();
551 assert_eq!(defs.len(), 1);
552 assert_eq!(defs[0].id, "shell");
553 }
554
555 fn matches(pattern: &str, name: &str) -> bool {
558 let p = compile_glob(pattern).unwrap();
559 glob_match(&p, name)
560 }
561
562 #[test]
563 fn glob_star_matches_all() {
564 assert!(matches("*", "anything"));
565 assert!(matches("*", ""));
566 }
567
568 #[test]
569 fn glob_prefix_star() {
570 assert!(matches("git-*", "git-commit"));
571 assert!(matches("git-*", "git-status"));
572 assert!(!matches("git-*", "rust-fmt"));
573 }
574
575 #[test]
576 fn glob_literal_exact_match() {
577 assert!(matches("shell", "shell"));
578 assert!(!matches("shell", "shell-extra"));
579 }
580
581 #[test]
582 fn glob_star_suffix() {
583 assert!(matches("*-review", "code-review"));
584 assert!(!matches("*-review", "code-reviewer"));
585 }
586
587 #[test]
588 fn glob_double_star_is_error() {
589 assert!(compile_glob("**").is_err());
590 }
591
592 #[test]
593 fn glob_mid_string_wildcard() {
594 assert!(matches("a*b", "axb"));
596 assert!(matches("a*b", "aXYZb"));
597 assert!(!matches("a*b", "ab-extra"));
598 assert!(!matches("a*b", "xab"));
599 }
600
601 #[tokio::test]
604 async fn deny_list_permits_unlisted_tool() {
605 let exec = FilteredToolExecutor::new(
606 stub_box(&["shell", "web"]),
607 ToolPolicy::DenyList(vec!["shell".into()]),
608 );
609 let call = ToolCall {
610 tool_id: "web".into(), params: serde_json::Map::default(),
612 };
613 let res = exec.execute_tool_call_erased(&call).await.unwrap();
614 assert!(res.is_some());
615 }
616
617 #[test]
618 fn tool_definitions_filtered_by_deny_list() {
619 let exec = FilteredToolExecutor::new(
620 stub_box(&["shell", "web"]),
621 ToolPolicy::DenyList(vec!["shell".into()]),
622 );
623 let defs = exec.tool_definitions_erased();
624 assert_eq!(defs.len(), 1);
625 assert_eq!(defs[0].id, "web");
626 }
627
628 #[test]
629 fn tool_definitions_inherit_all_returns_all() {
630 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
631 let defs = exec.tool_definitions_erased();
632 assert_eq!(defs.len(), 2);
633 }
634
635 #[tokio::test]
638 async fn fenced_block_matching_tag_is_blocked() {
639 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
641 let res = exec.execute_erased("```bash\nls\n```").await;
642 assert!(
643 res.is_err(),
644 "actual fenced-block invocation must be blocked"
645 );
646 }
647
648 #[tokio::test]
649 async fn fenced_block_matching_tag_confirmed_is_blocked() {
650 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
651 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
652 assert!(
653 res.is_err(),
654 "actual fenced-block invocation (confirmed) must be blocked"
655 );
656 }
657
658 #[tokio::test]
659 async fn no_fenced_tools_plain_text_returns_ok_none() {
660 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
662 let res = exec.execute_erased("This is a plain text response.").await;
663 assert!(
664 res.unwrap().is_none(),
665 "plain text must not be treated as a tool call"
666 );
667 }
668
669 #[tokio::test]
670 async fn markdown_non_tool_fence_returns_ok_none() {
671 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
673 let res = exec
674 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
675 .await;
676 assert!(
677 res.unwrap().is_none(),
678 "non-tool code fence must not trigger blocking"
679 );
680 }
681
682 #[tokio::test]
683 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
684 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
685 let res = exec
686 .execute_confirmed_erased("Plain response without any fences.")
687 .await;
688 assert!(res.unwrap().is_none());
689 }
690
691 #[tokio::test]
695 async fn fenced_executor_plain_text_returns_ok_none() {
696 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
697 let res = exec
698 .execute_erased("Here is my analysis of the code. No shell commands needed.")
699 .await;
700 assert!(
701 res.unwrap().is_none(),
702 "plain text with fenced executor must not be treated as a tool call"
703 );
704 }
705
706 #[tokio::test]
709 async fn unclosed_fenced_block_returns_ok_none() {
710 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
711 let res = exec.execute_erased("```bash\nls -la\n").await;
712 assert!(
713 res.unwrap().is_none(),
714 "unclosed fenced block must not be treated as a tool invocation"
715 );
716 }
717
718 #[tokio::test]
720 async fn multiple_fences_one_matching_tag_is_blocked() {
721 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
722 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
723 let res = exec.execute_erased(response).await;
724 assert!(
725 res.is_err(),
726 "response containing a matching fenced block must be blocked"
727 );
728 }
729
730 #[tokio::test]
733 async fn disallowed_blocks_tool_from_allow_list() {
734 let exec = FilteredToolExecutor::with_disallowed(
735 stub_box(&["shell", "web"]),
736 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
737 vec!["shell".into()],
738 );
739 let call = ToolCall {
740 tool_id: "shell".into(),
741 params: serde_json::Map::default(),
742 };
743 let res = exec.execute_tool_call_erased(&call).await;
744 assert!(
745 res.is_err(),
746 "disallowed tool must be blocked even if in allow list"
747 );
748 }
749
750 #[tokio::test]
751 async fn disallowed_allows_non_disallowed_tool() {
752 let exec = FilteredToolExecutor::with_disallowed(
753 stub_box(&["shell", "web"]),
754 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
755 vec!["shell".into()],
756 );
757 let call = ToolCall {
758 tool_id: "web".into(),
759 params: serde_json::Map::default(),
760 };
761 let res = exec.execute_tool_call_erased(&call).await;
762 assert!(res.is_ok(), "non-disallowed tool must be allowed");
763 }
764
765 #[test]
766 fn disallowed_empty_list_no_change() {
767 let exec = FilteredToolExecutor::with_disallowed(
768 stub_box(&["shell", "web"]),
769 ToolPolicy::InheritAll,
770 vec![],
771 );
772 let defs = exec.tool_definitions_erased();
773 assert_eq!(defs.len(), 2);
774 }
775
776 #[test]
777 fn tool_definitions_filters_disallowed_tools() {
778 let exec = FilteredToolExecutor::with_disallowed(
779 stub_box(&["shell", "web", "dangerous"]),
780 ToolPolicy::InheritAll,
781 vec!["dangerous".into()],
782 );
783 let defs = exec.tool_definitions_erased();
784 assert_eq!(defs.len(), 2);
785 assert!(!defs.iter().any(|d| d.id == "dangerous"));
786 }
787
788 #[test]
791 fn plan_mode_with_disallowed_excludes_from_catalog() {
792 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
795 let exec = FilteredToolExecutor::with_disallowed(
796 inner,
797 ToolPolicy::InheritAll,
798 vec!["shell".into()],
799 );
800 let defs = exec.tool_definitions_erased();
801 assert!(
802 !defs.iter().any(|d| d.id == "shell"),
803 "shell must be excluded from catalog"
804 );
805 assert!(
806 defs.iter().any(|d| d.id == "web"),
807 "web must remain in catalog"
808 );
809 }
810
811 #[tokio::test]
814 async fn plan_mode_blocks_execute_erased() {
815 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
816 let res = exec.execute_erased("response").await;
817 assert!(res.is_err());
818 }
819
820 #[tokio::test]
821 async fn plan_mode_blocks_execute_confirmed_erased() {
822 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
823 let res = exec.execute_confirmed_erased("response").await;
824 assert!(res.is_err());
825 }
826
827 #[tokio::test]
828 async fn plan_mode_blocks_tool_call() {
829 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
830 let call = ToolCall {
831 tool_id: "shell".into(),
832 params: serde_json::Map::default(),
833 };
834 let res = exec.execute_tool_call_erased(&call).await;
835 assert!(res.is_err(), "plan mode must block all tool execution");
836 }
837
838 #[test]
839 fn plan_mode_exposes_real_tool_definitions() {
840 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
841 let defs = exec.tool_definitions_erased();
842 assert_eq!(defs.len(), 2);
844 assert!(defs.iter().any(|d| d.id == "shell"));
845 assert!(defs.iter().any(|d| d.id == "web"));
846 }
847
848 #[test]
851 fn filter_skills_empty_registry_returns_empty() {
852 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
853 let filter = SkillFilter::default();
854 let result = filter_skills(®istry, &filter).unwrap();
855 assert!(result.is_empty());
856 }
857
858 #[test]
859 fn filter_skills_empty_include_passes_all() {
860 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
863 let filter = SkillFilter {
864 include: vec![],
865 exclude: vec![],
866 };
867 let result = filter_skills(®istry, &filter).unwrap();
868 assert!(result.is_empty());
869 }
870
871 #[test]
872 fn filter_skills_double_star_pattern_is_error() {
873 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
874 let filter = SkillFilter {
875 include: vec!["**".into()],
876 exclude: vec![],
877 };
878 let err = filter_skills(®istry, &filter).unwrap_err();
879 assert!(matches!(err, SubAgentError::Invalid(_)));
880 }
881
882 mod proptest_glob {
883 use proptest::prelude::*;
884
885 use super::{compile_glob, glob_match};
886
887 proptest! {
888 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
889
890 #[test]
892 fn glob_match_never_panics(
893 pattern in "[a-z*-]{1,10}",
894 name in "[a-z-]{0,15}",
895 ) {
896 if !pattern.contains("**")
898 && let Ok(p) = compile_glob(&pattern)
899 {
900 let _ = glob_match(&p, &name);
901 }
902 }
903
904 #[test]
906 fn glob_literal_matches_only_exact(
907 name in "[a-z-]{1,10}",
908 ) {
909 let p = compile_glob(&name).unwrap();
911 prop_assert!(glob_match(&p, &name));
912
913 let other = format!("{name}-x");
915 prop_assert!(!glob_match(&p, &other));
916 }
917
918 #[test]
920 fn glob_star_matches_everything(name in ".*") {
921 let p = compile_glob("*").unwrap();
922 prop_assert!(glob_match(&p, &name));
923 }
924 }
925 }
926}