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
194pub struct PlanModeExecutor {
203 inner: Arc<dyn ErasedToolExecutor>,
204}
205
206impl PlanModeExecutor {
207 #[must_use]
209 pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
210 Self { inner }
211 }
212}
213
214impl ErasedToolExecutor for PlanModeExecutor {
215 fn execute_erased<'a>(
216 &'a self,
217 _response: &'a str,
218 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
219 {
220 Box::pin(std::future::ready(Err(ToolError::Blocked {
221 command: "plan_mode".into(),
222 })))
223 }
224
225 fn execute_confirmed_erased<'a>(
226 &'a self,
227 _response: &'a str,
228 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
229 {
230 Box::pin(std::future::ready(Err(ToolError::Blocked {
231 command: "plan_mode".into(),
232 })))
233 }
234
235 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
236 self.inner.tool_definitions_erased()
237 }
238
239 fn execute_tool_call_erased<'a>(
240 &'a self,
241 call: &'a ToolCall,
242 ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
243 {
244 tracing::debug!(
245 tool_id = %call.tool_id,
246 "tool execution blocked in plan mode"
247 );
248 Box::pin(std::future::ready(Err(ToolError::Blocked {
249 command: call.tool_id.to_string(),
250 })))
251 }
252
253 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
254 self.inner.set_skill_env(env);
255 }
256
257 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
258 false
259 }
260}
261
262pub fn filter_skills(
291 registry: &SkillRegistry,
292 filter: &SkillFilter,
293) -> Result<Vec<Skill>, SubAgentError> {
294 let compiled_include = compile_globs(&filter.include)?;
295 let compiled_exclude = compile_globs(&filter.exclude)?;
296
297 let all: Vec<Skill> = registry
298 .all_meta()
299 .into_iter()
300 .filter(|meta| {
301 let name = &meta.name;
302 let included =
303 compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
304 let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
305 included && !excluded
306 })
307 .filter_map(|meta| registry.get_skill(&meta.name).ok())
308 .collect();
309
310 Ok(all)
311}
312
313struct GlobPattern {
315 raw: String,
316 prefix: String,
317 suffix: Option<String>,
318 is_star: bool,
319}
320
321fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
322 patterns.iter().map(|p| compile_glob(p)).collect()
323}
324
325fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
326 if pattern.contains("**") {
329 return Err(SubAgentError::Invalid(format!(
330 "glob pattern '{pattern}' uses '**' which is not supported"
331 )));
332 }
333
334 let is_star = pattern == "*";
335
336 let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
337 let before = pattern[..pos].to_owned();
338 let after = pattern[pos + 1..].to_owned();
339 (before, Some(after))
340 } else {
341 (pattern.to_owned(), None)
342 };
343
344 Ok(GlobPattern {
345 raw: pattern.to_owned(),
346 prefix,
347 suffix,
348 is_star,
349 })
350}
351
352fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
353 if pattern.is_star {
354 return true;
355 }
356
357 match &pattern.suffix {
358 None => name == pattern.raw,
359 Some(suf) => {
360 name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
361 name.len() >= pattern.prefix.len() + suf.len()
363 }
364 }
365 }
366}
367
368#[cfg(test)]
371mod tests {
372 #![allow(clippy::default_trait_access)]
373
374 use super::*;
375 use crate::def::ToolPolicy;
376
377 struct StubExecutor {
380 tools: Vec<&'static str>,
381 }
382
383 struct StubFencedExecutor {
385 tag: &'static str,
386 }
387
388 impl ErasedToolExecutor for StubFencedExecutor {
389 fn execute_erased<'a>(
390 &'a self,
391 _response: &'a str,
392 ) -> Pin<
393 Box<
394 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
395 >,
396 > {
397 Box::pin(std::future::ready(Ok(None)))
398 }
399
400 fn execute_confirmed_erased<'a>(
401 &'a self,
402 _response: &'a str,
403 ) -> Pin<
404 Box<
405 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
406 >,
407 > {
408 Box::pin(std::future::ready(Ok(None)))
409 }
410
411 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
412 use zeph_tools::registry::InvocationHint;
413 vec![ToolDef {
414 id: self.tag.into(),
415 description: "fenced stub".into(),
416 schema: schemars::Schema::default(),
417 invocation: InvocationHint::FencedBlock(self.tag),
418 output_schema: None,
419 }]
420 }
421
422 fn execute_tool_call_erased<'a>(
423 &'a self,
424 call: &'a ToolCall,
425 ) -> Pin<
426 Box<
427 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
428 >,
429 > {
430 let result = Ok(Some(ToolOutput {
431 tool_name: call.tool_id.clone(),
432 summary: "ok".into(),
433 blocks_executed: 1,
434 filter_stats: None,
435 diff: None,
436 streamed: false,
437 terminal_id: None,
438 locations: None,
439 raw_response: None,
440 claim_source: None,
441 }));
442 Box::pin(std::future::ready(result))
443 }
444
445 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
446 false
447 }
448 }
449
450 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
451 Arc::new(StubFencedExecutor { tag })
452 }
453
454 impl ErasedToolExecutor for StubExecutor {
455 fn execute_erased<'a>(
456 &'a self,
457 _response: &'a str,
458 ) -> Pin<
459 Box<
460 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
461 >,
462 > {
463 Box::pin(std::future::ready(Ok(None)))
464 }
465
466 fn execute_confirmed_erased<'a>(
467 &'a self,
468 _response: &'a str,
469 ) -> Pin<
470 Box<
471 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
472 >,
473 > {
474 Box::pin(std::future::ready(Ok(None)))
475 }
476
477 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
478 use zeph_tools::registry::InvocationHint;
480 self.tools
481 .iter()
482 .map(|id| ToolDef {
483 id: (*id).into(),
484 description: "stub".into(),
485 schema: schemars::Schema::default(),
486 invocation: InvocationHint::ToolCall,
487 output_schema: None,
488 })
489 .collect()
490 }
491
492 fn execute_tool_call_erased<'a>(
493 &'a self,
494 call: &'a ToolCall,
495 ) -> Pin<
496 Box<
497 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
498 >,
499 > {
500 let result = Ok(Some(ToolOutput {
501 tool_name: call.tool_id.clone(),
502 summary: "ok".into(),
503 blocks_executed: 1,
504 filter_stats: None,
505 diff: None,
506 streamed: false,
507 terminal_id: None,
508 locations: None,
509 raw_response: None,
510 claim_source: None,
511 }));
512 Box::pin(std::future::ready(result))
513 }
514
515 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
516 false
517 }
518 }
519
520 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
521 Arc::new(StubExecutor {
522 tools: tools.to_vec(),
523 })
524 }
525
526 #[tokio::test]
527 async fn allow_list_permits_listed_tool() {
528 let exec = FilteredToolExecutor::new(
529 stub_box(&["shell", "web"]),
530 ToolPolicy::AllowList(vec!["shell".into()]),
531 );
532 let call = ToolCall {
533 tool_id: "shell".into(),
534 params: serde_json::Map::default(),
535 caller_id: None,
536 };
537 let res = exec.execute_tool_call_erased(&call).await.unwrap();
538 assert!(res.is_some());
539 }
540
541 #[tokio::test]
542 async fn allow_list_blocks_unlisted_tool() {
543 let exec = FilteredToolExecutor::new(
544 stub_box(&["shell", "web"]),
545 ToolPolicy::AllowList(vec!["shell".into()]),
546 );
547 let call = ToolCall {
548 tool_id: "web".into(),
549 params: serde_json::Map::default(),
550 caller_id: None,
551 };
552 let res = exec.execute_tool_call_erased(&call).await;
553 assert!(res.is_err());
554 }
555
556 #[tokio::test]
557 async fn deny_list_blocks_listed_tool() {
558 let exec = FilteredToolExecutor::new(
559 stub_box(&["shell", "web"]),
560 ToolPolicy::DenyList(vec!["shell".into()]),
561 );
562 let call = ToolCall {
563 tool_id: "shell".into(),
564 params: serde_json::Map::default(),
565 caller_id: None,
566 };
567 let res = exec.execute_tool_call_erased(&call).await;
568 assert!(res.is_err());
569 }
570
571 #[tokio::test]
572 async fn inherit_all_permits_any_tool() {
573 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
574 let call = ToolCall {
575 tool_id: "shell".into(),
576 params: serde_json::Map::default(),
577 caller_id: None,
578 };
579 let res = exec.execute_tool_call_erased(&call).await.unwrap();
580 assert!(res.is_some());
581 }
582
583 #[test]
584 fn tool_definitions_filtered_by_allow_list() {
585 let exec = FilteredToolExecutor::new(
586 stub_box(&["shell", "web"]),
587 ToolPolicy::AllowList(vec!["shell".into()]),
588 );
589 let defs = exec.tool_definitions_erased();
590 assert_eq!(defs.len(), 1);
591 assert_eq!(defs[0].id, "shell");
592 }
593
594 fn matches(pattern: &str, name: &str) -> bool {
597 let p = compile_glob(pattern).unwrap();
598 glob_match(&p, name)
599 }
600
601 #[test]
602 fn glob_star_matches_all() {
603 assert!(matches("*", "anything"));
604 assert!(matches("*", ""));
605 }
606
607 #[test]
608 fn glob_prefix_star() {
609 assert!(matches("git-*", "git-commit"));
610 assert!(matches("git-*", "git-status"));
611 assert!(!matches("git-*", "rust-fmt"));
612 }
613
614 #[test]
615 fn glob_literal_exact_match() {
616 assert!(matches("shell", "shell"));
617 assert!(!matches("shell", "shell-extra"));
618 }
619
620 #[test]
621 fn glob_star_suffix() {
622 assert!(matches("*-review", "code-review"));
623 assert!(!matches("*-review", "code-reviewer"));
624 }
625
626 #[test]
627 fn glob_double_star_is_error() {
628 assert!(compile_glob("**").is_err());
629 }
630
631 #[test]
632 fn glob_mid_string_wildcard() {
633 assert!(matches("a*b", "axb"));
635 assert!(matches("a*b", "aXYZb"));
636 assert!(!matches("a*b", "ab-extra"));
637 assert!(!matches("a*b", "xab"));
638 }
639
640 #[tokio::test]
643 async fn deny_list_permits_unlisted_tool() {
644 let exec = FilteredToolExecutor::new(
645 stub_box(&["shell", "web"]),
646 ToolPolicy::DenyList(vec!["shell".into()]),
647 );
648 let call = ToolCall {
649 tool_id: "web".into(), params: serde_json::Map::default(),
651 caller_id: None,
652 };
653 let res = exec.execute_tool_call_erased(&call).await.unwrap();
654 assert!(res.is_some());
655 }
656
657 #[test]
658 fn tool_definitions_filtered_by_deny_list() {
659 let exec = FilteredToolExecutor::new(
660 stub_box(&["shell", "web"]),
661 ToolPolicy::DenyList(vec!["shell".into()]),
662 );
663 let defs = exec.tool_definitions_erased();
664 assert_eq!(defs.len(), 1);
665 assert_eq!(defs[0].id, "web");
666 }
667
668 #[test]
669 fn tool_definitions_inherit_all_returns_all() {
670 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
671 let defs = exec.tool_definitions_erased();
672 assert_eq!(defs.len(), 2);
673 }
674
675 #[tokio::test]
678 async fn fenced_block_matching_tag_is_blocked() {
679 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
681 let res = exec.execute_erased("```bash\nls\n```").await;
682 assert!(
683 res.is_err(),
684 "actual fenced-block invocation must be blocked"
685 );
686 }
687
688 #[tokio::test]
689 async fn fenced_block_matching_tag_confirmed_is_blocked() {
690 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
691 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
692 assert!(
693 res.is_err(),
694 "actual fenced-block invocation (confirmed) must be blocked"
695 );
696 }
697
698 #[tokio::test]
699 async fn no_fenced_tools_plain_text_returns_ok_none() {
700 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
702 let res = exec.execute_erased("This is a plain text response.").await;
703 assert!(
704 res.unwrap().is_none(),
705 "plain text must not be treated as a tool call"
706 );
707 }
708
709 #[tokio::test]
710 async fn markdown_non_tool_fence_returns_ok_none() {
711 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
713 let res = exec
714 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
715 .await;
716 assert!(
717 res.unwrap().is_none(),
718 "non-tool code fence must not trigger blocking"
719 );
720 }
721
722 #[tokio::test]
723 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
724 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
725 let res = exec
726 .execute_confirmed_erased("Plain response without any fences.")
727 .await;
728 assert!(res.unwrap().is_none());
729 }
730
731 #[tokio::test]
735 async fn fenced_executor_plain_text_returns_ok_none() {
736 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
737 let res = exec
738 .execute_erased("Here is my analysis of the code. No shell commands needed.")
739 .await;
740 assert!(
741 res.unwrap().is_none(),
742 "plain text with fenced executor must not be treated as a tool call"
743 );
744 }
745
746 #[tokio::test]
749 async fn unclosed_fenced_block_returns_ok_none() {
750 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
751 let res = exec.execute_erased("```bash\nls -la\n").await;
752 assert!(
753 res.unwrap().is_none(),
754 "unclosed fenced block must not be treated as a tool invocation"
755 );
756 }
757
758 #[tokio::test]
760 async fn multiple_fences_one_matching_tag_is_blocked() {
761 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
762 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
763 let res = exec.execute_erased(response).await;
764 assert!(
765 res.is_err(),
766 "response containing a matching fenced block must be blocked"
767 );
768 }
769
770 #[tokio::test]
773 async fn disallowed_blocks_tool_from_allow_list() {
774 let exec = FilteredToolExecutor::with_disallowed(
775 stub_box(&["shell", "web"]),
776 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
777 vec!["shell".into()],
778 );
779 let call = ToolCall {
780 tool_id: "shell".into(),
781 params: serde_json::Map::default(),
782 caller_id: None,
783 };
784 let res = exec.execute_tool_call_erased(&call).await;
785 assert!(
786 res.is_err(),
787 "disallowed tool must be blocked even if in allow list"
788 );
789 }
790
791 #[tokio::test]
792 async fn disallowed_allows_non_disallowed_tool() {
793 let exec = FilteredToolExecutor::with_disallowed(
794 stub_box(&["shell", "web"]),
795 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
796 vec!["shell".into()],
797 );
798 let call = ToolCall {
799 tool_id: "web".into(),
800 params: serde_json::Map::default(),
801 caller_id: None,
802 };
803 let res = exec.execute_tool_call_erased(&call).await;
804 assert!(res.is_ok(), "non-disallowed tool must be allowed");
805 }
806
807 #[test]
808 fn disallowed_empty_list_no_change() {
809 let exec = FilteredToolExecutor::with_disallowed(
810 stub_box(&["shell", "web"]),
811 ToolPolicy::InheritAll,
812 vec![],
813 );
814 let defs = exec.tool_definitions_erased();
815 assert_eq!(defs.len(), 2);
816 }
817
818 #[test]
819 fn tool_definitions_filters_disallowed_tools() {
820 let exec = FilteredToolExecutor::with_disallowed(
821 stub_box(&["shell", "web", "dangerous"]),
822 ToolPolicy::InheritAll,
823 vec!["dangerous".into()],
824 );
825 let defs = exec.tool_definitions_erased();
826 assert_eq!(defs.len(), 2);
827 assert!(!defs.iter().any(|d| d.id == "dangerous"));
828 }
829
830 #[test]
833 fn plan_mode_with_disallowed_excludes_from_catalog() {
834 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
837 let exec = FilteredToolExecutor::with_disallowed(
838 inner,
839 ToolPolicy::InheritAll,
840 vec!["shell".into()],
841 );
842 let defs = exec.tool_definitions_erased();
843 assert!(
844 !defs.iter().any(|d| d.id == "shell"),
845 "shell must be excluded from catalog"
846 );
847 assert!(
848 defs.iter().any(|d| d.id == "web"),
849 "web must remain in catalog"
850 );
851 }
852
853 #[tokio::test]
856 async fn plan_mode_blocks_execute_erased() {
857 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
858 let res = exec.execute_erased("response").await;
859 assert!(res.is_err());
860 }
861
862 #[tokio::test]
863 async fn plan_mode_blocks_execute_confirmed_erased() {
864 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
865 let res = exec.execute_confirmed_erased("response").await;
866 assert!(res.is_err());
867 }
868
869 #[tokio::test]
870 async fn plan_mode_blocks_tool_call() {
871 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
872 let call = ToolCall {
873 tool_id: "shell".into(),
874 params: serde_json::Map::default(),
875 caller_id: None,
876 };
877 let res = exec.execute_tool_call_erased(&call).await;
878 assert!(res.is_err(), "plan mode must block all tool execution");
879 }
880
881 #[test]
882 fn plan_mode_exposes_real_tool_definitions() {
883 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
884 let defs = exec.tool_definitions_erased();
885 assert_eq!(defs.len(), 2);
887 assert!(defs.iter().any(|d| d.id == "shell"));
888 assert!(defs.iter().any(|d| d.id == "web"));
889 }
890
891 #[test]
894 fn filter_skills_empty_registry_returns_empty() {
895 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
896 let filter = SkillFilter::default();
897 let result = filter_skills(®istry, &filter).unwrap();
898 assert!(result.is_empty());
899 }
900
901 #[test]
902 fn filter_skills_empty_include_passes_all() {
903 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
906 let filter = SkillFilter {
907 include: vec![],
908 exclude: vec![],
909 };
910 let result = filter_skills(®istry, &filter).unwrap();
911 assert!(result.is_empty());
912 }
913
914 #[test]
915 fn filter_skills_double_star_pattern_is_error() {
916 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
917 let filter = SkillFilter {
918 include: vec!["**".into()],
919 exclude: vec![],
920 };
921 let err = filter_skills(®istry, &filter).unwrap_err();
922 assert!(matches!(err, SubAgentError::Invalid(_)));
923 }
924
925 mod proptest_glob {
926 use proptest::prelude::*;
927
928 use super::{compile_glob, glob_match};
929
930 proptest! {
931 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
932
933 #[test]
935 fn glob_match_never_panics(
936 pattern in "[a-z*-]{1,10}",
937 name in "[a-z-]{0,15}",
938 ) {
939 if !pattern.contains("**")
941 && let Ok(p) = compile_glob(&pattern)
942 {
943 let _ = glob_match(&p, &name);
944 }
945 }
946
947 #[test]
949 fn glob_literal_matches_only_exact(
950 name in "[a-z-]{1,10}",
951 ) {
952 let p = compile_glob(&name).unwrap();
954 prop_assert!(glob_match(&p, &name));
955
956 let other = format!("{name}-x");
958 prop_assert!(!glob_match(&p, &other));
959 }
960
961 #[test]
963 fn glob_star_matches_everything(name in ".*") {
964 let p = compile_glob("*").unwrap();
965 prop_assert!(glob_match(&p, &name));
966 }
967 }
968 }
969}