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