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