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