Skip to main content

zeph_tools/
scope.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `ScopedToolExecutor`: config-driven capability scoping wrapper.
5//!
6//! Wraps any `ToolExecutor` and filters both `tool_definitions()` (LLM tool list) and
7//! `execute_tool_call()` (dispatch path) to an operator-configured allow-list of
8//! fully-qualified tool ids.
9//!
10//! # Wiring order
11//!
12//! ```text
13//! ScopedToolExecutor          ← outermost (this crate)
14//!   → PolicyGateExecutor
15//!       → TrustGateExecutor
16//!           → CompositeExecutor
17//!               → ToolFilter, AuditedExecutor, ...
18//! ```
19//!
20//! `ScopedToolExecutor` is placed outside `PolicyGateExecutor` so an out-of-scope call
21//! short-circuits before policy evaluation.
22//!
23//! # Tool-id namespacing
24//!
25//! All tool ids MUST carry a namespace prefix before scope resolution:
26//!
27//! | Source | Prefix |
28//! |---|---|
29//! | Built-in executors | `builtin:` |
30//! | Skill-defined tools | `skill:<name>/` |
31//! | MCP tools | `mcp:<server_id>/` |
32//! | ACP / A2A proxied tools | `acp:<peer>/` / `a2a:<peer>/` |
33//!
34//! Built-in executors register tools with unqualified ids (`"bash"`, `"read"`, etc.).
35//! At the scope boundary these are automatically normalised to `builtin:<id>` so that
36//! patterns like `builtin:*` or `builtin:bash` resolve correctly.  The caller of
37//! `build_scoped_executor` (see `runner.rs`) is responsible for pre-qualifying registry
38//! ids before passing them in.
39//!
40//! # Pattern strictness
41//!
42//! - `builtin:` / `skill:` globs: strict — zero-match is `ScopeError::DeadPattern`.
43//! - `mcp:` / `acp:` / `a2a:` globs: provisional — zero-match is
44//!   `ScopeWarning::ProvisionalDeadPattern` (re-resolved on dynamic registration).
45//! - A glob matching the **entire** registry without an explicit `general` opt-in is
46//!   `ScopeError::AccidentallyFull`.
47
48use std::collections::{HashMap, HashSet};
49use std::sync::Arc;
50
51use arc_swap::ArcSwap;
52use globset::{Glob, GlobSet, GlobSetBuilder};
53use tracing::warn;
54
55use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
56use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
57use crate::registry::ToolDef;
58use zeph_config::{CapabilityScopesConfig, PatternStrictness};
59
60// ── Errors & warnings ─────────────────────────────────────────────────────────
61
62#[non_exhaustive]
63/// Fatal startup error emitted when a scope configuration is invalid.
64#[derive(Debug, thiserror::Error)]
65pub enum ScopeError {
66    /// A glob pattern in a strict namespace matched zero registered tool ids.
67    #[error("scope '{scope}': pattern '{pattern}' matched zero registered tools (dead pattern)")]
68    DeadPattern { scope: String, pattern: String },
69
70    /// A glob pattern expanded to the entire tool registry without an explicit opt-in.
71    #[error(
72        "scope '{scope}': pattern '{pattern}' matches the entire registry; use default_scope=\"general\" to opt in"
73    )]
74    AccidentallyFull { scope: String, pattern: String },
75
76    /// An executor registered a tool id without a namespace prefix.
77    #[error("tool id '{id}' has no namespace prefix (expected '<namespace>:<id>')")]
78    UnqualifiedId { id: String },
79
80    /// A glob pattern could not be compiled.
81    #[error("scope '{scope}': invalid glob pattern '{pattern}': {source}")]
82    InvalidPattern {
83        scope: String,
84        pattern: String,
85        #[source]
86        source: globset::Error,
87    },
88}
89
90/// Non-fatal warning emitted for provisional-namespace zero-match patterns.
91#[derive(Debug)]
92pub struct ScopeWarning {
93    /// The scope name containing the unresolved pattern.
94    pub scope: String,
95    /// The glob pattern that matched zero ids at build time.
96    pub pattern: String,
97}
98
99// ── ToolScope ─────────────────────────────────────────────────────────────────
100
101/// Materialised tool scope: a pre-compiled allow-list of fully-qualified tool ids.
102///
103/// At agent build time, glob patterns are resolved against the registered tool set
104/// and stored as a `HashSet<String>`. Runtime admission is an O(1) lookup.
105#[derive(Debug, Clone)]
106pub struct ToolScope {
107    /// Identifier of this scope (task-type name).
108    pub task_type: Option<String>,
109    /// Expanded, materialised set of fully-qualified tool ids.
110    admitted: HashSet<String>,
111    /// `true` for the `general` default-scope only; admits every id without lookup.
112    is_full: bool,
113    /// Original patterns, kept for re-resolution when new tools are registered dynamically.
114    patterns: Vec<String>,
115}
116
117impl ToolScope {
118    /// The identity scope: admits every tool id. Used for the `general` default scope.
119    ///
120    /// # Examples
121    ///
122    /// ```rust
123    /// use zeph_tools::scope::ToolScope;
124    ///
125    /// let scope = ToolScope::full();
126    /// assert!(scope.admits("builtin:shell"));
127    /// assert!(scope.admits("mcp:any_server/any_tool"));
128    /// ```
129    #[must_use]
130    pub fn full() -> Self {
131        Self {
132            task_type: None,
133            admitted: HashSet::new(),
134            is_full: true,
135            patterns: vec!["*".to_owned()],
136        }
137    }
138
139    /// The deny-all scope: admits no tool at all.
140    ///
141    /// Used as a fail-**closed** fallback when scope compilation fails for a single
142    /// session/connection (spec-050 FR-CG-005/NFR-CG-004: a misconfigured
143    /// `[security.capability_scopes]` entry must never silently degrade to "no scoping at
144    /// all" — that would be fail-**open** for a security control the operator explicitly
145    /// enabled). Prefer this over falling back to the unscoped inner executor.
146    ///
147    /// # Examples
148    ///
149    /// ```rust
150    /// use zeph_tools::scope::ToolScope;
151    ///
152    /// let scope = ToolScope::empty();
153    /// assert!(!scope.admits("builtin:shell"));
154    /// assert!(!scope.admits("mcp:any_server/any_tool"));
155    /// ```
156    #[must_use]
157    pub fn empty() -> Self {
158        Self {
159            task_type: None,
160            admitted: HashSet::new(),
161            is_full: false,
162            patterns: Vec::new(),
163        }
164    }
165
166    /// Compile a scope from glob patterns against the materialised registry.
167    ///
168    /// # Errors
169    ///
170    /// Returns `ScopeError::DeadPattern` when a strict-namespace glob matches zero ids,
171    /// `ScopeError::AccidentallyFull` when a pattern expands to the entire registry without
172    /// an explicit `general` opt-in, or `ScopeError::InvalidPattern` on invalid glob syntax.
173    pub fn try_compile<S: std::hash::BuildHasher>(
174        task_type: impl Into<String>,
175        patterns: &[String],
176        registry_ids: &HashSet<String, S>,
177        strictness: PatternStrictness,
178        is_general_scope: bool,
179    ) -> Result<(Self, Vec<ScopeWarning>), ScopeError> {
180        let task_type_str = task_type.into();
181        let mut admitted = HashSet::new();
182        let mut warnings = Vec::new();
183
184        for pattern in patterns {
185            // Validate that glob compiles.
186            let glob = Glob::new(pattern).map_err(|e| ScopeError::InvalidPattern {
187                scope: task_type_str.clone(),
188                pattern: pattern.clone(),
189                source: e,
190            })?;
191
192            let mut builder = GlobSetBuilder::new();
193            builder.add(glob);
194            let glob_set: GlobSet = builder.build().map_err(|e| ScopeError::InvalidPattern {
195                scope: task_type_str.clone(),
196                pattern: pattern.clone(),
197                source: e,
198            })?;
199
200            let matched: HashSet<String> = registry_ids
201                .iter()
202                .filter(|id| glob_set.is_match(id.as_str()))
203                .cloned()
204                .collect();
205
206            // Check for accidentally-full expansion (unless this is the general scope).
207            if !is_general_scope && matched.len() == registry_ids.len() && !registry_ids.is_empty()
208            {
209                return Err(ScopeError::AccidentallyFull {
210                    scope: task_type_str,
211                    pattern: pattern.clone(),
212                });
213            }
214
215            if matched.is_empty() {
216                let is_strict = is_strict_pattern(pattern, strictness);
217                if is_strict {
218                    return Err(ScopeError::DeadPattern {
219                        scope: task_type_str,
220                        pattern: pattern.clone(),
221                    });
222                }
223                warnings.push(ScopeWarning {
224                    scope: task_type_str.clone(),
225                    pattern: pattern.clone(),
226                });
227            }
228
229            admitted.extend(matched);
230        }
231
232        Ok((
233            Self {
234                task_type: Some(task_type_str),
235                admitted,
236                is_full: false,
237                patterns: patterns.to_vec(),
238            },
239            warnings,
240        ))
241    }
242
243    /// Returns `true` when the given fully-qualified tool id is admitted by this scope.
244    ///
245    /// # Examples
246    ///
247    /// ```rust
248    /// use zeph_tools::scope::ToolScope;
249    ///
250    /// let scope = ToolScope::full();
251    /// assert!(scope.admits("builtin:shell"));
252    /// ```
253    #[must_use]
254    pub fn admits(&self, qualified_tool_id: &str) -> bool {
255        self.is_full || self.admitted.contains(qualified_tool_id)
256    }
257
258    /// Returns the list of admitted tool ids (excluding `full` scopes).
259    ///
260    /// Useful for `/scope list` output and the `scope_at_definition` audit field.
261    #[must_use]
262    pub fn admitted_ids(&self) -> Vec<&str> {
263        self.admitted.iter().map(String::as_str).collect()
264    }
265
266    /// The raw glob patterns this scope was compiled from (for re-resolution).
267    #[must_use]
268    pub fn patterns(&self) -> &[String] {
269        &self.patterns
270    }
271
272    /// Re-resolve the scope against a new registry (called on dynamic tool registration).
273    ///
274    /// Returns a new `ToolScope` with the updated admit set; warnings are logged but not
275    /// returned (non-fatal for provisional namespaces).
276    #[must_use]
277    pub fn re_resolve<S: std::hash::BuildHasher>(&self, registry_ids: &HashSet<String, S>) -> Self {
278        let task_type_str = self
279            .task_type
280            .clone()
281            .unwrap_or_else(|| "<unknown>".to_owned());
282        let mut admitted = HashSet::new();
283        for pattern in &self.patterns {
284            let Ok(glob) = Glob::new(pattern) else {
285                warn!(scope = %task_type_str, pattern, "re-resolve: invalid glob, skipping");
286                continue;
287            };
288            let mut builder = GlobSetBuilder::new();
289            builder.add(glob);
290            let Ok(glob_set) = builder.build() else {
291                continue;
292            };
293            let matched: HashSet<String> = registry_ids
294                .iter()
295                .filter(|id| glob_set.is_match(id.as_str()))
296                .cloned()
297                .collect();
298            admitted.extend(matched);
299        }
300        Self {
301            task_type: self.task_type.clone(),
302            admitted,
303            is_full: false,
304            patterns: self.patterns.clone(),
305        }
306    }
307}
308
309/// Returns `true` when the pattern targets a strict namespace (`builtin:` or `skill:`).
310fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
311    match strictness {
312        PatternStrictness::Strict => true,
313        PatternStrictness::ProvisionalForDynamicNamespaces => {
314            // Strict for builtin: and skill:; provisional for mcp:, acp:, a2a:
315            pattern.starts_with("builtin:") || pattern.starts_with("skill:")
316        }
317        _ => false,
318    }
319}
320
321// ── ScopedToolExecutor ────────────────────────────────────────────────────────
322
323/// Wraps any `ToolExecutor` and enforces a capability scope on both tool listing and dispatch.
324///
325/// # Type parameter
326///
327/// `E` is the inner executor (e.g., `PolicyGateExecutor<TrustGateExecutor<CompositeExecutor>>`).
328///
329/// # Examples
330///
331/// ```rust,no_run
332/// use std::collections::HashSet;
333/// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
334/// use zeph_tools::{ToolExecutor, ToolCall};
335/// use zeph_common::ToolName;
336///
337/// // Build a full (no-op) scope — identity, admits everything.
338/// let scope = ToolScope::full();
339///
340/// // Wrap some inner executor (omitted for brevity).
341/// struct MockExecutor;
342/// impl ToolExecutor for MockExecutor {
343///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
344///     zeph_tools::tool_executor_no_inner_defaults!();
345/// }
346/// let executor = ScopedToolExecutor::new(MockExecutor, scope);
347/// ```
348pub struct ScopedToolExecutor<E: ToolExecutor> {
349    inner: E,
350    /// Atomically swappable active scope. Swapped via `set_scope()`.
351    scope: ArcSwap<ToolScope>,
352    /// Named scope map for task-type lookup.
353    scopes: HashMap<String, Arc<ToolScope>>,
354    /// Name of the scope currently surfaced to the LLM (captured at `tool_definitions()` time).
355    scope_at_definition: parking_lot::Mutex<Option<String>>,
356    /// Optional shared queue — `OutOfScope` signal codes pushed here; drained by `begin_turn()`.
357    signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
358    /// Optional audit logger — `out_of_scope` entries emitted on every rejection.
359    audit: Option<Arc<AuditLogger>>,
360}
361
362impl<E: ToolExecutor> ScopedToolExecutor<E> {
363    /// Create a new `ScopedToolExecutor` with the given initial scope.
364    ///
365    /// # Examples
366    ///
367    /// ```rust,no_run
368    /// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
369    ///
370    /// struct Noop;
371    /// impl zeph_tools::ToolExecutor for Noop {
372    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
373    ///     zeph_tools::tool_executor_no_inner_defaults!();
374    /// }
375    /// let executor = ScopedToolExecutor::new(Noop, ToolScope::full());
376    /// ```
377    #[must_use]
378    pub fn new(inner: E, initial_scope: ToolScope) -> Self {
379        Self {
380            inner,
381            scope: ArcSwap::from_pointee(initial_scope),
382            scopes: HashMap::new(),
383            scope_at_definition: parking_lot::Mutex::new(None),
384            signal_queue: None,
385            audit: None,
386        }
387    }
388
389    /// Attach an audit logger so every `OutOfScope` rejection writes an audit entry.
390    #[must_use]
391    pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
392        self.audit = Some(audit);
393        self
394    }
395
396    /// Attach a shared signal queue so `OutOfScope` rejections are recorded in the sentinel.
397    #[must_use]
398    pub fn with_signal_queue(mut self, queue: crate::policy_gate::RiskSignalQueue) -> Self {
399        self.signal_queue = Some(queue);
400        self
401    }
402
403    /// Register a named scope for use with `set_scope_for_task`.
404    pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
405        self.scopes.insert(name.into(), Arc::new(scope));
406    }
407
408    /// Switch the active scope by task-type name. Returns `false` when the name is not found.
409    pub fn set_scope_for_task(&self, task_type: &str) -> bool {
410        if let Some(scope) = self.scopes.get(task_type) {
411            self.scope.store(Arc::clone(scope));
412            true
413        } else {
414            false
415        }
416    }
417
418    /// Replace the active scope with the given one directly.
419    pub fn set_scope(&self, scope: ToolScope) {
420        self.scope.store(Arc::new(scope));
421    }
422
423    /// Return the list of tool ids admitted by the scope for `task_type`.
424    ///
425    /// Returns `None` when `task_type` is not registered.
426    ///
427    /// # Examples
428    ///
429    /// ```rust,no_run
430    /// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
431    ///
432    /// struct Noop;
433    /// impl zeph_tools::ToolExecutor for Noop {
434    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
435    ///     zeph_tools::tool_executor_no_inner_defaults!();
436    /// }
437    /// let mut executor = ScopedToolExecutor::new(Noop, ToolScope::full());
438    /// // scope_for_task returns None for unregistered task types
439    /// assert!(executor.scope_for_task("unknown").is_none());
440    /// ```
441    #[must_use]
442    pub fn scope_for_task(&self, task_type: &str) -> Option<Vec<String>> {
443        self.scopes.get(task_type).map(|s| {
444            if s.is_full {
445                vec!["*".to_owned()]
446            } else {
447                s.admitted_ids().iter().map(|s| (*s).to_owned()).collect()
448            }
449        })
450    }
451
452    /// Name of the active scope at the last `tool_definitions()` call (for audit).
453    #[must_use]
454    pub fn scope_at_definition_name(&self) -> Option<String> {
455        self.scope_at_definition.lock().clone()
456    }
457
458    /// Name of the currently active scope (for audit at dispatch time).
459    #[must_use]
460    pub fn active_scope_name(&self) -> Option<String> {
461        self.scope.load().task_type.clone()
462    }
463}
464
465impl<E: ToolExecutor> ToolExecutor for ScopedToolExecutor<E> {
466    // CRIT-03 carve-out: legacy fenced-block dispatch path is not scoped (mirrors PolicyGate).
467    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
468        self.inner.execute(response).await
469    }
470
471    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
472        self.inner.execute_confirmed(response).await
473    }
474
475    /// Return the filtered tool definitions visible to the LLM under the active scope.
476    ///
477    /// Captures the active scope name into `scope_at_definition` for audit use.
478    fn tool_definitions(&self) -> Vec<ToolDef> {
479        let scope = self.scope.load();
480        self.scope_at_definition.lock().clone_from(&scope.task_type);
481        self.inner
482            .tool_definitions()
483            .into_iter()
484            .filter(|d| {
485                let id = d.id.as_ref();
486                let scope_id: String;
487                let qualified = if id.contains(':') {
488                    id
489                } else {
490                    scope_id = format!("builtin:{id}");
491                    scope_id.as_str()
492                };
493                scope.admits(qualified)
494            })
495            .collect()
496    }
497
498    /// Execute a structured tool call, rejecting out-of-scope ids before any side-effect.
499    ///
500    /// Returns `ToolError::OutOfScope` when the tool id is not in the active scope.
501    /// The audit log entry at the call site must carry `error_category = "out_of_scope"`.
502    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
503        let scope = self.scope.load();
504        let tool_id = call.tool_id.as_str();
505        // Built-in tools dispatch with unqualified ids ("bash", "read", etc.).
506        // Synthesize the "builtin:" prefix at the scope boundary so the admitted set
507        // (which contains "builtin:bash" etc.) resolves correctly.
508        let qualified_id: String;
509        let scope_id = if tool_id.contains(':') {
510            tool_id
511        } else {
512            qualified_id = format!("builtin:{tool_id}");
513            qualified_id.as_str()
514        };
515
516        if !scope.admits(scope_id) {
517            let scope_name = scope.task_type.clone();
518            let scope_def = self.scope_at_definition.lock().clone();
519            tracing::debug!(
520                tool_id,
521                scope = ?scope_name,
522                "ScopedToolExecutor: out-of-scope rejection"
523            );
524            // Signal code 3 = OutOfScope (matches RiskSignal::OutOfScope in zeph-core).
525            if let Some(ref q) = self.signal_queue {
526                q.lock().push(3);
527            }
528            // F4: emit audit entry with error_category = "out_of_scope".
529            if let Some(ref audit) = self.audit {
530                let entry = AuditEntry {
531                    source_kind: None,
532                    trust_level: None,
533                    timestamp: chrono_now(),
534                    tool: call.tool_id.clone(),
535                    command: String::new(),
536                    result: AuditResult::Blocked {
537                        reason: "out_of_scope".to_owned(),
538                    },
539                    duration_ms: 0,
540                    error_category: Some("out_of_scope".to_owned()),
541                    error_domain: Some("security".to_owned()),
542                    error_phase: None,
543                    claim_source: None,
544                    mcp_server_id: None,
545                    injection_flagged: false,
546                    embedding_anomalous: false,
547                    cross_boundary_mcp_to_acp: false,
548                    adversarial_policy_decision: None,
549                    exit_code: None,
550                    truncated: false,
551                    caller_id: call.caller_id.clone(),
552                    skill_name: call.skill_name.clone(),
553                    policy_match: None,
554                    correlation_id: None,
555                    vigil_risk: None,
556                    execution_env: None,
557                    resolved_cwd: None,
558                    scope_at_definition: scope_def,
559                    scope_at_dispatch: scope_name,
560                };
561                audit.log(&entry).await;
562            }
563            return Err(ToolError::OutOfScope {
564                tool_id: tool_id.to_owned(),
565                task_type: scope.task_type.clone(),
566            });
567        }
568
569        self.inner.execute_tool_call(call).await
570    }
571
572    async fn execute_tool_call_confirmed(
573        &self,
574        call: &ToolCall,
575    ) -> Result<Option<ToolOutput>, ToolError> {
576        let scope = self.scope.load();
577        let tool_id = call.tool_id.as_str();
578        let qualified_id: String;
579        let scope_id = if tool_id.contains(':') {
580            tool_id
581        } else {
582            qualified_id = format!("builtin:{tool_id}");
583            qualified_id.as_str()
584        };
585        if !scope.admits(scope_id) {
586            let scope_name = scope.task_type.clone();
587            let scope_def = self.scope_at_definition.lock().clone();
588            if let Some(ref q) = self.signal_queue {
589                q.lock().push(3);
590            }
591            if let Some(ref audit) = self.audit {
592                let entry = AuditEntry {
593                    source_kind: None,
594                    trust_level: None,
595                    timestamp: chrono_now(),
596                    tool: call.tool_id.clone(),
597                    command: String::new(),
598                    result: AuditResult::Blocked {
599                        reason: "out_of_scope".to_owned(),
600                    },
601                    duration_ms: 0,
602                    error_category: Some("out_of_scope".to_owned()),
603                    error_domain: Some("security".to_owned()),
604                    error_phase: None,
605                    claim_source: None,
606                    mcp_server_id: None,
607                    injection_flagged: false,
608                    embedding_anomalous: false,
609                    cross_boundary_mcp_to_acp: false,
610                    adversarial_policy_decision: None,
611                    exit_code: None,
612                    truncated: false,
613                    caller_id: call.caller_id.clone(),
614                    skill_name: call.skill_name.clone(),
615                    policy_match: None,
616                    correlation_id: None,
617                    vigil_risk: None,
618                    execution_env: None,
619                    resolved_cwd: None,
620                    scope_at_definition: scope_def,
621                    scope_at_dispatch: scope_name,
622                };
623                audit.log(&entry).await;
624            }
625            return Err(ToolError::OutOfScope {
626                tool_id: tool_id.to_owned(),
627                task_type: scope.task_type.clone(),
628            });
629        }
630        self.inner.execute_tool_call_confirmed(call).await
631    }
632
633    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
634        self.inner.set_skill_env(env);
635    }
636
637    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
638        self.inner.set_effective_trust(level);
639    }
640
641    fn is_tool_retryable(&self, tool_id: &str) -> bool {
642        self.inner.is_tool_retryable(tool_id)
643    }
644
645    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
646        self.inner.is_tool_speculatable(tool_id)
647    }
648
649    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
650        self.inner.checkpoint_undo(n)
651    }
652
653    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
654        self.inner.checkpoint_redo()
655    }
656
657    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
658        self.inner.checkpoint_list()
659    }
660
661    fn requires_confirmation(&self, call: &ToolCall) -> bool {
662        self.inner.requires_confirmation(call)
663    }
664}
665
666// ── Config-driven builder ──────────────────────────────────────────────────────
667
668/// Build a `ScopedToolExecutor` from a `CapabilityScopesConfig` and a registered tool set.
669///
670/// Returns a fatal `ScopeError` when any strict-namespace pattern matches zero tools.
671/// Emits `ScopeWarning` entries for provisional-namespace zero-match patterns.
672///
673/// # Errors
674///
675/// Returns `ScopeError` when scope configuration is invalid (dead patterns, accidental-full).
676///
677/// # Examples
678///
679/// ```rust,no_run
680/// use std::collections::HashSet;
681/// use zeph_config::CapabilityScopesConfig;
682/// use zeph_tools::scope::build_scoped_executor;
683///
684/// struct Noop;
685/// impl zeph_tools::ToolExecutor for Noop {
686///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
687///     zeph_tools::tool_executor_no_inner_defaults!();
688/// }
689///
690/// let cfg = CapabilityScopesConfig::default();
691/// let registry: HashSet<String> = HashSet::new();
692/// let executor = build_scoped_executor(Noop, &cfg, &registry).expect("build failed");
693/// ```
694pub fn build_scoped_executor<E: ToolExecutor, S: std::hash::BuildHasher>(
695    inner: E,
696    cfg: &CapabilityScopesConfig,
697    registry_ids: &HashSet<String, S>,
698) -> Result<ScopedToolExecutor<E>, ScopeError> {
699    let default_scope_name = &cfg.default_scope;
700    let strictness = cfg.pattern_strictness;
701
702    // The default initial scope is full (no-op) unless a named default_scope is configured.
703    let initial_scope = ToolScope::full();
704    let mut executor = ScopedToolExecutor::new(inner, initial_scope);
705
706    for (task_type, scope_cfg) in &cfg.scopes {
707        let is_general = task_type == default_scope_name;
708        let (scope, warnings) = ToolScope::try_compile(
709            task_type.clone(),
710            &scope_cfg.patterns,
711            registry_ids,
712            strictness,
713            is_general,
714        )?;
715        for w in &warnings {
716            warn!(
717                scope = %w.scope,
718                pattern = %w.pattern,
719                "capability scope: provisional zero-match pattern (will re-resolve on dynamic registration)"
720            );
721        }
722        executor.register_scope(task_type.clone(), scope);
723    }
724
725    // If a default_scope is configured and registered, activate it.
726    if cfg.scopes.contains_key(default_scope_name.as_str()) {
727        executor.set_scope_for_task(default_scope_name);
728    }
729
730    Ok(executor)
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::executor::ToolCall;
737    use crate::registry::{InvocationHint, ToolDef};
738    use std::assert_matches;
739    use zeph_common::ToolName;
740    use zeph_config::{CapabilityScopesConfig, PatternStrictness, ScopeConfig};
741
742    fn make_registry(ids: &[&str]) -> HashSet<String> {
743        ids.iter().map(|s| (*s).to_owned()).collect()
744    }
745
746    struct NullExecutor {
747        defs: Vec<ToolDef>,
748    }
749
750    impl ToolExecutor for NullExecutor {
751        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
752            Ok(None)
753        }
754
755        fn tool_definitions(&self) -> Vec<ToolDef> {
756            self.defs.clone()
757        }
758
759        async fn execute_tool_call(
760            &self,
761            call: &ToolCall,
762        ) -> Result<Option<ToolOutput>, ToolError> {
763            Ok(Some(ToolOutput {
764                tool_name: call.tool_id.clone(),
765                summary: "ok".to_owned(),
766                blocks_executed: 1,
767                filter_stats: None,
768                diff: None,
769                streamed: false,
770                terminal_id: None,
771                locations: None,
772                raw_response: None,
773                claim_source: None,
774                ..Default::default()
775            }))
776        }
777
778        crate::tool_executor_no_inner_defaults!();
779    }
780
781    struct CheckpointingExecutor;
782
783    impl ToolExecutor for CheckpointingExecutor {
784        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
785            Ok(None)
786        }
787        fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
788            crate::executor::CheckpointActionResult {
789                supported: true,
790                message: "stub".into(),
791                reverted_commands: n,
792                ..Default::default()
793            }
794        }
795        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
796            crate::executor::CheckpointActionResult {
797                supported: true,
798                message: "stub".into(),
799                ..Default::default()
800            }
801        }
802        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
803            crate::executor::CheckpointListResult {
804                supported: true,
805                ..Default::default()
806            }
807        }
808        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
809            true
810        }
811        async fn execute_tool_call_confirmed(
812            &self,
813            call: &ToolCall,
814        ) -> Result<Option<ToolOutput>, ToolError> {
815            self.execute_tool_call(call).await
816        }
817        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
818            false
819        }
820    }
821
822    fn null_def(id: &str) -> ToolDef {
823        ToolDef {
824            id: id.to_owned().into(),
825            description: "test tool".into(),
826            schema: schemars::schema_for!(String),
827            invocation: InvocationHint::ToolCall,
828            output_schema: None,
829            server_id: None,
830        }
831    }
832
833    fn make_call(tool_id: &str) -> ToolCall {
834        ToolCall {
835            tool_id: ToolName::new(tool_id),
836            params: serde_json::Map::new(),
837            caller_id: None,
838            context: None,
839
840            tool_call_id: String::new(),
841            skill_name: None,
842        }
843    }
844
845    #[test]
846    fn full_scope_admits_everything() {
847        let scope = ToolScope::full();
848        assert!(scope.admits("builtin:shell"));
849        assert!(scope.admits("mcp:server/tool"));
850        assert!(scope.admits("builtin:read"));
851    }
852
853    #[test]
854    fn compiled_scope_admits_only_matched() {
855        let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
856        let patterns = vec!["builtin:read".to_owned()];
857        let (scope, warnings) = ToolScope::try_compile(
858            "narrow",
859            &patterns,
860            &registry,
861            PatternStrictness::Strict,
862            false,
863        )
864        .unwrap();
865        assert!(warnings.is_empty());
866        assert!(scope.admits("builtin:read"));
867        assert!(!scope.admits("builtin:shell"));
868        assert!(!scope.admits("builtin:write"));
869    }
870
871    #[test]
872    fn dead_pattern_strict_returns_error() {
873        let registry = make_registry(&["builtin:shell"]);
874        let patterns = vec!["builtin:nonexistent".to_owned()];
875        let result = ToolScope::try_compile(
876            "test",
877            &patterns,
878            &registry,
879            PatternStrictness::Strict,
880            false,
881        );
882        assert!(
883            matches!(result, Err(ScopeError::DeadPattern { .. })),
884            "expected DeadPattern, got {result:?}"
885        );
886    }
887
888    #[test]
889    fn dead_pattern_provisional_returns_warning() {
890        let registry = make_registry(&["builtin:shell"]);
891        let patterns = vec!["mcp:server/nonexistent".to_owned()];
892        let result = ToolScope::try_compile(
893            "test",
894            &patterns,
895            &registry,
896            PatternStrictness::ProvisionalForDynamicNamespaces,
897            false,
898        );
899        assert!(result.is_ok());
900        let (_, warnings) = result.unwrap();
901        assert_eq!(warnings.len(), 1);
902    }
903
904    #[test]
905    fn accidentally_full_pattern_returns_error() {
906        let registry = make_registry(&["builtin:shell", "builtin:read"]);
907        let patterns = vec!["*".to_owned()];
908        let result = ToolScope::try_compile(
909            "test",
910            &patterns,
911            &registry,
912            PatternStrictness::Strict,
913            false, // not general scope
914        );
915        assert!(
916            matches!(result, Err(ScopeError::AccidentallyFull { .. })),
917            "expected AccidentallyFull for non-general scope with '*'"
918        );
919    }
920
921    #[test]
922    fn general_scope_allows_wildcard() {
923        let registry = make_registry(&["builtin:shell", "builtin:read"]);
924        let patterns = vec!["*".to_owned()];
925        let result = ToolScope::try_compile(
926            "general",
927            &patterns,
928            &registry,
929            PatternStrictness::Strict,
930            true, // is_general_scope = true
931        );
932        assert!(result.is_ok());
933    }
934
935    #[tokio::test]
936    async fn executor_rejects_out_of_scope_call() {
937        let registry = make_registry(&["builtin:shell", "builtin:read"]);
938        let (scope, _) = ToolScope::try_compile(
939            "narrow",
940            &["builtin:read".to_owned()],
941            &registry,
942            PatternStrictness::Strict,
943            false,
944        )
945        .unwrap();
946        let inner = NullExecutor {
947            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
948        };
949        let executor = ScopedToolExecutor::new(inner, scope);
950        let call = make_call("builtin:shell");
951        let result = executor.execute_tool_call(&call).await;
952        assert_matches!(result, Err(ToolError::OutOfScope { .. }));
953    }
954
955    #[tokio::test]
956    async fn executor_allows_in_scope_call() {
957        let registry = make_registry(&["builtin:shell", "builtin:read"]);
958        let (scope, _) = ToolScope::try_compile(
959            "narrow",
960            &["builtin:read".to_owned()],
961            &registry,
962            PatternStrictness::Strict,
963            false,
964        )
965        .unwrap();
966        let inner = NullExecutor {
967            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
968        };
969        let executor = ScopedToolExecutor::new(inner, scope);
970        let call = make_call("builtin:read");
971        let result = executor.execute_tool_call(&call).await;
972        assert!(result.is_ok());
973    }
974
975    #[test]
976    fn tool_definitions_filtered_by_scope() {
977        let registry = make_registry(&["builtin:shell", "builtin:read"]);
978        let (scope, _) = ToolScope::try_compile(
979            "narrow",
980            &["builtin:read".to_owned()],
981            &registry,
982            PatternStrictness::Strict,
983            false,
984        )
985        .unwrap();
986        let inner = NullExecutor {
987            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
988        };
989        let executor = ScopedToolExecutor::new(inner, scope);
990        let defs = executor.tool_definitions();
991        assert_eq!(defs.len(), 1);
992        assert_eq!(defs[0].id.as_ref(), "builtin:read");
993    }
994
995    #[tokio::test]
996    async fn unnamespaced_tool_id_admitted_via_builtin_prefix() {
997        // Built-in tools dispatch with unqualified ids (e.g. "bash").
998        // ScopedToolExecutor must normalize "bash" → "builtin:bash" before the admits check.
999        let registry = make_registry(&["builtin:bash", "builtin:read"]);
1000        let (scope, _) = ToolScope::try_compile(
1001            "narrow",
1002            &["builtin:bash".to_owned()],
1003            &registry,
1004            PatternStrictness::Strict,
1005            false,
1006        )
1007        .unwrap();
1008        let inner = NullExecutor {
1009            defs: vec![null_def("bash"), null_def("read")],
1010        };
1011        let executor = ScopedToolExecutor::new(inner, scope);
1012        // "bash" (unqualified) must be admitted because admitted set contains "builtin:bash".
1013        let call = make_call("bash");
1014        let result = executor.execute_tool_call(&call).await;
1015        assert!(
1016            result.is_ok(),
1017            "builtin tool with unqualified id must be admitted"
1018        );
1019        // "read" is not in the narrow scope, so it must be rejected.
1020        let call_read = make_call("read");
1021        let result_read = executor.execute_tool_call(&call_read).await;
1022        assert!(
1023            matches!(result_read, Err(ToolError::OutOfScope { .. })),
1024            "out-of-scope built-in tool must be rejected"
1025        );
1026    }
1027
1028    #[test]
1029    fn build_scoped_executor_accepts_unqualified_registry_id() {
1030        // registry_ids in runner.rs are pre-qualified; build_scoped_executor must not
1031        // reject unqualified ids itself (caller responsibility).
1032        let cfg = CapabilityScopesConfig::default();
1033        let registry = make_registry(&["shell"]); // no namespace — still accepted
1034        let inner = NullExecutor { defs: vec![] };
1035        let result = build_scoped_executor(inner, &cfg, &registry);
1036        assert!(
1037            result.is_ok(),
1038            "build_scoped_executor must accept unqualified registry ids"
1039        );
1040    }
1041
1042    #[test]
1043    fn build_scoped_executor_with_builtin_prefix_and_glob() {
1044        let mut cfg = CapabilityScopesConfig::default();
1045        cfg.scopes.insert(
1046            "general".to_owned(),
1047            ScopeConfig {
1048                patterns: vec!["builtin:*".to_owned()],
1049            },
1050        );
1051        cfg.default_scope = "general".to_owned();
1052        let registry = make_registry(&["builtin:bash", "builtin:read", "builtin:fetch"]);
1053        let inner = NullExecutor { defs: vec![] };
1054        let result = build_scoped_executor(inner, &cfg, &registry);
1055        assert!(
1056            result.is_ok(),
1057            "builtin:* glob must match all builtin tools"
1058        );
1059    }
1060
1061    #[tokio::test]
1062    async fn unqualified_tool_out_of_scope_rejected() {
1063        let registry = make_registry(&["builtin:bash", "builtin:read"]);
1064        let (scope, _) = ToolScope::try_compile(
1065            "narrow",
1066            &["builtin:read".to_owned()],
1067            &registry,
1068            PatternStrictness::Strict,
1069            false,
1070        )
1071        .unwrap();
1072        let inner = NullExecutor {
1073            defs: vec![null_def("bash"), null_def("read")],
1074        };
1075        let executor = ScopedToolExecutor::new(inner, scope);
1076        let call = make_call("bash"); // unqualified; not in narrow scope
1077        let result = executor.execute_tool_call(&call).await;
1078        assert!(
1079            matches!(result, Err(ToolError::OutOfScope { .. })),
1080            "unqualified id not in scope must be rejected after normalization"
1081        );
1082    }
1083
1084    #[test]
1085    fn tool_definitions_filtered_by_scope_with_unqualified_ids() {
1086        // Built-in tool defs have unqualified ids; filtering must still work via builtin: prefix.
1087        let registry = make_registry(&["builtin:bash", "builtin:read"]);
1088        let (scope, _) = ToolScope::try_compile(
1089            "narrow",
1090            &["builtin:read".to_owned()],
1091            &registry,
1092            PatternStrictness::Strict,
1093            false,
1094        )
1095        .unwrap();
1096        let inner = NullExecutor {
1097            defs: vec![null_def("bash"), null_def("read")],
1098        };
1099        let executor = ScopedToolExecutor::new(inner, scope);
1100        let defs = executor.tool_definitions();
1101        assert_eq!(defs.len(), 1);
1102        assert_eq!(defs[0].id.as_ref(), "read");
1103    }
1104
1105    #[test]
1106    fn scope_for_task_returns_ids() {
1107        let registry = make_registry(&["builtin:shell", "builtin:read"]);
1108        let (scope, _) = ToolScope::try_compile(
1109            "narrow",
1110            &["builtin:read".to_owned()],
1111            &registry,
1112            PatternStrictness::Strict,
1113            false,
1114        )
1115        .unwrap();
1116        let inner = NullExecutor { defs: vec![] };
1117        let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
1118        executor.register_scope("narrow", scope);
1119        let ids = executor.scope_for_task("narrow");
1120        assert!(ids.is_some());
1121        let ids = ids.unwrap();
1122        assert!(ids.contains(&"builtin:read".to_owned()));
1123        assert!(!ids.contains(&"builtin:shell".to_owned()));
1124    }
1125
1126    #[test]
1127    fn scope_for_task_returns_none_for_unknown() {
1128        let inner = NullExecutor { defs: vec![] };
1129        let executor = ScopedToolExecutor::new(inner, ToolScope::full());
1130        assert!(executor.scope_for_task("does_not_exist").is_none());
1131    }
1132
1133    #[test]
1134    fn re_resolve_updates_admitted_set() {
1135        // Initial registry: two tools so builtin:* does not accidentally cover everything.
1136        // Use a specific pattern to keep the test simple.
1137        let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
1138        let (scope, _) = ToolScope::try_compile(
1139            "narrow",
1140            &["builtin:read".to_owned()],
1141            &registry,
1142            PatternStrictness::Strict,
1143            false,
1144        )
1145        .unwrap();
1146        assert!(scope.admits("builtin:read"));
1147        assert!(!scope.admits("builtin:write"));
1148
1149        // After re-resolve with a new registry entry the pattern still only matches "builtin:read".
1150        let mut new_registry = registry.clone();
1151        new_registry.insert("builtin:write".to_owned());
1152        let updated = scope.re_resolve(&new_registry);
1153        assert!(updated.admits("builtin:read"));
1154        // "builtin:write" is not in the original pattern, so it remains excluded.
1155        assert!(!updated.admits("builtin:write"));
1156    }
1157
1158    #[test]
1159    fn checkpoint_methods_delegated_to_inner() {
1160        let executor = ScopedToolExecutor::new(CheckpointingExecutor, ToolScope::full());
1161        let undo_result = executor.checkpoint_undo(7);
1162        assert!(undo_result.supported);
1163        assert_eq!(
1164            undo_result.reverted_commands, 7,
1165            "n must be forwarded, not hardcoded"
1166        );
1167        assert!(executor.checkpoint_redo().supported);
1168        assert!(executor.checkpoint_list().supported);
1169    }
1170
1171    #[test]
1172    fn requires_confirmation_delegated_to_inner() {
1173        let executor = ScopedToolExecutor::new(CheckpointingExecutor, ToolScope::full());
1174        assert!(executor.requires_confirmation(&make_call("builtin:shell")));
1175    }
1176
1177    #[test]
1178    fn build_from_config_with_scopes() {
1179        let mut scopes = std::collections::HashMap::new();
1180        scopes.insert(
1181            "general".to_owned(),
1182            ScopeConfig {
1183                patterns: vec!["*".to_owned()],
1184            },
1185        );
1186        scopes.insert(
1187            "narrow".to_owned(),
1188            ScopeConfig {
1189                patterns: vec!["builtin:read".to_owned()],
1190            },
1191        );
1192        let cfg = CapabilityScopesConfig {
1193            default_scope: "general".to_owned(),
1194            strict: false,
1195            pattern_strictness: PatternStrictness::Strict,
1196            scopes,
1197        };
1198        let registry = make_registry(&["builtin:shell", "builtin:read"]);
1199        let inner = NullExecutor { defs: vec![] };
1200        let executor = build_scoped_executor(inner, &cfg, &registry).unwrap();
1201        // narrow scope should be registered
1202        let narrow_ids = executor.scope_for_task("narrow");
1203        assert!(narrow_ids.is_some());
1204        let ids = narrow_ids.unwrap();
1205        assert!(ids.contains(&"builtin:read".to_owned()));
1206    }
1207}