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