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    /// Same policy as `execute_tool_call_erased`: the confirmed path must go through the
202    /// same allow/deny check, not bypass it. Mirrors the removed trait default's behavior
203    /// (delegate to `execute_tool_call_erased`) while preserving the policy enforcement
204    /// already present there.
205    fn execute_tool_call_confirmed_erased<'a>(
206        &'a self,
207        call: &'a ToolCall,
208    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
209    {
210        self.execute_tool_call_erased(call)
211    }
212
213    fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
214        self.inner.set_skill_env(env);
215    }
216
217    fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
218        self.inner.set_effective_trust(level);
219    }
220
221    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
222        self.inner.is_tool_retryable_erased(tool_id)
223    }
224
225    fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
226        self.inner.requires_confirmation_erased(call)
227    }
228
229    zeph_tools::erased_tool_executor_forward!(inner);
230}
231
232// ── Plan mode executor ────────────────────────────────────────────────────────
233
234/// Wraps an [`ErasedToolExecutor`] for `Plan` permission mode.
235///
236/// Exposes the real tool catalog via `tool_definitions_erased()` so the LLM can
237/// reference existing tools in its plan, but blocks all execution methods with
238/// [`ToolError::Blocked`]. This implements read-only planning: the agent sees what
239/// tools exist but cannot invoke them.
240pub struct PlanModeExecutor {
241    inner: Arc<dyn ErasedToolExecutor>,
242}
243
244impl PlanModeExecutor {
245    /// Wrap `inner` with plan-mode restrictions.
246    #[must_use]
247    pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
248        Self { inner }
249    }
250}
251
252impl ErasedToolExecutor for PlanModeExecutor {
253    fn execute_erased<'a>(
254        &'a self,
255        _response: &'a str,
256    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
257    {
258        Box::pin(std::future::ready(Err(ToolError::Blocked {
259            command: "plan_mode".into(),
260        })))
261    }
262
263    fn execute_confirmed_erased<'a>(
264        &'a self,
265        _response: &'a str,
266    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
267    {
268        Box::pin(std::future::ready(Err(ToolError::Blocked {
269            command: "plan_mode".into(),
270        })))
271    }
272
273    fn tool_definitions_erased(&self) -> Vec<ToolDef> {
274        self.inner.tool_definitions_erased()
275    }
276
277    fn execute_tool_call_erased<'a>(
278        &'a self,
279        call: &'a ToolCall,
280    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
281    {
282        tracing::debug!(
283            tool_id = %call.tool_id,
284            "tool execution blocked in plan mode"
285        );
286        Box::pin(std::future::ready(Err(ToolError::Blocked {
287            command: call.tool_id.to_string(),
288        })))
289    }
290
291    /// Plan mode blocks all execution, confirmed or not — reuse the same block as the
292    /// unconfirmed path rather than forwarding to `inner`.
293    fn execute_tool_call_confirmed_erased<'a>(
294        &'a self,
295        call: &'a ToolCall,
296    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
297    {
298        self.execute_tool_call_erased(call)
299    }
300
301    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
302        self.inner.set_skill_env(env);
303    }
304
305    /// Read-only in plan mode: trust level is metadata, not an execution capability,
306    /// so it is safe (and necessary for parity with other read paths) to forward.
307    fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
308        self.inner.set_effective_trust(level);
309    }
310
311    fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
312        false
313    }
314
315    fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
316        false
317    }
318
319    // Deliberate: this forwards the checkpoint trio to `inner` rather than reporting
320    // "unsupported" (the pre-#6019 behavior). Plan mode blocks new tool *execution*
321    // (`execute_tool_call_erased` above always returns `Blocked`); checkpoint undo/redo/list
322    // act on already-executed side effects and are administrative/read operations, not new
323    // execution, so exposing them while plan mode is active is in scope.
324    zeph_tools::erased_tool_executor_forward!(inner);
325}
326
327// ── Network egress denial ─────────────────────────────────────────────────────
328
329/// Tool IDs that are pure network-egress tools — any call is blocked outright, no command
330/// inspection needed (unlike `bash`, which is dual-use).
331///
332/// `web_scrape`/`fetch` are the native `WebScrapeExecutor` tool IDs (see `zeph-tools`
333/// `scrape.rs`) — the tool an LLM reaches for by default to retrieve a URL, and therefore
334/// the highest-likelihood egress vector for a `Deny`-scoped task (#6030 critic finding S2).
335/// `web_search` is the native `WebSearchExecutor` tool ID (spec 006-1-web-search, #6358) —
336/// added alongside them since it is also a pure network-egress tool with no non-network
337/// purpose.
338const NETWORK_ONLY_TOOL_IDS: &[&str] = &["web_scrape", "fetch", "web_search"];
339
340/// Blocks network-egress tool calls for a single sub-agent spawn.
341///
342/// Wraps an [`ErasedToolExecutor`] and rejects two classes of call with
343/// [`ToolError::Blocked`]:
344/// - Any call to a network-only tool (`web_scrape`, `fetch`, `web_search`) — blocked unconditionally,
345///   since these tools have no non-network purpose.
346/// - `bash` tool calls whose command matches [`zeph_tools::NETWORK_COMMANDS`] (`curl`,
347///   `wget`, `nc`, `ncat`, `netcat`).
348///
349/// All other tool calls pass through unchanged. **Known gap**: MCP-provided tools (which may
350/// perform their own HTTP egress) are not inspected — see `specs/069-threat-model/spec.md`
351/// INVARIANT-5. This is a best-effort, tool/command-identity block, not a sandbox boundary.
352///
353/// Installed by `build_filtered_executor` (`crate::manager::spawn`) when the spawning
354/// task carries `NetworkScope::Deny` (spec `069-threat-model` OQ-1). Unlike mutating
355/// [`ShellConfig`](zeph_tools::ShellConfig)'s `allow_network` field directly, this
356/// wrapper scopes the restriction to a single spawn without affecting the shared
357/// `tool_executor` used by the parent agent and sibling tasks.
358pub struct NetworkDenyToolExecutor {
359    inner: Arc<dyn ErasedToolExecutor>,
360    blocklist: Vec<String>,
361}
362
363impl NetworkDenyToolExecutor {
364    /// Wrap `inner`, blocking network-egress tool calls for every call.
365    #[must_use]
366    pub fn new(inner: Arc<dyn ErasedToolExecutor>) -> Self {
367        Self {
368            inner,
369            blocklist: zeph_tools::NETWORK_COMMANDS
370                .iter()
371                .map(|s| (*s).to_owned())
372                .collect(),
373        }
374    }
375
376    /// Returns `Err` if `call` targets a network-only tool (`web_scrape`, `fetch`, `web_search`) or is a
377    /// `bash` invocation whose command matches the network-command blocklist; `Ok(())`
378    /// otherwise.
379    fn check_call(&self, call: &ToolCall) -> Result<(), ToolError> {
380        let tool_id = normalize_tool_id(call.tool_id.as_str());
381
382        if NETWORK_ONLY_TOOL_IDS.contains(&tool_id.as_str()) {
383            tracing::warn!(
384                tool_id = %tool_id,
385                "network egress denied for sub-agent task (NetworkScope::Deny)"
386            );
387            return Err(ToolError::Blocked { command: tool_id });
388        }
389
390        if tool_id != "bash" {
391            return Ok(());
392        }
393        let Some(command) = call.params.get("command").and_then(|v| v.as_str()) else {
394            return Ok(());
395        };
396        if let Some(matched) = zeph_tools::check_blocklist(command, &self.blocklist) {
397            tracing::warn!(
398                command = %matched,
399                "network egress denied for sub-agent task (NetworkScope::Deny)"
400            );
401            return Err(ToolError::Blocked { command: matched });
402        }
403        Ok(())
404    }
405}
406
407impl ErasedToolExecutor for NetworkDenyToolExecutor {
408    fn execute_erased<'a>(
409        &'a self,
410        response: &'a str,
411    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
412    {
413        self.inner.execute_erased(response)
414    }
415
416    fn execute_confirmed_erased<'a>(
417        &'a self,
418        response: &'a str,
419    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
420    {
421        self.inner.execute_confirmed_erased(response)
422    }
423
424    fn tool_definitions_erased(&self) -> Vec<ToolDef> {
425        self.inner.tool_definitions_erased()
426    }
427
428    fn execute_tool_call_erased<'a>(
429        &'a self,
430        call: &'a ToolCall,
431    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
432    {
433        if let Err(e) = self.check_call(call) {
434            return Box::pin(std::future::ready(Err(e)));
435        }
436        Box::pin(self.inner.execute_tool_call_erased(call))
437    }
438
439    fn execute_tool_call_confirmed_erased<'a>(
440        &'a self,
441        call: &'a ToolCall,
442    ) -> Pin<Box<dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
443    {
444        if let Err(e) = self.check_call(call) {
445            return Box::pin(std::future::ready(Err(e)));
446        }
447        Box::pin(self.inner.execute_tool_call_confirmed_erased(call))
448    }
449
450    fn set_skill_env(&self, env: Option<HashMap<String, String>>) {
451        self.inner.set_skill_env(env);
452    }
453
454    fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
455        self.inner.set_effective_trust(level);
456    }
457
458    fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
459        self.inner.is_tool_retryable_erased(tool_id)
460    }
461
462    fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
463        self.inner.requires_confirmation_erased(call)
464    }
465
466    zeph_tools::erased_tool_executor_forward!(inner);
467}
468
469// ── Skill filtering ───────────────────────────────────────────────────────────
470
471/// Filter skills from a registry according to a [`SkillFilter`].
472///
473/// Include patterns are glob-matched against skill names. If `include` is empty,
474/// all skills pass (unless excluded). Exclude patterns always take precedence.
475///
476/// Supported glob syntax:
477/// - `*` — wildcard matching any substring (e.g., `"git-*"`)
478/// - Literal strings — exact match only
479/// - `**` is **not** supported and returns [`SubAgentError::Invalid`]
480///
481/// # Errors
482///
483/// Returns [`SubAgentError::Invalid`] if any glob pattern is syntactically invalid.
484///
485/// # Examples
486///
487/// ```rust,no_run
488/// use zeph_skills::registry::SkillRegistry;
489/// use zeph_subagent::filter_skills;
490/// use zeph_subagent::SkillFilter;
491///
492/// let registry = SkillRegistry::load(&[] as &[&str]);
493/// let filter = SkillFilter { include: vec![], exclude: vec![] };
494/// let skills = filter_skills(&registry, &filter).unwrap();
495/// assert!(skills.is_empty());
496/// ```
497pub fn filter_skills(
498    registry: &SkillRegistry,
499    filter: &SkillFilter,
500) -> Result<Vec<Skill>, SubAgentError> {
501    let compiled_include = compile_globs(&filter.include)?;
502    let compiled_exclude = compile_globs(&filter.exclude)?;
503
504    let all: Vec<Skill> = registry
505        .all_meta()
506        .into_iter()
507        .filter(|meta| {
508            let name = &meta.name;
509            let included =
510                compiled_include.is_empty() || compiled_include.iter().any(|p| glob_match(p, name));
511            let excluded = compiled_exclude.iter().any(|p| glob_match(p, name));
512            included && !excluded
513        })
514        .filter_map(|meta| registry.skill(&meta.name).ok())
515        .collect();
516
517    Ok(all)
518}
519
520/// Compiled glob pattern: literal prefix + optional `*` wildcard suffix.
521struct GlobPattern {
522    raw: String,
523    prefix: String,
524    suffix: Option<String>,
525    is_star: bool,
526}
527
528fn compile_globs(patterns: &[String]) -> Result<Vec<GlobPattern>, SubAgentError> {
529    patterns.iter().map(|p| compile_glob(p)).collect()
530}
531
532fn compile_glob(pattern: &str) -> Result<GlobPattern, SubAgentError> {
533    // Simple glob: supports `*` as a wildcard anywhere in the string.
534    // For MVP we only need prefix-star patterns like "git-*" or "*".
535    if pattern.contains("**") {
536        return Err(SubAgentError::Invalid(format!(
537            "glob pattern '{pattern}' uses '**' which is not supported"
538        )));
539    }
540
541    let is_star = pattern == "*";
542
543    let (prefix, suffix) = if let Some(pos) = pattern.find('*') {
544        let before = pattern[..pos].to_owned();
545        let after = pattern[pos + 1..].to_owned();
546        (before, Some(after))
547    } else {
548        (pattern.to_owned(), None)
549    };
550
551    Ok(GlobPattern {
552        raw: pattern.to_owned(),
553        prefix,
554        suffix,
555        is_star,
556    })
557}
558
559fn glob_match(pattern: &GlobPattern, name: &str) -> bool {
560    if pattern.is_star {
561        return true;
562    }
563
564    match &pattern.suffix {
565        None => name == pattern.raw,
566        Some(suf) => {
567            name.starts_with(&pattern.prefix) && name.ends_with(suf.as_str()) && {
568                // Ensure the wildcard section isn't negative-length.
569                name.len() >= pattern.prefix.len() + suf.len()
570            }
571        }
572    }
573}
574
575// ── Tests ─────────────────────────────────────────────────────────────────────
576
577#[cfg(test)]
578mod tests {
579    #![allow(clippy::default_trait_access)]
580    use std::assert_matches;
581
582    use super::*;
583    use crate::def::ToolPolicy;
584
585    // ── FilteredToolExecutor tests ─────────────────────────────────────────
586
587    struct StubExecutor {
588        tools: Vec<&'static str>,
589    }
590
591    /// Stub executor that exposes tools with `InvocationHint::FencedBlock(tag)`.
592    struct StubFencedExecutor {
593        tag: &'static str,
594    }
595
596    impl ErasedToolExecutor for StubFencedExecutor {
597        fn execute_erased<'a>(
598            &'a self,
599            _response: &'a str,
600        ) -> Pin<
601            Box<
602                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
603            >,
604        > {
605            Box::pin(std::future::ready(Ok(None)))
606        }
607
608        fn execute_confirmed_erased<'a>(
609            &'a self,
610            _response: &'a str,
611        ) -> Pin<
612            Box<
613                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
614            >,
615        > {
616            Box::pin(std::future::ready(Ok(None)))
617        }
618
619        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
620            use zeph_tools::registry::InvocationHint;
621            vec![ToolDef {
622                id: self.tag.into(),
623                description: "fenced stub".into(),
624                schema: schemars::Schema::default(),
625                invocation: InvocationHint::FencedBlock(self.tag),
626                output_schema: None,
627                server_id: None,
628            }]
629        }
630
631        fn execute_tool_call_erased<'a>(
632            &'a self,
633            call: &'a ToolCall,
634        ) -> Pin<
635            Box<
636                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
637            >,
638        > {
639            let result = Ok(Some(ToolOutput {
640                tool_name: call.tool_id.clone(),
641                summary: "ok".into(),
642                blocks_executed: 1,
643                filter_stats: None,
644                diff: None,
645                streamed: false,
646                terminal_id: None,
647                locations: None,
648                raw_response: None,
649                claim_source: None,
650                ..Default::default()
651            }));
652            Box::pin(std::future::ready(result))
653        }
654
655        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
656            false
657        }
658
659        fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
660            false
661        }
662
663        fn execute_tool_call_confirmed_erased<'a>(
664            &'a self,
665            call: &'a ToolCall,
666        ) -> Pin<
667            Box<
668                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
669            >,
670        > {
671            self.execute_tool_call_erased(call)
672        }
673
674        fn checkpoint_undo_erased(&self, _n: usize) -> zeph_tools::CheckpointActionResult {
675            zeph_tools::CheckpointActionResult::unsupported()
676        }
677
678        fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
679            zeph_tools::CheckpointActionResult::unsupported()
680        }
681
682        fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
683            zeph_tools::CheckpointListResult::default()
684        }
685
686        fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
687            false
688        }
689    }
690
691    fn fenced_stub_box(tag: &'static str) -> Arc<dyn ErasedToolExecutor> {
692        Arc::new(StubFencedExecutor { tag })
693    }
694
695    impl ErasedToolExecutor for StubExecutor {
696        fn execute_erased<'a>(
697            &'a self,
698            _response: &'a str,
699        ) -> Pin<
700            Box<
701                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
702            >,
703        > {
704            Box::pin(std::future::ready(Ok(None)))
705        }
706
707        fn execute_confirmed_erased<'a>(
708            &'a self,
709            _response: &'a str,
710        ) -> Pin<
711            Box<
712                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
713            >,
714        > {
715            Box::pin(std::future::ready(Ok(None)))
716        }
717
718        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
719            // Return stub definitions for each tool name.
720            use zeph_tools::registry::InvocationHint;
721            self.tools
722                .iter()
723                .map(|id| ToolDef {
724                    id: (*id).into(),
725                    description: "stub".into(),
726                    schema: schemars::Schema::default(),
727                    invocation: InvocationHint::ToolCall,
728                    output_schema: None,
729                    server_id: None,
730                })
731                .collect()
732        }
733
734        fn execute_tool_call_erased<'a>(
735            &'a self,
736            call: &'a ToolCall,
737        ) -> Pin<
738            Box<
739                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
740            >,
741        > {
742            let result = Ok(Some(ToolOutput {
743                tool_name: call.tool_id.clone(),
744                summary: "ok".into(),
745                blocks_executed: 1,
746                filter_stats: None,
747                diff: None,
748                streamed: false,
749                terminal_id: None,
750                locations: None,
751                raw_response: None,
752                claim_source: None,
753                ..Default::default()
754            }));
755            Box::pin(std::future::ready(result))
756        }
757
758        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
759            false
760        }
761
762        fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
763            false
764        }
765
766        fn execute_tool_call_confirmed_erased<'a>(
767            &'a self,
768            call: &'a ToolCall,
769        ) -> Pin<
770            Box<
771                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
772            >,
773        > {
774            self.execute_tool_call_erased(call)
775        }
776
777        fn checkpoint_undo_erased(&self, _n: usize) -> zeph_tools::CheckpointActionResult {
778            zeph_tools::CheckpointActionResult::unsupported()
779        }
780
781        fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
782            zeph_tools::CheckpointActionResult::unsupported()
783        }
784
785        fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
786            zeph_tools::CheckpointListResult::default()
787        }
788
789        fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
790            false
791        }
792    }
793
794    fn stub_box(tools: &[&'static str]) -> Arc<dyn ErasedToolExecutor> {
795        Arc::new(StubExecutor {
796            tools: tools.to_vec(),
797        })
798    }
799
800    #[tokio::test]
801    async fn allow_list_permits_listed_tool() {
802        let exec = FilteredToolExecutor::new(
803            stub_box(&["shell", "web"]),
804            ToolPolicy::AllowList(vec!["shell".into()]),
805        );
806        let call = ToolCall {
807            tool_id: "shell".into(),
808            params: serde_json::Map::default(),
809            caller_id: None,
810            context: None,
811
812            tool_call_id: String::new(),
813            skill_name: None,
814        };
815        let res = exec.execute_tool_call_erased(&call).await.unwrap();
816        assert!(res.is_some());
817    }
818
819    #[tokio::test]
820    async fn allow_list_blocks_unlisted_tool() {
821        let exec = FilteredToolExecutor::new(
822            stub_box(&["shell", "web"]),
823            ToolPolicy::AllowList(vec!["shell".into()]),
824        );
825        let call = ToolCall {
826            tool_id: "web".into(),
827            params: serde_json::Map::default(),
828            caller_id: None,
829            context: None,
830
831            tool_call_id: String::new(),
832            skill_name: None,
833        };
834        let res = exec.execute_tool_call_erased(&call).await;
835        assert!(res.is_err());
836    }
837
838    #[tokio::test]
839    async fn deny_list_blocks_listed_tool() {
840        let exec = FilteredToolExecutor::new(
841            stub_box(&["shell", "web"]),
842            ToolPolicy::DenyList(vec!["shell".into()]),
843        );
844        let call = ToolCall {
845            tool_id: "shell".into(),
846            params: serde_json::Map::default(),
847            caller_id: None,
848            context: None,
849
850            tool_call_id: String::new(),
851            skill_name: None,
852        };
853        let res = exec.execute_tool_call_erased(&call).await;
854        assert!(res.is_err());
855    }
856
857    #[tokio::test]
858    async fn inherit_all_permits_any_tool() {
859        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
860        let call = ToolCall {
861            tool_id: "shell".into(),
862            params: serde_json::Map::default(),
863            caller_id: None,
864            context: None,
865
866            tool_call_id: String::new(),
867            skill_name: None,
868        };
869        let res = exec.execute_tool_call_erased(&call).await.unwrap();
870        assert!(res.is_some());
871    }
872
873    #[test]
874    fn tool_definitions_filtered_by_allow_list() {
875        let exec = FilteredToolExecutor::new(
876            stub_box(&["shell", "web"]),
877            ToolPolicy::AllowList(vec!["shell".into()]),
878        );
879        let defs = exec.tool_definitions_erased();
880        assert_eq!(defs.len(), 1);
881        assert_eq!(defs[0].id, "shell");
882    }
883
884    // ── glob_match tests ───────────────────────────────────────────────────
885
886    fn matches(pattern: &str, name: &str) -> bool {
887        let p = compile_glob(pattern).unwrap();
888        glob_match(&p, name)
889    }
890
891    #[test]
892    fn glob_star_matches_all() {
893        assert!(matches("*", "anything"));
894        assert!(matches("*", ""));
895    }
896
897    #[test]
898    fn glob_prefix_star() {
899        assert!(matches("git-*", "git-commit"));
900        assert!(matches("git-*", "git-status"));
901        assert!(!matches("git-*", "rust-fmt"));
902    }
903
904    #[test]
905    fn glob_literal_exact_match() {
906        assert!(matches("shell", "shell"));
907        assert!(!matches("shell", "shell-extra"));
908    }
909
910    #[test]
911    fn glob_star_suffix() {
912        assert!(matches("*-review", "code-review"));
913        assert!(!matches("*-review", "code-reviewer"));
914    }
915
916    #[test]
917    fn glob_double_star_is_error() {
918        assert!(compile_glob("**").is_err());
919    }
920
921    #[test]
922    fn glob_mid_string_wildcard() {
923        // "a*b" — prefix="a", suffix=Some("b")
924        assert!(matches("a*b", "axb"));
925        assert!(matches("a*b", "aXYZb"));
926        assert!(!matches("a*b", "ab-extra"));
927        assert!(!matches("a*b", "xab"));
928    }
929
930    // ── FilteredToolExecutor additional tests ──────────────────────────────
931
932    #[tokio::test]
933    async fn deny_list_permits_unlisted_tool() {
934        let exec = FilteredToolExecutor::new(
935            stub_box(&["shell", "web"]),
936            ToolPolicy::DenyList(vec!["shell".into()]),
937        );
938        let call = ToolCall {
939            tool_id: "web".into(), // not in deny list → allowed
940            params: serde_json::Map::default(),
941            caller_id: None,
942            context: None,
943
944            tool_call_id: String::new(),
945            skill_name: None,
946        };
947        let res = exec.execute_tool_call_erased(&call).await.unwrap();
948        assert!(res.is_some());
949    }
950
951    #[test]
952    fn tool_definitions_filtered_by_deny_list() {
953        let exec = FilteredToolExecutor::new(
954            stub_box(&["shell", "web"]),
955            ToolPolicy::DenyList(vec!["shell".into()]),
956        );
957        let defs = exec.tool_definitions_erased();
958        assert_eq!(defs.len(), 1);
959        assert_eq!(defs[0].id, "web");
960    }
961
962    #[test]
963    fn tool_definitions_inherit_all_returns_all() {
964        let exec = FilteredToolExecutor::new(stub_box(&["shell", "web"]), ToolPolicy::InheritAll);
965        let defs = exec.tool_definitions_erased();
966        assert_eq!(defs.len(), 2);
967    }
968
969    // ── fenced-block detection tests (fix for #1432) ──────────────────────
970
971    #[tokio::test]
972    async fn fenced_block_matching_tag_is_blocked() {
973        // Executor has a FencedBlock("bash") tool; response contains ```bash block.
974        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
975        let res = exec.execute_erased("```bash\nls\n```").await;
976        assert!(
977            res.is_err(),
978            "actual fenced-block invocation must be blocked"
979        );
980    }
981
982    #[tokio::test]
983    async fn fenced_block_matching_tag_confirmed_is_blocked() {
984        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
985        let res = exec.execute_confirmed_erased("```bash\nls\n```").await;
986        assert!(
987            res.is_err(),
988            "actual fenced-block invocation (confirmed) must be blocked"
989        );
990    }
991
992    #[tokio::test]
993    async fn no_fenced_tools_plain_text_returns_ok_none() {
994        // No fenced-block tools registered → plain text must return Ok(None).
995        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
996        let res = exec.execute_erased("This is a plain text response.").await;
997        assert!(
998            res.unwrap().is_none(),
999            "plain text must not be treated as a tool call"
1000        );
1001    }
1002
1003    #[tokio::test]
1004    async fn markdown_non_tool_fence_returns_ok_none() {
1005        // Response has a ```rust fence but no FencedBlock tool with tag "rust" is registered.
1006        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1007        let res = exec
1008            .execute_erased("Here is some code:\n```rust\nfn main() {}\n```")
1009            .await;
1010        assert!(
1011            res.unwrap().is_none(),
1012            "non-tool code fence must not trigger blocking"
1013        );
1014    }
1015
1016    #[tokio::test]
1017    async fn no_fenced_tools_plain_text_confirmed_returns_ok_none() {
1018        let exec = FilteredToolExecutor::new(stub_box(&["shell"]), ToolPolicy::InheritAll);
1019        let res = exec
1020            .execute_confirmed_erased("Plain response without any fences.")
1021            .await;
1022        assert!(res.unwrap().is_none());
1023    }
1024
1025    /// Regression test for #1432: fenced executor + plain text (no fences at all) must return
1026    /// Ok(None) so the agent loop can break. Previously this returned Err(Blocked)
1027    /// unconditionally, exhausting all sub-agent turns.
1028    #[tokio::test]
1029    async fn fenced_executor_plain_text_returns_ok_none() {
1030        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1031        let res = exec
1032            .execute_erased("Here is my analysis of the code. No shell commands needed.")
1033            .await;
1034        assert!(
1035            res.unwrap().is_none(),
1036            "plain text with fenced executor must not be treated as a tool call"
1037        );
1038    }
1039
1040    /// Unclosed fence (no closing ```) must not trigger blocking — it is not an executable
1041    /// tool invocation. Verified by debugger as an intentional false-negative.
1042    #[tokio::test]
1043    async fn unclosed_fenced_block_returns_ok_none() {
1044        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1045        let res = exec.execute_erased("```bash\nls -la\n").await;
1046        assert!(
1047            res.unwrap().is_none(),
1048            "unclosed fenced block must not be treated as a tool invocation"
1049        );
1050    }
1051
1052    /// Multiple fenced blocks where one matches a registered tag — must block.
1053    #[tokio::test]
1054    async fn multiple_fences_one_matching_tag_is_blocked() {
1055        let exec = FilteredToolExecutor::new(fenced_stub_box("bash"), ToolPolicy::InheritAll);
1056        let response = "Here is an example:\n```python\nprint('hello')\n```\nAnd the fix:\n```bash\nrm -rf /tmp/old\n```";
1057        let res = exec.execute_erased(response).await;
1058        assert!(
1059            res.is_err(),
1060            "response containing a matching fenced block must be blocked"
1061        );
1062    }
1063
1064    // ── disallowed_tools (tools.except) tests ─────────────────────────────
1065
1066    #[tokio::test]
1067    async fn disallowed_blocks_tool_from_allow_list() {
1068        let exec = FilteredToolExecutor::with_disallowed(
1069            stub_box(&["shell", "web"]),
1070            ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
1071            vec!["shell".into()],
1072        );
1073        let call = ToolCall {
1074            tool_id: "shell".into(),
1075            params: serde_json::Map::default(),
1076            caller_id: None,
1077            context: None,
1078
1079            tool_call_id: String::new(),
1080            skill_name: None,
1081        };
1082        let res = exec.execute_tool_call_erased(&call).await;
1083        assert!(
1084            res.is_err(),
1085            "disallowed tool must be blocked even if in allow list"
1086        );
1087    }
1088
1089    #[tokio::test]
1090    async fn disallowed_allows_non_disallowed_tool() {
1091        let exec = FilteredToolExecutor::with_disallowed(
1092            stub_box(&["shell", "web"]),
1093            ToolPolicy::AllowList(vec!["shell".into(), "web".into()]),
1094            vec!["shell".into()],
1095        );
1096        let call = ToolCall {
1097            tool_id: "web".into(),
1098            params: serde_json::Map::default(),
1099            caller_id: None,
1100            context: None,
1101
1102            tool_call_id: String::new(),
1103            skill_name: None,
1104        };
1105        let res = exec.execute_tool_call_erased(&call).await;
1106        assert!(res.is_ok(), "non-disallowed tool must be allowed");
1107    }
1108
1109    #[test]
1110    fn disallowed_empty_list_no_change() {
1111        let exec = FilteredToolExecutor::with_disallowed(
1112            stub_box(&["shell", "web"]),
1113            ToolPolicy::InheritAll,
1114            vec![],
1115        );
1116        let defs = exec.tool_definitions_erased();
1117        assert_eq!(defs.len(), 2);
1118    }
1119
1120    #[test]
1121    fn tool_definitions_filters_disallowed_tools() {
1122        let exec = FilteredToolExecutor::with_disallowed(
1123            stub_box(&["shell", "web", "dangerous"]),
1124            ToolPolicy::InheritAll,
1125            vec!["dangerous".into()],
1126        );
1127        let defs = exec.tool_definitions_erased();
1128        assert_eq!(defs.len(), 2);
1129        assert!(!defs.iter().any(|d| d.id == "dangerous"));
1130    }
1131
1132    // ── NetworkDenyToolExecutor tests (issue #6030) ────────────────────────
1133
1134    fn bash_call(command: &str) -> ToolCall {
1135        let mut params = serde_json::Map::new();
1136        params.insert("command".into(), serde_json::Value::from(command));
1137        ToolCall {
1138            tool_id: "bash".into(),
1139            params,
1140            caller_id: None,
1141            context: None,
1142            tool_call_id: String::new(),
1143            skill_name: None,
1144        }
1145    }
1146
1147    #[tokio::test]
1148    async fn network_deny_blocks_curl() {
1149        let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1150        let res = exec
1151            .execute_tool_call_erased(&bash_call("curl https://evil.example"))
1152            .await;
1153        assert_matches!(res, Err(ToolError::Blocked { .. }));
1154    }
1155
1156    #[tokio::test]
1157    async fn network_deny_blocks_wget_and_nc() {
1158        let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1159        assert!(
1160            exec.execute_tool_call_erased(&bash_call("wget https://evil.example"))
1161                .await
1162                .is_err()
1163        );
1164        assert!(
1165            exec.execute_tool_call_erased(&bash_call("nc -l 4444"))
1166                .await
1167                .is_err()
1168        );
1169    }
1170
1171    #[tokio::test]
1172    async fn network_deny_permits_non_network_bash() {
1173        let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1174        let res = exec.execute_tool_call_erased(&bash_call("ls -la")).await;
1175        assert!(res.is_ok(), "non-network command must pass through");
1176    }
1177
1178    #[tokio::test]
1179    async fn network_deny_ignores_non_bash_tools() {
1180        let exec = NetworkDenyToolExecutor::new(stub_box(&["web"]));
1181        let call = ToolCall {
1182            tool_id: "web".into(),
1183            params: serde_json::Map::default(),
1184            caller_id: None,
1185            context: None,
1186            tool_call_id: String::new(),
1187            skill_name: None,
1188        };
1189        let res = exec.execute_tool_call_erased(&call).await;
1190        assert!(res.is_ok(), "non-bash tool calls must not be inspected");
1191    }
1192
1193    #[tokio::test]
1194    async fn network_deny_confirmed_path_also_enforces() {
1195        let exec = NetworkDenyToolExecutor::new(stub_box(&["bash"]));
1196        let res = exec
1197            .execute_tool_call_confirmed_erased(&bash_call("curl https://evil.example"))
1198            .await;
1199        assert_matches!(res, Err(ToolError::Blocked { .. }));
1200    }
1201
1202    fn tool_call(tool_id: &str) -> ToolCall {
1203        ToolCall {
1204            tool_id: tool_id.into(),
1205            params: serde_json::Map::default(),
1206            caller_id: None,
1207            context: None,
1208            tool_call_id: String::new(),
1209            skill_name: None,
1210        }
1211    }
1212
1213    #[tokio::test]
1214    async fn network_deny_blocks_web_scrape_unconditionally() {
1215        let exec = NetworkDenyToolExecutor::new(stub_box(&["web_scrape"]));
1216        let res = exec
1217            .execute_tool_call_erased(&tool_call("web_scrape"))
1218            .await;
1219        assert_matches!(res, Err(ToolError::Blocked { .. }));
1220    }
1221
1222    #[tokio::test]
1223    async fn network_deny_blocks_fetch_unconditionally() {
1224        let exec = NetworkDenyToolExecutor::new(stub_box(&["fetch"]));
1225        let res = exec.execute_tool_call_erased(&tool_call("fetch")).await;
1226        assert_matches!(res, Err(ToolError::Blocked { .. }));
1227    }
1228
1229    #[tokio::test]
1230    async fn network_deny_blocks_fetch_confirmed_path_too() {
1231        let exec = NetworkDenyToolExecutor::new(stub_box(&["fetch"]));
1232        let res = exec
1233            .execute_tool_call_confirmed_erased(&tool_call("fetch"))
1234            .await;
1235        assert_matches!(res, Err(ToolError::Blocked { .. }));
1236    }
1237
1238    #[tokio::test]
1239    async fn network_deny_blocks_web_search_unconditionally() {
1240        // Regression guard for #6358: web_search is a pure network-egress tool like
1241        // web_scrape/fetch — a NetworkScope::Deny sub-agent must not be able to reach it.
1242        let exec = NetworkDenyToolExecutor::new(stub_box(&["web_search"]));
1243        let res = exec
1244            .execute_tool_call_erased(&tool_call("web_search"))
1245            .await;
1246        assert_matches!(res, Err(ToolError::Blocked { .. }));
1247    }
1248
1249    // ── #1184: PlanModeExecutor + disallowed_tools catalog test ───────────
1250
1251    #[test]
1252    fn plan_mode_with_disallowed_excludes_from_catalog() {
1253        // FilteredToolExecutor wrapping PlanModeExecutor must exclude disallowed tools from
1254        // tool_definitions_erased(), verifying that deny-list is enforced in plan mode catalog.
1255        let inner = Arc::new(PlanModeExecutor::new(stub_box(&["shell", "web"])));
1256        let exec = FilteredToolExecutor::with_disallowed(
1257            inner,
1258            ToolPolicy::InheritAll,
1259            vec!["shell".into()],
1260        );
1261        let defs = exec.tool_definitions_erased();
1262        assert!(
1263            !defs.iter().any(|d| d.id == "shell"),
1264            "shell must be excluded from catalog"
1265        );
1266        assert!(
1267            defs.iter().any(|d| d.id == "web"),
1268            "web must remain in catalog"
1269        );
1270    }
1271
1272    // ── PlanModeExecutor tests ─────────────────────────────────────────────
1273
1274    #[tokio::test]
1275    async fn plan_mode_blocks_execute_erased() {
1276        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1277        let res = exec.execute_erased("response").await;
1278        assert!(res.is_err());
1279    }
1280
1281    #[tokio::test]
1282    async fn plan_mode_blocks_execute_confirmed_erased() {
1283        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1284        let res = exec.execute_confirmed_erased("response").await;
1285        assert!(res.is_err());
1286    }
1287
1288    #[tokio::test]
1289    async fn plan_mode_blocks_tool_call() {
1290        let exec = PlanModeExecutor::new(stub_box(&["shell"]));
1291        let call = ToolCall {
1292            tool_id: "shell".into(),
1293            params: serde_json::Map::default(),
1294            caller_id: None,
1295            context: None,
1296
1297            tool_call_id: String::new(),
1298            skill_name: None,
1299        };
1300        let res = exec.execute_tool_call_erased(&call).await;
1301        assert!(res.is_err(), "plan mode must block all tool execution");
1302    }
1303
1304    #[test]
1305    fn plan_mode_exposes_real_tool_definitions() {
1306        let exec = PlanModeExecutor::new(stub_box(&["shell", "web"]));
1307        let defs = exec.tool_definitions_erased();
1308        // Real tool catalog exposed — LLM can reference tools in its plan.
1309        assert_eq!(defs.len(), 2);
1310        assert!(defs.iter().any(|d| d.id == "shell"));
1311        assert!(defs.iter().any(|d| d.id == "web"));
1312    }
1313
1314    // ── #6019: checkpoint/speculatable/trust forwarding regression ─────────
1315
1316    /// Inner executor whose checkpoint/speculatable/trust methods return distinguishable
1317    /// non-default values, used to prove `FilteredToolExecutor` and `PlanModeExecutor`
1318    /// forward to `inner` rather than falling through to the "unsupported"/`false`
1319    /// defaults the removed trait defaults used to provide silently (#6019).
1320    struct CheckpointingStub {
1321        trust_recorded: std::sync::Mutex<Option<zeph_tools::SkillTrustLevel>>,
1322    }
1323
1324    impl ErasedToolExecutor for CheckpointingStub {
1325        fn execute_erased<'a>(
1326            &'a self,
1327            _response: &'a str,
1328        ) -> Pin<
1329            Box<
1330                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1331            >,
1332        > {
1333            Box::pin(std::future::ready(Ok(None)))
1334        }
1335
1336        fn execute_confirmed_erased<'a>(
1337            &'a self,
1338            _response: &'a str,
1339        ) -> Pin<
1340            Box<
1341                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1342            >,
1343        > {
1344            Box::pin(std::future::ready(Ok(None)))
1345        }
1346
1347        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1348            vec![]
1349        }
1350
1351        fn execute_tool_call_erased<'a>(
1352            &'a self,
1353            _call: &'a ToolCall,
1354        ) -> Pin<
1355            Box<
1356                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1357            >,
1358        > {
1359            Box::pin(std::future::ready(Ok(None)))
1360        }
1361
1362        fn execute_tool_call_confirmed_erased<'a>(
1363            &'a self,
1364            call: &'a ToolCall,
1365        ) -> Pin<
1366            Box<
1367                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1368            >,
1369        > {
1370            self.execute_tool_call_erased(call)
1371        }
1372
1373        fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1374            false
1375        }
1376
1377        fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
1378            false
1379        }
1380
1381        fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
1382            true
1383        }
1384
1385        fn set_effective_trust(&self, level: zeph_tools::SkillTrustLevel) {
1386            *self.trust_recorded.lock().unwrap() = Some(level);
1387        }
1388
1389        fn checkpoint_undo_erased(&self, n: usize) -> zeph_tools::CheckpointActionResult {
1390            zeph_tools::CheckpointActionResult {
1391                reverted_commands: n,
1392                restored: 0,
1393                deleted: 0,
1394                supported: true,
1395                message: "stub-undo".into(),
1396            }
1397        }
1398
1399        fn checkpoint_redo_erased(&self) -> zeph_tools::CheckpointActionResult {
1400            zeph_tools::CheckpointActionResult {
1401                reverted_commands: 0,
1402                restored: 0,
1403                deleted: 0,
1404                supported: true,
1405                message: "stub-redo".into(),
1406            }
1407        }
1408
1409        fn checkpoint_list_erased(&self) -> zeph_tools::CheckpointListResult {
1410            zeph_tools::CheckpointListResult {
1411                entries: vec![],
1412                redo_depth: 7,
1413                supported: true,
1414            }
1415        }
1416    }
1417
1418    fn checkpointing_stub() -> Arc<CheckpointingStub> {
1419        Arc::new(CheckpointingStub {
1420            trust_recorded: std::sync::Mutex::new(None),
1421        })
1422    }
1423
1424    #[test]
1425    fn filtered_executor_forwards_checkpoint_trio_and_speculatable() {
1426        let inner = checkpointing_stub();
1427        let exec = FilteredToolExecutor::new(Arc::clone(&inner) as _, ToolPolicy::InheritAll);
1428
1429        let undo = exec.checkpoint_undo_erased(7);
1430        assert!(
1431            undo.supported,
1432            "checkpoint_undo_erased must forward to inner"
1433        );
1434        assert_eq!(
1435            undo.reverted_commands, 7,
1436            "n must be forwarded, not hardcoded"
1437        );
1438        assert!(
1439            exec.checkpoint_redo_erased().supported,
1440            "checkpoint_redo_erased must forward to inner"
1441        );
1442        assert_eq!(
1443            exec.checkpoint_list_erased().redo_depth,
1444            7,
1445            "checkpoint_list_erased must forward to inner"
1446        );
1447        assert!(
1448            exec.is_tool_speculatable_erased("anything"),
1449            "is_tool_speculatable_erased must forward to inner, not default to false"
1450        );
1451    }
1452
1453    #[tokio::test]
1454    async fn filtered_executor_confirmed_erased_still_enforces_policy() {
1455        // execute_tool_call_confirmed_erased must delegate through execute_tool_call_erased
1456        // (preserving the policy check), not blind-forward straight to inner.
1457        let inner = checkpointing_stub();
1458        let exec = FilteredToolExecutor::with_disallowed(
1459            Arc::clone(&inner) as _,
1460            ToolPolicy::InheritAll,
1461            vec!["blocked_tool".into()],
1462        );
1463        let call = ToolCall {
1464            tool_id: "blocked_tool".into(),
1465            params: serde_json::Map::default(),
1466            caller_id: None,
1467            context: None,
1468
1469            tool_call_id: String::new(),
1470            skill_name: None,
1471        };
1472        // confirmed path must still enforce the denylist, not bypass it
1473        let res = exec.execute_tool_call_confirmed_erased(&call).await;
1474        assert_matches!(res, Err(ToolError::Blocked { .. }));
1475    }
1476
1477    #[test]
1478    fn plan_mode_forwards_checkpoint_trio_and_speculatable() {
1479        let inner = checkpointing_stub();
1480        let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1481
1482        let undo = exec.checkpoint_undo_erased(3);
1483        assert!(
1484            undo.supported,
1485            "checkpoint_undo_erased must forward to inner"
1486        );
1487        assert_eq!(undo.reverted_commands, 3);
1488        assert!(exec.checkpoint_redo_erased().supported);
1489        assert_eq!(exec.checkpoint_list_erased().redo_depth, 7);
1490        assert!(
1491            exec.is_tool_speculatable_erased("anything"),
1492            "is_tool_speculatable_erased must forward to inner, not default to false"
1493        );
1494    }
1495
1496    #[test]
1497    fn plan_mode_forwards_set_effective_trust() {
1498        let inner = checkpointing_stub();
1499        let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1500        exec.set_effective_trust(zeph_tools::SkillTrustLevel::Quarantined);
1501        assert_eq!(
1502            *inner.trust_recorded.lock().unwrap(),
1503            Some(zeph_tools::SkillTrustLevel::Quarantined),
1504            "set_effective_trust must forward to inner"
1505        );
1506    }
1507
1508    #[tokio::test]
1509    async fn plan_mode_confirmed_erased_still_blocks_execution() {
1510        let inner = checkpointing_stub();
1511        let exec = PlanModeExecutor::new(Arc::clone(&inner) as _);
1512        let call = ToolCall {
1513            tool_id: "shell".into(),
1514            params: serde_json::Map::default(),
1515            caller_id: None,
1516            context: None,
1517
1518            tool_call_id: String::new(),
1519            skill_name: None,
1520        };
1521        let res = exec.execute_tool_call_confirmed_erased(&call).await;
1522        assert!(
1523            res.is_err(),
1524            "plan mode must block confirmed execution too, not just unconfirmed"
1525        );
1526    }
1527
1528    // ── normalize_tool_id tests ────────────────────────────────────────────
1529
1530    #[test]
1531    fn normalize_tool_id_lowercases() {
1532        assert_eq!(normalize_tool_id("Read"), "read");
1533        assert_eq!(normalize_tool_id("Write"), "write");
1534        assert_eq!(normalize_tool_id("Edit"), "edit");
1535    }
1536
1537    #[test]
1538    fn normalize_tool_id_strips_args() {
1539        assert_eq!(normalize_tool_id("Bash(cargo *)"), "bash");
1540        assert_eq!(normalize_tool_id("Bash(git *)"), "bash");
1541        assert_eq!(normalize_tool_id("bash"), "bash");
1542    }
1543
1544    #[test]
1545    fn allow_list_pascal_case_permits_lowercase_runtime_id() {
1546        let exec = FilteredToolExecutor::new(
1547            stub_box(&["read", "write", "bash"]),
1548            ToolPolicy::AllowList(vec!["Read".into(), "Write".into(), "Bash(cargo *)".into()]),
1549        );
1550        // Runtime IDs are lowercase; policy entries use PascalCase / argument form.
1551        assert!(exec.is_allowed("read"));
1552        assert!(exec.is_allowed("write"));
1553        assert!(exec.is_allowed("bash"));
1554        assert!(!exec.is_allowed("web"));
1555        // tool_definitions_erased must also filter correctly.
1556        let defs = exec.tool_definitions_erased();
1557        assert_eq!(
1558            defs.len(),
1559            3,
1560            "read, write, bash must all appear in catalog"
1561        );
1562    }
1563
1564    // ── filter_skills tests ────────────────────────────────────────────────
1565
1566    #[test]
1567    fn filter_skills_empty_registry_returns_empty() {
1568        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1569        let filter = SkillFilter::default();
1570        let result = filter_skills(&registry, &filter).unwrap();
1571        assert!(result.is_empty());
1572    }
1573
1574    #[test]
1575    fn filter_skills_empty_include_passes_all() {
1576        // Empty include list means "include everything".
1577        // With an empty registry, result is still empty — logic is correct.
1578        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1579        let filter = SkillFilter {
1580            include: vec![],
1581            exclude: vec![],
1582        };
1583        let result = filter_skills(&registry, &filter).unwrap();
1584        assert!(result.is_empty());
1585    }
1586
1587    #[test]
1588    fn filter_skills_double_star_pattern_is_error() {
1589        let registry = zeph_skills::registry::SkillRegistry::load(&[] as &[&str]);
1590        let filter = SkillFilter {
1591            include: vec!["**".into()],
1592            exclude: vec![],
1593        };
1594        let err = filter_skills(&registry, &filter).unwrap_err();
1595        assert_matches!(err, SubAgentError::Invalid(_));
1596    }
1597
1598    mod proptest_glob {
1599        use proptest::prelude::*;
1600
1601        use super::{compile_glob, glob_match};
1602
1603        proptest! {
1604            #![proptest_config(proptest::test_runner::Config::with_cases(500))]
1605
1606            /// glob_match must never panic for any valid (non-**) pattern and any name string.
1607            #[test]
1608            fn glob_match_never_panics(
1609                pattern in "[a-z*-]{1,10}",
1610                name in "[a-z-]{0,15}",
1611            ) {
1612                // Skip patterns with ** (those are compile errors by design).
1613                if !pattern.contains("**")
1614                    && let Ok(p) = compile_glob(&pattern)
1615                {
1616                    let _ = glob_match(&p, &name);
1617                }
1618            }
1619
1620            /// A literal pattern (no `*`) must match only exact strings.
1621            #[test]
1622            fn glob_literal_matches_only_exact(
1623                name in "[a-z-]{1,10}",
1624            ) {
1625                // A literal pattern equal to `name` must match.
1626                let p = compile_glob(&name).unwrap();
1627                prop_assert!(glob_match(&p, &name));
1628
1629                // A different name must not match.
1630                let other = format!("{name}-x");
1631                prop_assert!(!glob_match(&p, &other));
1632            }
1633
1634            /// The `*` pattern must match every input.
1635            #[test]
1636            fn glob_star_matches_everything(name in ".*") {
1637                let p = compile_glob("*").unwrap();
1638                prop_assert!(glob_match(&p, &name));
1639            }
1640        }
1641    }
1642}