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