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