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 use std::assert_matches;
402
403 use super::*;
404 use crate::def::ToolPolicy;
405
406 struct StubExecutor {
409 tools: Vec<&'static str>,
410 }
411
412 struct StubFencedExecutor {
414 tag: &'static str,
415 }
416
417 impl ErasedToolExecutor for StubFencedExecutor {
418 fn execute_erased<'a>(
419 &'a self,
420 _response: &'a str,
421 ) -> Pin<
422 Box<
423 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
424 >,
425 > {
426 Box::pin(std::future::ready(Ok(None)))
427 }
428
429 fn execute_confirmed_erased<'a>(
430 &'a self,
431 _response: &'a str,
432 ) -> Pin<
433 Box<
434 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
435 >,
436 > {
437 Box::pin(std::future::ready(Ok(None)))
438 }
439
440 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
441 use zeph_tools::registry::InvocationHint;
442 vec![ToolDef {
443 id: self.tag.into(),
444 description: "fenced stub".into(),
445 schema: schemars::Schema::default(),
446 invocation: InvocationHint::FencedBlock(self.tag),
447 output_schema: None,
448 server_id: None,
449 }]
450 }
451
452 fn execute_tool_call_erased<'a>(
453 &'a self,
454 call: &'a ToolCall,
455 ) -> Pin<
456 Box<
457 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
458 >,
459 > {
460 let result = Ok(Some(ToolOutput {
461 tool_name: call.tool_id.clone(),
462 summary: "ok".into(),
463 blocks_executed: 1,
464 filter_stats: None,
465 diff: None,
466 streamed: false,
467 terminal_id: None,
468 locations: None,
469 raw_response: None,
470 claim_source: None,
471 }));
472 Box::pin(std::future::ready(result))
473 }
474
475 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
476 false
477 }
478
479 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
480 false
481 }
482 }
483
484 fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
485 Arc::new(StubFencedExecutor { tag })
486 }
487
488 impl ErasedToolExecutor for StubExecutor {
489 fn execute_erased<'a>(
490 &'a self,
491 _response: &'a str,
492 ) -> Pin<
493 Box<
494 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
495 >,
496 > {
497 Box::pin(std::future::ready(Ok(None)))
498 }
499
500 fn execute_confirmed_erased<'a>(
501 &'a self,
502 _response: &'a str,
503 ) -> Pin<
504 Box<
505 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
506 >,
507 > {
508 Box::pin(std::future::ready(Ok(None)))
509 }
510
511 fn tool_definitions_erased(&self) -> Vec<ToolDef> {
512 use zeph_tools::registry::InvocationHint;
514 self.tools
515 .iter()
516 .map(|id| ToolDef {
517 id: (*id).into(),
518 description: "stub".into(),
519 schema: schemars::Schema::default(),
520 invocation: InvocationHint::ToolCall,
521 output_schema: None,
522 server_id: None,
523 })
524 .collect()
525 }
526
527 fn execute_tool_call_erased<'a>(
528 &'a self,
529 call: &'a ToolCall,
530 ) -> Pin<
531 Box<
532 dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
533 >,
534 > {
535 let result = Ok(Some(ToolOutput {
536 tool_name: call.tool_id.clone(),
537 summary: "ok".into(),
538 blocks_executed: 1,
539 filter_stats: None,
540 diff: None,
541 streamed: false,
542 terminal_id: None,
543 locations: None,
544 raw_response: None,
545 claim_source: None,
546 }));
547 Box::pin(std::future::ready(result))
548 }
549
550 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
551 false
552 }
553
554 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
555 false
556 }
557 }
558
559 fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
560 Arc::new(StubExecutor {
561 tools: tools.to_vec(),
562 })
563 }
564
565 #[tokio::test]
566 async fn allow_list_permits_listed_tool() {
567 let exec = FilteredToolExecutor::new(
568 stub_box(&["shell", "web"]),
569 ToolPolicy::AllowList(vec!["shell".into()]),
570 );
571 let call = ToolCall {
572 tool_id: "shell".into(),
573 params: serde_json::Map::default(),
574 caller_id: None,
575 context: None,
576
577 tool_call_id: String::new(),
578 skill_name: None,
579 };
580 let res = exec.execute_tool_call_erased(&call).await.unwrap();
581 assert!(res.is_some());
582 }
583
584 #[tokio::test]
585 async fn allow_list_blocks_unlisted_tool() {
586 let exec = FilteredToolExecutor::new(
587 stub_box(&["shell", "web"]),
588 ToolPolicy::AllowList(vec!["shell".into()]),
589 );
590 let call = ToolCall {
591 tool_id: "web".into(),
592 params: serde_json::Map::default(),
593 caller_id: None,
594 context: None,
595
596 tool_call_id: String::new(),
597 skill_name: None,
598 };
599 let res = exec.execute_tool_call_erased(&call).await;
600 assert!(res.is_err());
601 }
602
603 #[tokio::test]
604 async fn deny_list_blocks_listed_tool() {
605 let exec = FilteredToolExecutor::new(
606 stub_box(&["shell", "web"]),
607 ToolPolicy::DenyList(vec!["shell".into()]),
608 );
609 let call = ToolCall {
610 tool_id: "shell".into(),
611 params: serde_json::Map::default(),
612 caller_id: None,
613 context: None,
614
615 tool_call_id: String::new(),
616 skill_name: None,
617 };
618 let res = exec.execute_tool_call_erased(&call).await;
619 assert!(res.is_err());
620 }
621
622 #[tokio::test]
623 async fn inherit_all_permits_any_tool() {
624 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
625 let call = ToolCall {
626 tool_id: "shell".into(),
627 params: serde_json::Map::default(),
628 caller_id: None,
629 context: None,
630
631 tool_call_id: String::new(),
632 skill_name: None,
633 };
634 let res = exec.execute_tool_call_erased(&call).await.unwrap();
635 assert!(res.is_some());
636 }
637
638 #[test]
639 fn tool_definitions_filtered_by_allow_list() {
640 let exec = FilteredToolExecutor::new(
641 stub_box(&["shell", "web"]),
642 ToolPolicy::AllowList(vec!["shell".into()]),
643 );
644 let defs = exec.tool_definitions_erased();
645 assert_eq!(defs.len(), 1);
646 assert_eq!(defs[0].id, "shell");
647 }
648
649 fn matches(pattern: &str, name: &str) -> bool {
652 let p = compile_glob(pattern).unwrap();
653 glob_match(&p, name)
654 }
655
656 #[test]
657 fn glob_star_matches_all() {
658 assert!(matches("*", "anything"));
659 assert!(matches("*", ""));
660 }
661
662 #[test]
663 fn glob_prefix_star() {
664 assert!(matches("git-*", "git-commit"));
665 assert!(matches("git-*", "git-status"));
666 assert!(!matches("git-*", "rust-fmt"));
667 }
668
669 #[test]
670 fn glob_literal_exact_match() {
671 assert!(matches("shell", "shell"));
672 assert!(!matches("shell", "shell-extra"));
673 }
674
675 #[test]
676 fn glob_star_suffix() {
677 assert!(matches("*-review", "code-review"));
678 assert!(!matches("*-review", "code-reviewer"));
679 }
680
681 #[test]
682 fn glob_double_star_is_error() {
683 assert!(compile_glob("**").is_err());
684 }
685
686 #[test]
687 fn glob_mid_string_wildcard() {
688 assert!(matches("a*b", "axb"));
690 assert!(matches("a*b", "aXYZb"));
691 assert!(!matches("a*b", "ab-extra"));
692 assert!(!matches("a*b", "xab"));
693 }
694
695 #[tokio::test]
698 async fn deny_list_permits_unlisted_tool() {
699 let exec = FilteredToolExecutor::new(
700 stub_box(&["shell", "web"]),
701 ToolPolicy::DenyList(vec!["shell".into()]),
702 );
703 let call = ToolCall {
704 tool_id: "web".into(), params: serde_json::Map::default(),
706 caller_id: None,
707 context: None,
708
709 tool_call_id: String::new(),
710 skill_name: None,
711 };
712 let res = exec.execute_tool_call_erased(&call).await.unwrap();
713 assert!(res.is_some());
714 }
715
716 #[test]
717 fn tool_definitions_filtered_by_deny_list() {
718 let exec = FilteredToolExecutor::new(
719 stub_box(&["shell", "web"]),
720 ToolPolicy::DenyList(vec!["shell".into()]),
721 );
722 let defs = exec.tool_definitions_erased();
723 assert_eq!(defs.len(), 1);
724 assert_eq!(defs[0].id, "web");
725 }
726
727 #[test]
728 fn tool_definitions_inherit_all_returns_all() {
729 let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
730 let defs = exec.tool_definitions_erased();
731 assert_eq!(defs.len(), 2);
732 }
733
734 #[tokio::test]
737 async fn fenced_block_matching_tag_is_blocked() {
738 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
740 let res = exec.execute_erased("```bash\nls\n```").await;
741 assert!(
742 res.is_err(),
743 "actual fenced-block invocation must be blocked"
744 );
745 }
746
747 #[tokio::test]
748 async fn fenced_block_matching_tag_confirmed_is_blocked() {
749 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
750 let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
751 assert!(
752 res.is_err(),
753 "actual fenced-block invocation (confirmed) must be blocked"
754 );
755 }
756
757 #[tokio::test]
758 async fn no_fenced_tools_plain_text_returns_ok_none() {
759 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
761 let res = exec.execute_erased("This is a plain text response.").await;
762 assert!(
763 res.unwrap().is_none(),
764 "plain text must not be treated as a tool call"
765 );
766 }
767
768 #[tokio::test]
769 async fn markdown_non_tool_fence_returns_ok_none() {
770 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
772 let res = exec
773 .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
774 .await;
775 assert!(
776 res.unwrap().is_none(),
777 "non-tool code fence must not trigger blocking"
778 );
779 }
780
781 #[tokio::test]
782 async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
783 let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
784 let res = exec
785 .execute_confirmed_erased("Plain response without any fences.")
786 .await;
787 assert!(res.unwrap().is_none());
788 }
789
790 #[tokio::test]
794 async fn fenced_executor_plain_text_returns_ok_none() {
795 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
796 let res = exec
797 .execute_erased("Here is my analysis of the code. No shell commands needed.")
798 .await;
799 assert!(
800 res.unwrap().is_none(),
801 "plain text with fenced executor must not be treated as a tool call"
802 );
803 }
804
805 #[tokio::test]
808 async fn unclosed_fenced_block_returns_ok_none() {
809 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
810 let res = exec.execute_erased("```bash\nls -la\n").await;
811 assert!(
812 res.unwrap().is_none(),
813 "unclosed fenced block must not be treated as a tool invocation"
814 );
815 }
816
817 #[tokio::test]
819 async fn multiple_fences_one_matching_tag_is_blocked() {
820 let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
821 let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
822 let res = exec.execute_erased(response).await;
823 assert!(
824 res.is_err(),
825 "response containing a matching fenced block must be blocked"
826 );
827 }
828
829 #[tokio::test]
832 async fn disallowed_blocks_tool_from_allow_list() {
833 let exec = FilteredToolExecutor::with_disallowed(
834 stub_box(&["shell", "web"]),
835 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
836 vec!["shell".into()],
837 );
838 let call = ToolCall {
839 tool_id: "shell".into(),
840 params: serde_json::Map::default(),
841 caller_id: None,
842 context: None,
843
844 tool_call_id: String::new(),
845 skill_name: None,
846 };
847 let res = exec.execute_tool_call_erased(&call).await;
848 assert!(
849 res.is_err(),
850 "disallowed tool must be blocked even if in allow list"
851 );
852 }
853
854 #[tokio::test]
855 async fn disallowed_allows_non_disallowed_tool() {
856 let exec = FilteredToolExecutor::with_disallowed(
857 stub_box(&["shell", "web"]),
858 ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
859 vec!["shell".into()],
860 );
861 let call = ToolCall {
862 tool_id: "web".into(),
863 params: serde_json::Map::default(),
864 caller_id: None,
865 context: None,
866
867 tool_call_id: String::new(),
868 skill_name: None,
869 };
870 let res = exec.execute_tool_call_erased(&call).await;
871 assert!(res.is_ok(), "non-disallowed tool must be allowed");
872 }
873
874 #[test]
875 fn disallowed_empty_list_no_change() {
876 let exec = FilteredToolExecutor::with_disallowed(
877 stub_box(&["shell", "web"]),
878 ToolPolicy::InheritAll,
879 vec![],
880 );
881 let defs = exec.tool_definitions_erased();
882 assert_eq!(defs.len(), 2);
883 }
884
885 #[test]
886 fn tool_definitions_filters_disallowed_tools() {
887 let exec = FilteredToolExecutor::with_disallowed(
888 stub_box(&["shell", "web", "dangerous"]),
889 ToolPolicy::InheritAll,
890 vec!["dangerous".into()],
891 );
892 let defs = exec.tool_definitions_erased();
893 assert_eq!(defs.len(), 2);
894 assert!(!defs.iter().any(|d| d.id == "dangerous"));
895 }
896
897 #[test]
900 fn plan_mode_with_disallowed_excludes_from_catalog() {
901 let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
904 let exec = FilteredToolExecutor::with_disallowed(
905 inner,
906 ToolPolicy::InheritAll,
907 vec!["shell".into()],
908 );
909 let defs = exec.tool_definitions_erased();
910 assert!(
911 !defs.iter().any(|d| d.id == "shell"),
912 "shell must be excluded from catalog"
913 );
914 assert!(
915 defs.iter().any(|d| d.id == "web"),
916 "web must remain in catalog"
917 );
918 }
919
920 #[tokio::test]
923 async fn plan_mode_blocks_execute_erased() {
924 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
925 let res = exec.execute_erased("response").await;
926 assert!(res.is_err());
927 }
928
929 #[tokio::test]
930 async fn plan_mode_blocks_execute_confirmed_erased() {
931 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
932 let res = exec.execute_confirmed_erased("response").await;
933 assert!(res.is_err());
934 }
935
936 #[tokio::test]
937 async fn plan_mode_blocks_tool_call() {
938 let exec = PlanModeExecutor::new(stub_box(&["shell"]));
939 let call = ToolCall {
940 tool_id: "shell".into(),
941 params: serde_json::Map::default(),
942 caller_id: None,
943 context: None,
944
945 tool_call_id: String::new(),
946 skill_name: None,
947 };
948 let res = exec.execute_tool_call_erased(&call).await;
949 assert!(res.is_err(), "plan mode must block all tool execution");
950 }
951
952 #[test]
953 fn plan_mode_exposes_real_tool_definitions() {
954 let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
955 let defs = exec.tool_definitions_erased();
956 assert_eq!(defs.len(), 2);
958 assert!(defs.iter().any(|d| d.id == "shell"));
959 assert!(defs.iter().any(|d| d.id == "web"));
960 }
961
962 #[test]
965 fn normalize_tool_id_lowercases() {
966 assert_eq!(normalize_tool_id("Read"), "read");
967 assert_eq!(normalize_tool_id("Write"), "write");
968 assert_eq!(normalize_tool_id("Edit"), "edit");
969 }
970
971 #[test]
972 fn normalize_tool_id_strips_args() {
973 assert_eq!(normalize_tool_id("Bash(cargo *)"), "bash");
974 assert_eq!(normalize_tool_id("Bash(git *)"), "bash");
975 assert_eq!(normalize_tool_id("bash"), "bash");
976 }
977
978 #[test]
979 fn allow_list_pascal_case_permits_lowercase_runtime_id() {
980 let exec = FilteredToolExecutor::new(
981 stub_box(&["read", "write", "bash"]),
982 ToolPolicy::AllowList(vec!["Read".into(), "Write".into(), "Bash(cargo *)".into()]),
983 );
984 assert!(exec.is_allowed("read"));
986 assert!(exec.is_allowed("write"));
987 assert!(exec.is_allowed("bash"));
988 assert!(!exec.is_allowed("web"));
989 let defs = exec.tool_definitions_erased();
991 assert_eq!(
992 defs.len(),
993 3,
994 "read, write, bash must all appear in catalog"
995 );
996 }
997
998 #[test]
1001 fn filter_skills_empty_registry_returns_empty() {
1002 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1003 let filter = SkillFilter::default();
1004 let result = filter_skills(®istry, &filter).unwrap();
1005 assert!(result.is_empty());
1006 }
1007
1008 #[test]
1009 fn filter_skills_empty_include_passes_all() {
1010 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1013 let filter = SkillFilter {
1014 include: vec![],
1015 exclude: vec![],
1016 };
1017 let result = filter_skills(®istry, &filter).unwrap();
1018 assert!(result.is_empty());
1019 }
1020
1021 #[test]
1022 fn filter_skills_double_star_pattern_is_error() {
1023 let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1024 let filter = SkillFilter {
1025 include: vec!["**".into()],
1026 exclude: vec![],
1027 };
1028 let err = filter_skills(®istry, &filter).unwrap_err();
1029 assert_matches!(err, SubAgentError::Invalid(_));
1030 }
1031
1032 mod proptest_glob {
1033 use proptest::prelude::*;
1034
1035 use super::{compile_glob, glob_match};
1036
1037 proptest! {
1038 #![proptest_config(proptest::test_runner::Config::with_cases(500))]
1039
1040 #[test]
1042 fn glob_match_never_panics(
1043 pattern in "[a-z*-]{1,10}",
1044 name in "[a-z-]{0,15}",
1045 ) {
1046 if !pattern.contains("**")
1048 && let Ok(p) = compile_glob(&pattern)
1049 {
1050 let _ = glob_match(&p, &name);
1051 }
1052 }
1053
1054 #[test]
1056 fn glob_literal_matches_only_exact(
1057 name in "[a-z-]{1,10}",
1058 ) {
1059 let p = compile_glob(&name).unwrap();
1061 prop_assert!(glob_match(&p, &name));
1062
1063 let other = format!("{name}-x");
1065 prop_assert!(!glob_match(&p, &other));
1066 }
1067
1068 #[test]
1070 fn glob_star_matches_everything(name in ".*") {
1071 let p = compile_glob("*").unwrap();
1072 prop_assert!(glob_match(&p, &name));
1073 }
1074 }
1075 }
1076}