Skip to main content

zeph_subagent/
filter.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use zeph_skills::loader::Skill;
9use zeph_skills::registry::SkillRegistry;
10use zeph_tools::ToolCall;
11use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput, extract_fenced_blocks};
12use zeph_tools::registry::{InvocationHint, ToolDef};
13
14use super::def::{SkillFilter, ToolPolicy};
15use super::error::SubAgentError;
16
17// ── Helpers ───────────────────────────────────────────────────────────────────
18
19/// Collect all fenced-block language tags from an executor's tool definitions.
20fn collect_fenced_tags(executor: &dyn ErasedToolExecutor) -> Vec<&'static str> {
21    executor
22        .tool_definitions_erased()
23        .into_iter()
24        .filter_map(|def| match def.invocation {
25            InvocationHint::FencedBlock(tag) => Some(tag),
26            InvocationHint::ToolCall => None,
27        })
28        .collect()
29}
30
31// ── Tool filtering ────────────────────────────────────────────────────────────
32
33/// Wraps an [`ErasedToolExecutor`] and enforces a [`ToolPolicy`] plus an optional
34/// additional denylist (`disallowed`).
35///
36/// All calls are checked against the policy and the denylist before being forwarded
37/// to the inner executor. The denylist is evaluated first — a tool in `disallowed`
38/// is blocked even if `policy` would allow it (deny wins). Rejected calls return a
39/// descriptive [`ToolError`].
40pub struct FilteredToolExecutor {
41    inner: Arc<dyn ErasedToolExecutor>,
42    policy: ToolPolicy,
43    disallowed: Vec<String>,
44    /// Fenced-block language tags collected from `inner` at construction time.
45    /// Used to detect actual fenced-block tool invocations in LLM responses.
46    fenced_tags: Vec<&'static str>,
47}
48
49impl FilteredToolExecutor {
50    /// Create a new filtered executor.
51    #[must_use]
52    pub fn new(inner: Arc<dyn ErasedToolExecutor>, policy: ToolPolicy) -> Self {
53        let fenced_tags = collect_fenced_tags(&*inner);
54        Self {
55            inner,
56            policy,
57            disallowed: Vec::new(),
58            fenced_tags,
59        }
60    }
61
62    /// Create a new filtered executor with an additional denylist.
63    ///
64    /// Tools in `disallowed` are blocked regardless of the base `policy`
65    /// (deny wins over allow).
66    #[must_use]
67    pub fn with_disallowed(
68        inner: Arc<dyn ErasedToolExecutor>,
69        policy: ToolPolicy,
70        disallowed: Vec<String>,
71    ) -> Self {
72        let fenced_tags = collect_fenced_tags(&*inner);
73        Self {
74            inner,
75            policy,
76            disallowed,
77            fenced_tags,
78        }
79    }
80
81    /// Return `true` if `response` contains at least one fenced block matching a registered tool.
82    fn has_fenced_tool_invocation(&self, response: &str) -> bool {
83        self.fenced_tags
84            .iter()
85            .any(|tag| !extract_fenced_blocks(response, tag).is_empty())
86    }
87
88    /// Check whether `tool_id` is allowed under the current policy and denylist.
89    ///
90    /// Matching is exact string equality. MCP compound tool IDs (e.g. `mcp__server__tool`)
91    /// must be listed in full in `tools.except` — partial names or prefixes are not matched.
92    fn is_allowed(&self, tool_id: &str) -> bool {
93        if self.disallowed.iter().any(|t| t == tool_id) {
94            return false;
95        }
96        match &self.policy {
97            ToolPolicy::InheritAll => true,
98            ToolPolicy::AllowList(list) => list.iter().any(|t| t == tool_id),
99            ToolPolicy::DenyList(list) => !list.iter().any(|t| t == tool_id),
100        }
101    }
102}
103
104impl ErasedToolExecutor for FilteredToolExecutor {
105    fn execute_erased<'a>(
106        &'a self,
107        response: &'a str,
108    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
109    {
110        // Sub-agents must use structured tool calls (execute_tool_call_erased).
111        // Fenced-block execution is disabled to prevent policy bypass (SEC-03).
112        //
113        // However, this method is also called for plain-text LLM responses that
114        // contain markdown code fences unrelated to tool invocations. Returning
115        // Err unconditionally causes the agent loop to treat every text response
116        // as a failed tool call and exhaust all turns without producing output.
117        //
118        // Only block when the response actually contains a fenced block that
119        // matches a registered fenced-block tool language tag.
120        if self.has_fenced_tool_invocation(response) {
121            tracing::warn!("sub-agent attempted fenced-block tool invocation — blocked by policy");
122            return Box::pin(std::future::ready(Err(ToolError::Blocked {
123                command: "fenced-block".into(),
124            })));
125        }
126        Box::pin(std::future::ready(Ok(None)))
127    }
128
129    fn execute_confirmed_erased<'a>(
130        &'a self,
131        response: &'a str,
132    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
133    {
134        // Same policy as execute_erased: only block actual fenced-block invocations.
135        if self.has_fenced_tool_invocation(response) {
136            tracing::warn!(
137                "sub-agent attempted confirmed fenced-block tool invocation — blocked by policy"
138            );
139            return Box::pin(std::future::ready(Err(ToolError::Blocked {
140                command: "fenced-block".into(),
141            })));
142        }
143        Box::pin(std::future::ready(Ok(None)))
144    }
145
146    fn tool_definitions_erased(&self) -> Vec<ToolDef> {
147        // Filter the visible tool definitions according to the policy.
148        self.inner
149            .tool_definitions_erased()
150            .into_iter()
151            .filter(|def| self.is_allowed(&def.id))
152            .collect()
153    }
154
155    fn execute_tool_call_erased<'a>(
156        &'a self,
157        call: &'a ToolCall,
158    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
159    {
160        if !self.is_allowed(&call.tool_id) {
161            tracing::warn!(
162                tool_id = %call.tool_id,
163                "sub-agent tool call rejected by policy"
164            );
165            return Box::pin(std::future::ready(Err(ToolError::Blocked {
166                command: call.tool_id.clone(),
167            })));
168        }
169        Box::pin(self.inner.execute_tool_call_erased(call))
170    }
171
172    fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
173        self.inner.set_skill_env(env);
174    }
175
176    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
177        self.inner.is_tool_retryable_erased(tool_id)
178    }
179}
180
181// ── Plan mode executor ────────────────────────────────────────────────────────
182
183/// Wraps an [`ErasedToolExecutor`] for `Plan` permission mode.
184///
185/// Exposes the real tool catalog via `tool_definitions_erased()` so the LLM can
186/// reference existing tools in its plan, but blocks all execution methods with
187/// [`ToolError::Blocked`]. This implements read-only planning: the agent sees what
188/// tools exist but cannot invoke them.
189pub struct PlanModeExecutor {
190    inner: Arc<dyn ErasedToolExecutor>,
191}
192
193impl PlanModeExecutor {
194    /// Wrap `inner` with plan-mode restrictions.
195    #[must_use]
196    pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
197        Self { inner }
198    }
199}
200
201impl ErasedToolExecutor for PlanModeExecutor {
202    fn execute_erased<'a>(
203        &'a self,
204        _response: &'a str,
205    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
206    {
207        Box::pin(std::future::ready(Err(ToolError::Blocked {
208            command: "plan_mode".into(),
209        })))
210    }
211
212    fn execute_confirmed_erased<'a>(
213        &'a self,
214        _response: &'a str,
215    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
216    {
217        Box::pin(std::future::ready(Err(ToolError::Blocked {
218            command: "plan_mode".into(),
219        })))
220    }
221
222    fn tool_definitions_erased(&self) -> Vec<ToolDef> {
223        self.inner.tool_definitions_erased()
224    }
225
226    fn execute_tool_call_erased<'a>(
227        &'a self,
228        call: &'a ToolCall,
229    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
230    {
231        tracing::debug!(
232            tool_id = %call.tool_id,
233            "tool execution blocked in plan mode"
234        );
235        Box::pin(std::future::ready(Err(ToolError::Blocked {
236            command: call.tool_id.clone(),
237        })))
238    }
239
240    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
241        self.inner.set_skill_env(env);
242    }
243
244    fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
245        false
246    }
247}
248
249// ── Skill filtering ───────────────────────────────────────────────────────────
250
251/// Filter skills from a registry according to a [`SkillFilter`].
252///
253/// Include patterns are glob-matched against skill names. If `include` is empty,
254/// all skills pass (unless excluded). Exclude patterns always take precedence.
255///
256/// # Errors
257///
258/// Returns [`SubAgentError::Invalid`] if any glob pattern is syntactically invalid.
259pub fn filter_skills(
260    registry: &SkillRegistry,
261    filter: &SkillFilter,
262) -> Result<Vec<Skill>, SubAgentError> {
263    let compiled_include = compile_globs(&filter.include)?;
264    let compiled_exclude = compile_globs(&filter.exclude)?;
265
266    let all: Vec<Skill> = registry
267        .all_meta()
268        .into_iter()
269        .filter(|meta| {
270            let name = &meta.name;
271            let included =
272                compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
273            let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
274            included && !excluded
275        })
276        .filter_map(|meta| registry.get_skill(&meta.name).ok())
277        .collect();
278
279    Ok(all)
280}
281
282/// Compiled glob pattern: literal prefix + optional `*` wildcard suffix.
283struct GlobPattern {
284    raw: String,
285    prefix: String,
286    suffix: Option<String>,
287    is_star: bool,
288}
289
290fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
291    patterns.iter().map(|p| compile_glob(p)).collect()
292}
293
294fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
295    // Simple glob: supports `*` as a wildcard anywhere in the string.
296    // For MVP we only need prefix-star patterns like "git-*" or "*".
297    if pattern.contains("**") {
298        return Err(SubAgentError::Invalid(format!(
299            "glob pattern '{pattern}' uses '**' which is not supported"
300        )));
301    }
302
303    let is_star = pattern == "*";
304
305    let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
306        let before = pattern[..pos].to_owned();
307        let after = pattern[pos + 1..].to_owned();
308        (before, Some(after))
309    } else {
310        (pattern.to_owned(), None)
311    };
312
313    Ok(GlobPattern {
314        raw: pattern.to_owned(),
315        prefix,
316        suffix,
317        is_star,
318    })
319}
320
321fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
322    if pattern.is_star {
323        return true;
324    }
325
326    match &pattern.suffix {
327        None => name == pattern.raw,
328        Some(suf) => {
329            name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
330                // Ensure the wildcard section isn't negative-length.
331                name.len() >= pattern.prefix.len() + suf.len()
332            }
333        }
334    }
335}
336
337// ── Tests ─────────────────────────────────────────────────────────────────────
338
339#[cfg(test)]
340mod tests {
341    #![allow(clippy::default_trait_access)]
342
343    use super::*;
344    use crate::def::ToolPolicy;
345
346    // ── FilteredToolExecutor tests ─────────────────────────────────────────
347
348    struct StubExecutor {
349        tools: Vec<&'static str>,
350    }
351
352    /// Stub executor that exposes tools with `InvocationHint::FencedBlock(tag)`.
353    struct StubFencedExecutor {
354        tag: &'static str,
355    }
356
357    impl ErasedToolExecutor for StubFencedExecutor {
358        fn execute_erased<'a>(
359            &'a self,
360            _response: &'a str,
361        ) -> Pin<
362            Box<
363                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
364            >,
365        > {
366            Box::pin(std::future::ready(Ok(None)))
367        }
368
369        fn execute_confirmed_erased<'a>(
370            &'a self,
371            _response: &'a str,
372        ) -> Pin<
373            Box<
374                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
375            >,
376        > {
377            Box::pin(std::future::ready(Ok(None)))
378        }
379
380        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
381            use zeph_tools::registry::InvocationHint;
382            vec![ToolDef {
383                id: self.tag.into(),
384                description: "fenced stub".into(),
385                schema: schemars::Schema::default(),
386                invocation: InvocationHint::FencedBlock(self.tag),
387            }]
388        }
389
390        fn execute_tool_call_erased<'a>(
391            &'a self,
392            call: &'a ToolCall,
393        ) -> Pin<
394            Box<
395                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
396            >,
397        > {
398            let result = Ok(Some(ToolOutput {
399                tool_name: call.tool_id.clone(),
400                summary: "ok".into(),
401                blocks_executed: 1,
402                filter_stats: None,
403                diff: None,
404                streamed: false,
405                terminal_id: None,
406                locations: None,
407                raw_response: None,
408                claim_source: None,
409            }));
410            Box::pin(std::future::ready(result))
411        }
412
413        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
414            false
415        }
416    }
417
418    fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
419        Arc::new(StubFencedExecutor { tag })
420    }
421
422    impl ErasedToolExecutor for StubExecutor {
423        fn execute_erased<'a>(
424            &'a self,
425            _response: &'a str,
426        ) -> Pin<
427            Box<
428                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
429            >,
430        > {
431            Box::pin(std::future::ready(Ok(None)))
432        }
433
434        fn execute_confirmed_erased<'a>(
435            &'a self,
436            _response: &'a str,
437        ) -> Pin<
438            Box<
439                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
440            >,
441        > {
442            Box::pin(std::future::ready(Ok(None)))
443        }
444
445        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
446            // Return stub definitions for each tool name.
447            use zeph_tools::registry::InvocationHint;
448            self.tools
449                .iter()
450                .map(|id| ToolDef {
451                    id: (*id).into(),
452                    description: "stub".into(),
453                    schema: schemars::Schema::default(),
454                    invocation: InvocationHint::ToolCall,
455                })
456                .collect()
457        }
458
459        fn execute_tool_call_erased<'a>(
460            &'a self,
461            call: &'a ToolCall,
462        ) -> Pin<
463            Box<
464                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
465            >,
466        > {
467            let result = Ok(Some(ToolOutput {
468                tool_name: call.tool_id.clone(),
469                summary: "ok".into(),
470                blocks_executed: 1,
471                filter_stats: None,
472                diff: None,
473                streamed: false,
474                terminal_id: None,
475                locations: None,
476                raw_response: None,
477                claim_source: None,
478            }));
479            Box::pin(std::future::ready(result))
480        }
481
482        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
483            false
484        }
485    }
486
487    fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
488        Arc::new(StubExecutor {
489            tools: tools.to_vec(),
490        })
491    }
492
493    #[tokio::test]
494    async fn allow_list_permits_listed_tool() {
495        let exec = FilteredToolExecutor::new(
496            stub_box(&["shell", "web"]),
497            ToolPolicy::AllowList(vec!["shell".into()]),
498        );
499        let call = ToolCall {
500            tool_id: "shell".into(),
501            params: serde_json::Map::default(),
502            caller_id: None,
503        };
504        let res = exec.execute_tool_call_erased(&call).await.unwrap();
505        assert!(res.is_some());
506    }
507
508    #[tokio::test]
509    async fn allow_list_blocks_unlisted_tool() {
510        let exec = FilteredToolExecutor::new(
511            stub_box(&["shell", "web"]),
512            ToolPolicy::AllowList(vec!["shell".into()]),
513        );
514        let call = ToolCall {
515            tool_id: "web".into(),
516            params: serde_json::Map::default(),
517            caller_id: None,
518        };
519        let res = exec.execute_tool_call_erased(&call).await;
520        assert!(res.is_err());
521    }
522
523    #[tokio::test]
524    async fn deny_list_blocks_listed_tool() {
525        let exec = FilteredToolExecutor::new(
526            stub_box(&["shell", "web"]),
527            ToolPolicy::DenyList(vec!["shell".into()]),
528        );
529        let call = ToolCall {
530            tool_id: "shell".into(),
531            params: serde_json::Map::default(),
532            caller_id: None,
533        };
534        let res = exec.execute_tool_call_erased(&call).await;
535        assert!(res.is_err());
536    }
537
538    #[tokio::test]
539    async fn inherit_all_permits_any_tool() {
540        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
541        let call = ToolCall {
542            tool_id: "shell".into(),
543            params: serde_json::Map::default(),
544            caller_id: None,
545        };
546        let res = exec.execute_tool_call_erased(&call).await.unwrap();
547        assert!(res.is_some());
548    }
549
550    #[test]
551    fn tool_definitions_filtered_by_allow_list() {
552        let exec = FilteredToolExecutor::new(
553            stub_box(&["shell", "web"]),
554            ToolPolicy::AllowList(vec!["shell".into()]),
555        );
556        let defs = exec.tool_definitions_erased();
557        assert_eq!(defs.len(), 1);
558        assert_eq!(defs[0].id, "shell");
559    }
560
561    // ── glob_match tests ───────────────────────────────────────────────────
562
563    fn matches(pattern: &str, name: &str) -> bool {
564        let p = compile_glob(pattern).unwrap();
565        glob_match(&p, name)
566    }
567
568    #[test]
569    fn glob_star_matches_all() {
570        assert!(matches("*", "anything"));
571        assert!(matches("*", ""));
572    }
573
574    #[test]
575    fn glob_prefix_star() {
576        assert!(matches("git-*", "git-commit"));
577        assert!(matches("git-*", "git-status"));
578        assert!(!matches("git-*", "rust-fmt"));
579    }
580
581    #[test]
582    fn glob_literal_exact_match() {
583        assert!(matches("shell", "shell"));
584        assert!(!matches("shell", "shell-extra"));
585    }
586
587    #[test]
588    fn glob_star_suffix() {
589        assert!(matches("*-review", "code-review"));
590        assert!(!matches("*-review", "code-reviewer"));
591    }
592
593    #[test]
594    fn glob_double_star_is_error() {
595        assert!(compile_glob("**").is_err());
596    }
597
598    #[test]
599    fn glob_mid_string_wildcard() {
600        // "a*b" — prefix="a", suffix=Some("b")
601        assert!(matches("a*b", "axb"));
602        assert!(matches("a*b", "aXYZb"));
603        assert!(!matches("a*b", "ab-extra"));
604        assert!(!matches("a*b", "xab"));
605    }
606
607    // ── FilteredToolExecutor additional tests ──────────────────────────────
608
609    #[tokio::test]
610    async fn deny_list_permits_unlisted_tool() {
611        let exec = FilteredToolExecutor::new(
612            stub_box(&["shell", "web"]),
613            ToolPolicy::DenyList(vec!["shell".into()]),
614        );
615        let call = ToolCall {
616            tool_id: "web".into(), // not in deny list → allowed
617            params: serde_json::Map::default(),
618            caller_id: None,
619        };
620        let res = exec.execute_tool_call_erased(&call).await.unwrap();
621        assert!(res.is_some());
622    }
623
624    #[test]
625    fn tool_definitions_filtered_by_deny_list() {
626        let exec = FilteredToolExecutor::new(
627            stub_box(&["shell", "web"]),
628            ToolPolicy::DenyList(vec!["shell".into()]),
629        );
630        let defs = exec.tool_definitions_erased();
631        assert_eq!(defs.len(), 1);
632        assert_eq!(defs[0].id, "web");
633    }
634
635    #[test]
636    fn tool_definitions_inherit_all_returns_all() {
637        let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
638        let defs = exec.tool_definitions_erased();
639        assert_eq!(defs.len(), 2);
640    }
641
642    // ── fenced-block detection tests (fix for #1432) ──────────────────────
643
644    #[tokio::test]
645    async fn fenced_block_matching_tag_is_blocked() {
646        // Executor has a FencedBlock("bash") tool; response contains ```bash block.
647        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
648        let res = exec.execute_erased("```bash\nls\n```").await;
649        assert!(
650            res.is_err(),
651            "actual fenced-block invocation must be blocked"
652        );
653    }
654
655    #[tokio::test]
656    async fn fenced_block_matching_tag_confirmed_is_blocked() {
657        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
658        let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
659        assert!(
660            res.is_err(),
661            "actual fenced-block invocation (confirmed) must be blocked"
662        );
663    }
664
665    #[tokio::test]
666    async fn no_fenced_tools_plain_text_returns_ok_none() {
667        // No fenced-block tools registered → plain text must return Ok(None).
668        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
669        let res = exec.execute_erased("This is a plain text response.").await;
670        assert!(
671            res.unwrap().is_none(),
672            "plain text must not be treated as a tool call"
673        );
674    }
675
676    #[tokio::test]
677    async fn markdown_non_tool_fence_returns_ok_none() {
678        // Response has a ```rust fence but no FencedBlock tool with tag "rust" is registered.
679        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
680        let res = exec
681            .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
682            .await;
683        assert!(
684            res.unwrap().is_none(),
685            "non-tool code fence must not trigger blocking"
686        );
687    }
688
689    #[tokio::test]
690    async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
691        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
692        let res = exec
693            .execute_confirmed_erased("Plain response without any fences.")
694            .await;
695        assert!(res.unwrap().is_none());
696    }
697
698    /// Regression test for #1432: fenced executor + plain text (no fences at all) must return
699    /// Ok(None) so the agent loop can break. Previously this returned Err(Blocked)
700    /// unconditionally, exhausting all sub-agent turns.
701    #[tokio::test]
702    async fn fenced_executor_plain_text_returns_ok_none() {
703        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
704        let res = exec
705            .execute_erased("Here is my analysis of the code. No shell commands needed.")
706            .await;
707        assert!(
708            res.unwrap().is_none(),
709            "plain text with fenced executor must not be treated as a tool call"
710        );
711    }
712
713    /// Unclosed fence (no closing ```) must not trigger blocking — it is not an executable
714    /// tool invocation. Verified by debugger as an intentional false-negative.
715    #[tokio::test]
716    async fn unclosed_fenced_block_returns_ok_none() {
717        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
718        let res = exec.execute_erased("```bash\nls -la\n").await;
719        assert!(
720            res.unwrap().is_none(),
721            "unclosed fenced block must not be treated as a tool invocation"
722        );
723    }
724
725    /// Multiple fenced blocks where one matches a registered tag — must block.
726    #[tokio::test]
727    async fn multiple_fences_one_matching_tag_is_blocked() {
728        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
729        let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
730        let res = exec.execute_erased(response).await;
731        assert!(
732            res.is_err(),
733            "response containing a matching fenced block must be blocked"
734        );
735    }
736
737    // ── disallowed_tools (tools.except) tests ─────────────────────────────
738
739    #[tokio::test]
740    async fn disallowed_blocks_tool_from_allow_list() {
741        let exec = FilteredToolExecutor::with_disallowed(
742            stub_box(&["shell", "web"]),
743            ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
744            vec!["shell".into()],
745        );
746        let call = ToolCall {
747            tool_id: "shell".into(),
748            params: serde_json::Map::default(),
749            caller_id: None,
750        };
751        let res = exec.execute_tool_call_erased(&call).await;
752        assert!(
753            res.is_err(),
754            "disallowed tool must be blocked even if in allow list"
755        );
756    }
757
758    #[tokio::test]
759    async fn disallowed_allows_non_disallowed_tool() {
760        let exec = FilteredToolExecutor::with_disallowed(
761            stub_box(&["shell", "web"]),
762            ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
763            vec!["shell".into()],
764        );
765        let call = ToolCall {
766            tool_id: "web".into(),
767            params: serde_json::Map::default(),
768            caller_id: None,
769        };
770        let res = exec.execute_tool_call_erased(&call).await;
771        assert!(res.is_ok(), "non-disallowed tool must be allowed");
772    }
773
774    #[test]
775    fn disallowed_empty_list_no_change() {
776        let exec = FilteredToolExecutor::with_disallowed(
777            stub_box(&["shell", "web"]),
778            ToolPolicy::InheritAll,
779            vec![],
780        );
781        let defs = exec.tool_definitions_erased();
782        assert_eq!(defs.len(), 2);
783    }
784
785    #[test]
786    fn tool_definitions_filters_disallowed_tools() {
787        let exec = FilteredToolExecutor::with_disallowed(
788            stub_box(&["shell", "web", "dangerous"]),
789            ToolPolicy::InheritAll,
790            vec!["dangerous".into()],
791        );
792        let defs = exec.tool_definitions_erased();
793        assert_eq!(defs.len(), 2);
794        assert!(!defs.iter().any(|d| d.id == "dangerous"));
795    }
796
797    // ── #1184: PlanModeExecutor + disallowed_tools catalog test ───────────
798
799    #[test]
800    fn plan_mode_with_disallowed_excludes_from_catalog() {
801        // FilteredToolExecutor wrapping PlanModeExecutor must exclude disallowed tools from
802        // tool_definitions_erased(), verifying that deny-list is enforced in plan mode catalog.
803        let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
804        let exec = FilteredToolExecutor::with_disallowed(
805            inner,
806            ToolPolicy::InheritAll,
807            vec!["shell".into()],
808        );
809        let defs = exec.tool_definitions_erased();
810        assert!(
811            !defs.iter().any(|d| d.id == "shell"),
812            "shell must be excluded from catalog"
813        );
814        assert!(
815            defs.iter().any(|d| d.id == "web"),
816            "web must remain in catalog"
817        );
818    }
819
820    // ── PlanModeExecutor tests ─────────────────────────────────────────────
821
822    #[tokio::test]
823    async fn plan_mode_blocks_execute_erased() {
824        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
825        let res = exec.execute_erased("response").await;
826        assert!(res.is_err());
827    }
828
829    #[tokio::test]
830    async fn plan_mode_blocks_execute_confirmed_erased() {
831        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
832        let res = exec.execute_confirmed_erased("response").await;
833        assert!(res.is_err());
834    }
835
836    #[tokio::test]
837    async fn plan_mode_blocks_tool_call() {
838        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
839        let call = ToolCall {
840            tool_id: "shell".into(),
841            params: serde_json::Map::default(),
842            caller_id: None,
843        };
844        let res = exec.execute_tool_call_erased(&call).await;
845        assert!(res.is_err(), "plan mode must block all tool execution");
846    }
847
848    #[test]
849    fn plan_mode_exposes_real_tool_definitions() {
850        let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
851        let defs = exec.tool_definitions_erased();
852        // Real tool catalog exposed — LLM can reference tools in its plan.
853        assert_eq!(defs.len(), 2);
854        assert!(defs.iter().any(|d| d.id == "shell"));
855        assert!(defs.iter().any(|d| d.id == "web"));
856    }
857
858    // ── filter_skills tests ────────────────────────────────────────────────
859
860    #[test]
861    fn filter_skills_empty_registry_returns_empty() {
862        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
863        let filter = SkillFilter::default();
864        let result = filter_skills(&registry, &filter).unwrap();
865        assert!(result.is_empty());
866    }
867
868    #[test]
869    fn filter_skills_empty_include_passes_all() {
870        // Empty include list means "include everything".
871        // With an empty registry, result is still empty — logic is correct.
872        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
873        let filter = SkillFilter {
874            include: vec![],
875            exclude: vec![],
876        };
877        let result = filter_skills(&registry, &filter).unwrap();
878        assert!(result.is_empty());
879    }
880
881    #[test]
882    fn filter_skills_double_star_pattern_is_error() {
883        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
884        let filter = SkillFilter {
885            include: vec!["**".into()],
886            exclude: vec![],
887        };
888        let err = filter_skills(&registry, &filter).unwrap_err();
889        assert!(matches!(err, SubAgentError::Invalid(_)));
890    }
891
892    mod proptest_glob {
893        use proptest::prelude::*;
894
895        use super::{compile_glob, glob_match};
896
897        proptest! {
898            #![proptest_config(proptest::test_runner::Config::with_cases(500))]
899
900            /// glob_match must never panic for any valid (non-**) pattern and any name string.
901            #[test]
902            fn glob_match_never_panics(
903                pattern in "[a-z*-]{1,10}",
904                name in "[a-z-]{0,15}",
905            ) {
906                // Skip patterns with ** (those are compile errors by design).
907                if !pattern.contains("**")
908                    && let Ok(p) = compile_glob(&pattern)
909                {
910                    let _ = glob_match(&p, &name);
911                }
912            }
913
914            /// A literal pattern (no `*`) must match only exact strings.
915            #[test]
916            fn glob_literal_matches_only_exact(
917                name in "[a-z-]{1,10}",
918            ) {
919                // A literal pattern equal to `name` must match.
920                let p = compile_glob(&name).unwrap();
921                prop_assert!(glob_match(&p, &name));
922
923                // A different name must not match.
924                let other = format!("{name}-x");
925                prop_assert!(!glob_match(&p, &other));
926            }
927
928            /// The `*` pattern must match every input.
929            #[test]
930            fn glob_star_matches_everything(name in ".*") {
931                let p = compile_glob("*").unwrap();
932                prop_assert!(glob_match(&p, &name));
933            }
934        }
935    }
936}