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 claim_source: None,
409 }));
410 Box::pin(std::future::ready(result))
411 }
412
413 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
414 false
415 }
416 }
417
418 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
419 Arc::new(StubFencedExecutor { tag })
420 }
421
422 impl ErasedToolExecutor for StubExecutor {
423 fn execute_erased<'a>(
424 &'a self,
425 _response: &'a str,
426 ) -> Pin<
427 Box<
428 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
429 >,
430 > {
431 Box::pin(std::future::ready(Ok(None)))
432 }
433
434 fn execute_confirmed_erased<'a>(
435 &'a self,
436 _response: &'a str,
437 ) -> Pin<
438 Box<
439 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
440 >,
441 > {
442 Box::pin(std::future::ready(Ok(None)))
443 }
444
445 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
446 use zeph_tools::registry::InvocationHint;
448 self.tools
449 .iter()
450 .map(|id| ToolDef {
451 id: (*id).into(),
452 description: "stub".into(),
453 schema: schemars::Schema::default(),
454 invocation: InvocationHint::ToolCall,
455 })
456 .collect()
457 }
458
459 fn execute_tool_call_erased<'a>(
460 &'a self,
461 call: &'a ToolCall,
462 ) -> Pin<
463 Box<
464 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
465 >,
466 > {
467 let result = Ok(Some(ToolOutput {
468 tool_name: call.tool_id.clone(),
469 summary: "ok".into(),
470 blocks_executed: 1,
471 filter_stats: None,
472 diff: None,
473 streamed: false,
474 terminal_id: None,
475 locations: None,
476 raw_response: None,
477 claim_source: None,
478 }));
479 Box::pin(std::future::ready(result))
480 }
481
482 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
483 false
484 }
485 }
486
487 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
488 Arc::new(StubExecutor {
489 tools: tools.to_vec(),
490 })
491 }
492
493 #[tokio::test]
494 async fn allow_list_permits_listed_tool() {
495 let exec = FilteredToolExecutor::new(
496 stub_box(&["shell", "web"]),
497 ToolPolicy::AllowList(vec!["shell".into()]),
498 );
499 let call = ToolCall {
500 tool_id: "shell".into(),
501 params: serde_json::Map::default(),
502 caller_id: None,
503 };
504 let res = exec.execute_tool_call_erased(&call).await.unwrap();
505 assert!(res.is_some());
506 }
507
508 #[tokio::test]
509 async fn allow_list_blocks_unlisted_tool() {
510 let exec = FilteredToolExecutor::new(
511 stub_box(&["shell", "web"]),
512 ToolPolicy::AllowList(vec!["shell".into()]),
513 );
514 let call = ToolCall {
515 tool_id: "web".into(),
516 params: serde_json::Map::default(),
517 caller_id: None,
518 };
519 let res = exec.execute_tool_call_erased(&call).await;
520 assert!(res.is_err());
521 }
522
523 #[tokio::test]
524 async fn deny_list_blocks_listed_tool() {
525 let exec = FilteredToolExecutor::new(
526 stub_box(&["shell", "web"]),
527 ToolPolicy::DenyList(vec!["shell".into()]),
528 );
529 let call = ToolCall {
530 tool_id: "shell".into(),
531 params: serde_json::Map::default(),
532 caller_id: None,
533 };
534 let res = exec.execute_tool_call_erased(&call).await;
535 assert!(res.is_err());
536 }
537
538 #[tokio::test]
539 async fn inherit_all_permits_any_tool() {
540 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
541 let call = ToolCall {
542 tool_id: "shell".into(),
543 params: serde_json::Map::default(),
544 caller_id: None,
545 };
546 let res = exec.execute_tool_call_erased(&call).await.unwrap();
547 assert!(res.is_some());
548 }
549
550 #[test]
551 fn tool_definitions_filtered_by_allow_list() {
552 let exec = FilteredToolExecutor::new(
553 stub_box(&["shell", "web"]),
554 ToolPolicy::AllowList(vec!["shell".into()]),
555 );
556 let defs = exec.tool_definitions_erased();
557 assert_eq!(defs.len(), 1);
558 assert_eq!(defs[0].id, "shell");
559 }
560
561 fn matches(pattern: &str, name: &str) -> bool {
564 let p = compile_glob(pattern).unwrap();
565 glob_match(&p, name)
566 }
567
568 #[test]
569 fn glob_star_matches_all() {
570 assert!(matches("*", "anything"));
571 assert!(matches("*", ""));
572 }
573
574 #[test]
575 fn glob_prefix_star() {
576 assert!(matches("git-*", "git-commit"));
577 assert!(matches("git-*", "git-status"));
578 assert!(!matches("git-*", "rust-fmt"));
579 }
580
581 #[test]
582 fn glob_literal_exact_match() {
583 assert!(matches("shell", "shell"));
584 assert!(!matches("shell", "shell-extra"));
585 }
586
587 #[test]
588 fn glob_star_suffix() {
589 assert!(matches("*-review", "code-review"));
590 assert!(!matches("*-review", "code-reviewer"));
591 }
592
593 #[test]
594 fn glob_double_star_is_error() {
595 assert!(compile_glob("**").is_err());
596 }
597
598 #[test]
599 fn glob_mid_string_wildcard() {
600 assert!(matches("a*b", "axb"));
602 assert!(matches("a*b", "aXYZb"));
603 assert!(!matches("a*b", "ab-extra"));
604 assert!(!matches("a*b", "xab"));
605 }
606
607 #[tokio::test]
610 async fn deny_list_permits_unlisted_tool() {
611 let exec = FilteredToolExecutor::new(
612 stub_box(&["shell", "web"]),
613 ToolPolicy::DenyList(vec!["shell".into()]),
614 );
615 let call = ToolCall {
616 tool_id: "web".into(), params: serde_json::Map::default(),
618 caller_id: None,
619 };
620 let res = exec.execute_tool_call_erased(&call).await.unwrap();
621 assert!(res.is_some());
622 }
623
624 #[test]
625 fn tool_definitions_filtered_by_deny_list() {
626 let exec = FilteredToolExecutor::new(
627 stub_box(&["shell", "web"]),
628 ToolPolicy::DenyList(vec!["shell".into()]),
629 );
630 let defs = exec.tool_definitions_erased();
631 assert_eq!(defs.len(), 1);
632 assert_eq!(defs[0].id, "web");
633 }
634
635 #[test]
636 fn tool_definitions_inherit_all_returns_all() {
637 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
638 let defs = exec.tool_definitions_erased();
639 assert_eq!(defs.len(), 2);
640 }
641
642 #[tokio::test]
645 async fn fenced_block_matching_tag_is_blocked() {
646 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
648 let res = exec.execute_erased("```bash\nls\n```").await;
649 assert!(
650 res.is_err(),
651 "actual fenced-block invocation must be blocked"
652 );
653 }
654
655 #[tokio::test]
656 async fn fenced_block_matching_tag_confirmed_is_blocked() {
657 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
658 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
659 assert!(
660 res.is_err(),
661 "actual fenced-block invocation (confirmed) must be blocked"
662 );
663 }
664
665 #[tokio::test]
666 async fn no_fenced_tools_plain_text_returns_ok_none() {
667 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
669 let res = exec.execute_erased("This is a plain text response.").await;
670 assert!(
671 res.unwrap().is_none(),
672 "plain text must not be treated as a tool call"
673 );
674 }
675
676 #[tokio::test]
677 async fn markdown_non_tool_fence_returns_ok_none() {
678 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
680 let res = exec
681 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
682 .await;
683 assert!(
684 res.unwrap().is_none(),
685 "non-tool code fence must not trigger blocking"
686 );
687 }
688
689 #[tokio::test]
690 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
691 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
692 let res = exec
693 .execute_confirmed_erased("Plain response without any fences.")
694 .await;
695 assert!(res.unwrap().is_none());
696 }
697
698 #[tokio::test]
702 async fn fenced_executor_plain_text_returns_ok_none() {
703 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
704 let res = exec
705 .execute_erased("Here is my analysis of the code. No shell commands needed.")
706 .await;
707 assert!(
708 res.unwrap().is_none(),
709 "plain text with fenced executor must not be treated as a tool call"
710 );
711 }
712
713 #[tokio::test]
716 async fn unclosed_fenced_block_returns_ok_none() {
717 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
718 let res = exec.execute_erased("```bash\nls -la\n").await;
719 assert!(
720 res.unwrap().is_none(),
721 "unclosed fenced block must not be treated as a tool invocation"
722 );
723 }
724
725 #[tokio::test]
727 async fn multiple_fences_one_matching_tag_is_blocked() {
728 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
729 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
730 let res = exec.execute_erased(response).await;
731 assert!(
732 res.is_err(),
733 "response containing a matching fenced block must be blocked"
734 );
735 }
736
737 #[tokio::test]
740 async fn disallowed_blocks_tool_from_allow_list() {
741 let exec = FilteredToolExecutor::with_disallowed(
742 stub_box(&["shell", "web"]),
743 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
744 vec!["shell".into()],
745 );
746 let call = ToolCall {
747 tool_id: "shell".into(),
748 params: serde_json::Map::default(),
749 caller_id: None,
750 };
751 let res = exec.execute_tool_call_erased(&call).await;
752 assert!(
753 res.is_err(),
754 "disallowed tool must be blocked even if in allow list"
755 );
756 }
757
758 #[tokio::test]
759 async fn disallowed_allows_non_disallowed_tool() {
760 let exec = FilteredToolExecutor::with_disallowed(
761 stub_box(&["shell", "web"]),
762 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
763 vec!["shell".into()],
764 );
765 let call = ToolCall {
766 tool_id: "web".into(),
767 params: serde_json::Map::default(),
768 caller_id: None,
769 };
770 let res = exec.execute_tool_call_erased(&call).await;
771 assert!(res.is_ok(), "non-disallowed tool must be allowed");
772 }
773
774 #[test]
775 fn disallowed_empty_list_no_change() {
776 let exec = FilteredToolExecutor::with_disallowed(
777 stub_box(&["shell", "web"]),
778 ToolPolicy::InheritAll,
779 vec![],
780 );
781 let defs = exec.tool_definitions_erased();
782 assert_eq!(defs.len(), 2);
783 }
784
785 #[test]
786 fn tool_definitions_filters_disallowed_tools() {
787 let exec = FilteredToolExecutor::with_disallowed(
788 stub_box(&["shell", "web", "dangerous"]),
789 ToolPolicy::InheritAll,
790 vec!["dangerous".into()],
791 );
792 let defs = exec.tool_definitions_erased();
793 assert_eq!(defs.len(), 2);
794 assert!(!defs.iter().any(|d| d.id == "dangerous"));
795 }
796
797 #[test]
800 fn plan_mode_with_disallowed_excludes_from_catalog() {
801 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
804 let exec = FilteredToolExecutor::with_disallowed(
805 inner,
806 ToolPolicy::InheritAll,
807 vec!["shell".into()],
808 );
809 let defs = exec.tool_definitions_erased();
810 assert!(
811 !defs.iter().any(|d| d.id == "shell"),
812 "shell must be excluded from catalog"
813 );
814 assert!(
815 defs.iter().any(|d| d.id == "web"),
816 "web must remain in catalog"
817 );
818 }
819
820 #[tokio::test]
823 async fn plan_mode_blocks_execute_erased() {
824 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
825 let res = exec.execute_erased("response").await;
826 assert!(res.is_err());
827 }
828
829 #[tokio::test]
830 async fn plan_mode_blocks_execute_confirmed_erased() {
831 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
832 let res = exec.execute_confirmed_erased("response").await;
833 assert!(res.is_err());
834 }
835
836 #[tokio::test]
837 async fn plan_mode_blocks_tool_call() {
838 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
839 let call = ToolCall {
840 tool_id: "shell".into(),
841 params: serde_json::Map::default(),
842 caller_id: None,
843 };
844 let res = exec.execute_tool_call_erased(&call).await;
845 assert!(res.is_err(), "plan mode must block all tool execution");
846 }
847
848 #[test]
849 fn plan_mode_exposes_real_tool_definitions() {
850 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
851 let defs = exec.tool_definitions_erased();
852 assert_eq!(defs.len(), 2);
854 assert!(defs.iter().any(|d| d.id == "shell"));
855 assert!(defs.iter().any(|d| d.id == "web"));
856 }
857
858 #[test]
861 fn filter_skills_empty_registry_returns_empty() {
862 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
863 let filter = SkillFilter::default();
864 let result = filter_skills(®istry, &filter).unwrap();
865 assert!(result.is_empty());
866 }
867
868 #[test]
869 fn filter_skills_empty_include_passes_all() {
870 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
873 let filter = SkillFilter {
874 include: vec![],
875 exclude: vec![],
876 };
877 let result = filter_skills(®istry, &filter).unwrap();
878 assert!(result.is_empty());
879 }
880
881 #[test]
882 fn filter_skills_double_star_pattern_is_error() {
883 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
884 let filter = SkillFilter {
885 include: vec!["**".into()],
886 exclude: vec![],
887 };
888 let err = filter_skills(®istry, &filter).unwrap_err();
889 assert!(matches!(err, SubAgentError::Invalid(_)));
890 }
891
892 mod proptest_glob {
893 use proptest::prelude::*;
894
895 use super::{compile_glob, glob_match};
896
897 proptest! {
898 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
899
900 #[test]
902 fn glob_match_never_panics(
903 pattern in "[a-z*-]{1,10}",
904 name in "[a-z-]{0,15}",
905 ) {
906 if !pattern.contains("**")
908 && let Ok(p) = compile_glob(&pattern)
909 {
910 let _ = glob_match(&p, &name);
911 }
912 }
913
914 #[test]
916 fn glob_literal_matches_only_exact(
917 name in "[a-z-]{1,10}",
918 ) {
919 let p = compile_glob(&name).unwrap();
921 prop_assert!(glob_match(&p, &name));
922
923 let other = format!("{name}-x");
925 prop_assert!(!glob_match(&p, &other));
926 }
927
928 #[test]
930 fn glob_star_matches_everything(name in ".*") {
931 let p = compile_glob("*").unwrap();
932 prop_assert!(glob_match(&p, &name));
933 }
934 }
935 }
936}