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