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    /// Compile a scope from glob patterns against the materialised registry.
140    ///
141    /// # Errors
142    ///
143    /// Returns `ScopeError::DeadPattern` when a strict-namespace glob matches zero ids,
144    /// `ScopeError::AccidentallyFull` when a pattern expands to the entire registry without
145    /// an explicit `general` opt-in, or `ScopeError::InvalidPattern` on invalid glob syntax.
146    pub fn try_compile<S: std::hash::BuildHasher>(
147        task_type: impl Into<String>,
148        patterns: &[String],
149        registry_ids: &HashSet<String, S>,
150        strictness: PatternStrictness,
151        is_general_scope: bool,
152    ) -> Result<(Self, Vec<ScopeWarning>), ScopeError> {
153        let task_type_str = task_type.into();
154        let mut admitted = HashSet::new();
155        let mut warnings = Vec::new();
156
157        for pattern in patterns {
158            // Validate that glob compiles.
159            let glob = Glob::new(pattern).map_err(|e| ScopeError::InvalidPattern {
160                scope: task_type_str.clone(),
161                pattern: pattern.clone(),
162                source: e,
163            })?;
164
165            let mut builder = GlobSetBuilder::new();
166            builder.add(glob);
167            let glob_set: GlobSet = builder.build().map_err(|e| ScopeError::InvalidPattern {
168                scope: task_type_str.clone(),
169                pattern: pattern.clone(),
170                source: e,
171            })?;
172
173            let matched: HashSet<String> = registry_ids
174                .iter()
175                .filter(|id| glob_set.is_match(id.as_str()))
176                .cloned()
177                .collect();
178
179            // Check for accidentally-full expansion (unless this is the general scope).
180            if !is_general_scope && matched.len() == registry_ids.len() && !registry_ids.is_empty()
181            {
182                return Err(ScopeError::AccidentallyFull {
183                    scope: task_type_str,
184                    pattern: pattern.clone(),
185                });
186            }
187
188            if matched.is_empty() {
189                let is_strict = is_strict_pattern(pattern, strictness);
190                if is_strict {
191                    return Err(ScopeError::DeadPattern {
192                        scope: task_type_str,
193                        pattern: pattern.clone(),
194                    });
195                }
196                warnings.push(ScopeWarning {
197                    scope: task_type_str.clone(),
198                    pattern: pattern.clone(),
199                });
200            }
201
202            admitted.extend(matched);
203        }
204
205        Ok((
206            Self {
207                task_type: Some(task_type_str),
208                admitted,
209                is_full: false,
210                patterns: patterns.to_vec(),
211            },
212            warnings,
213        ))
214    }
215
216    /// Returns `true` when the given fully-qualified tool id is admitted by this scope.
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use zeph_tools::scope::ToolScope;
222    ///
223    /// let scope = ToolScope::full();
224    /// assert!(scope.admits("builtin:shell"));
225    /// ```
226    #[must_use]
227    pub fn admits(&self, qualified_tool_id: &str) -> bool {
228        self.is_full || self.admitted.contains(qualified_tool_id)
229    }
230
231    /// Returns the list of admitted tool ids (excluding `full` scopes).
232    ///
233    /// Useful for `/scope list` output and the `scope_at_definition` audit field.
234    #[must_use]
235    pub fn admitted_ids(&self) -> Vec<&str> {
236        self.admitted.iter().map(String::as_str).collect()
237    }
238
239    /// The raw glob patterns this scope was compiled from (for re-resolution).
240    #[must_use]
241    pub fn patterns(&self) -> &[String] {
242        &self.patterns
243    }
244
245    /// Re-resolve the scope against a new registry (called on dynamic tool registration).
246    ///
247    /// Returns a new `ToolScope` with the updated admit set; warnings are logged but not
248    /// returned (non-fatal for provisional namespaces).
249    #[must_use]
250    pub fn re_resolve<S: std::hash::BuildHasher>(&self, registry_ids: &HashSet<String, S>) -> Self {
251        let task_type_str = self
252            .task_type
253            .clone()
254            .unwrap_or_else(|| "<unknown>".to_owned());
255        let mut admitted = HashSet::new();
256        for pattern in &self.patterns {
257            let Ok(glob) = Glob::new(pattern) else {
258                warn!(scope = %task_type_str, pattern, "re-resolve: invalid glob, skipping");
259                continue;
260            };
261            let mut builder = GlobSetBuilder::new();
262            builder.add(glob);
263            let Ok(glob_set) = builder.build() else {
264                continue;
265            };
266            let matched: HashSet<String> = registry_ids
267                .iter()
268                .filter(|id| glob_set.is_match(id.as_str()))
269                .cloned()
270                .collect();
271            admitted.extend(matched);
272        }
273        Self {
274            task_type: self.task_type.clone(),
275            admitted,
276            is_full: false,
277            patterns: self.patterns.clone(),
278        }
279    }
280}
281
282/// Returns `true` when the pattern targets a strict namespace (`builtin:` or `skill:`).
283fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
284    match strictness {
285        PatternStrictness::Strict => true,
286        PatternStrictness::ProvisionalForDynamicNamespaces => {
287            // Strict for builtin: and skill:; provisional for mcp:, acp:, a2a:
288            pattern.starts_with("builtin:") || pattern.starts_with("skill:")
289        }
290        _ => false,
291    }
292}
293
294// ── ScopedToolExecutor ────────────────────────────────────────────────────────
295
296/// Wraps any `ToolExecutor` and enforces a capability scope on both tool listing and dispatch.
297///
298/// # Type parameter
299///
300/// `E` is the inner executor (e.g., `PolicyGateExecutor<TrustGateExecutor<CompositeExecutor>>`).
301///
302/// # Examples
303///
304/// ```rust,no_run
305/// use std::collections::HashSet;
306/// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
307/// use zeph_tools::{ToolExecutor, ToolCall};
308/// use zeph_common::ToolName;
309///
310/// // Build a full (no-op) scope — identity, admits everything.
311/// let scope = ToolScope::full();
312///
313/// // Wrap some inner executor (omitted for brevity).
314/// struct MockExecutor;
315/// impl ToolExecutor for MockExecutor {
316///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
317/// }
318/// let executor = ScopedToolExecutor::new(MockExecutor, scope);
319/// ```
320pub struct ScopedToolExecutor<E: ToolExecutor> {
321    inner: E,
322    /// Atomically swappable active scope. Swapped via `set_scope()`.
323    scope: ArcSwap<ToolScope>,
324    /// Named scope map for task-type lookup.
325    scopes: HashMap<String, Arc<ToolScope>>,
326    /// Name of the scope currently surfaced to the LLM (captured at `tool_definitions()` time).
327    scope_at_definition: parking_lot::Mutex<Option<String>>,
328    /// Optional shared queue — `OutOfScope` signal codes pushed here; drained by `begin_turn()`.
329    signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
330    /// Optional audit logger — `out_of_scope` entries emitted on every rejection.
331    audit: Option<Arc<AuditLogger>>,
332}
333
334impl<E: ToolExecutor> ScopedToolExecutor<E> {
335    /// Create a new `ScopedToolExecutor` with the given initial scope.
336    ///
337    /// # Examples
338    ///
339    /// ```rust,no_run
340    /// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
341    ///
342    /// struct Noop;
343    /// impl zeph_tools::ToolExecutor for Noop {
344    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
345    /// }
346    /// let executor = ScopedToolExecutor::new(Noop, ToolScope::full());
347    /// ```
348    #[must_use]
349    pub fn new(inner: E, initial_scope: ToolScope) -> Self {
350        Self {
351            inner,
352            scope: ArcSwap::from_pointee(initial_scope),
353            scopes: HashMap::new(),
354            scope_at_definition: parking_lot::Mutex::new(None),
355            signal_queue: None,
356            audit: None,
357        }
358    }
359
360    /// Attach an audit logger so every `OutOfScope` rejection writes an audit entry.
361    #[must_use]
362    pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
363        self.audit = Some(audit);
364        self
365    }
366
367    /// Attach a shared signal queue so `OutOfScope` rejections are recorded in the sentinel.
368    #[must_use]
369    pub fn with_signal_queue(mut self, queue: crate::policy_gate::RiskSignalQueue) -> Self {
370        self.signal_queue = Some(queue);
371        self
372    }
373
374    /// Register a named scope for use with `set_scope_for_task`.
375    pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
376        self.scopes.insert(name.into(), Arc::new(scope));
377    }
378
379    /// Switch the active scope by task-type name. Returns `false` when the name is not found.
380    pub fn set_scope_for_task(&self, task_type: &str) -> bool {
381        if let Some(scope) = self.scopes.get(task_type) {
382            self.scope.store(Arc::clone(scope));
383            true
384        } else {
385            false
386        }
387    }
388
389    /// Replace the active scope with the given one directly.
390    pub fn set_scope(&self, scope: ToolScope) {
391        self.scope.store(Arc::new(scope));
392    }
393
394    /// Return the list of tool ids admitted by the scope for `task_type`.
395    ///
396    /// Returns `None` when `task_type` is not registered.
397    ///
398    /// # Examples
399    ///
400    /// ```rust,no_run
401    /// use zeph_tools::scope::{ScopedToolExecutor, ToolScope};
402    ///
403    /// struct Noop;
404    /// impl zeph_tools::ToolExecutor for Noop {
405    ///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
406    /// }
407    /// let mut executor = ScopedToolExecutor::new(Noop, ToolScope::full());
408    /// // scope_for_task returns None for unregistered task types
409    /// assert!(executor.scope_for_task("unknown").is_none());
410    /// ```
411    #[must_use]
412    pub fn scope_for_task(&self, task_type: &str) -> Option<Vec<String>> {
413        self.scopes.get(task_type).map(|s| {
414            if s.is_full {
415                vec!["*".to_owned()]
416            } else {
417                s.admitted_ids().iter().map(|s| (*s).to_owned()).collect()
418            }
419        })
420    }
421
422    /// Name of the active scope at the last `tool_definitions()` call (for audit).
423    #[must_use]
424    pub fn scope_at_definition_name(&self) -> Option<String> {
425        self.scope_at_definition.lock().clone()
426    }
427
428    /// Name of the currently active scope (for audit at dispatch time).
429    #[must_use]
430    pub fn active_scope_name(&self) -> Option<String> {
431        self.scope.load().task_type.clone()
432    }
433}
434
435impl<E: ToolExecutor> ToolExecutor for ScopedToolExecutor<E> {
436    // CRIT-03 carve-out: legacy fenced-block dispatch path is not scoped (mirrors PolicyGate).
437    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
438        self.inner.execute(response).await
439    }
440
441    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
442        self.inner.execute_confirmed(response).await
443    }
444
445    /// Return the filtered tool definitions visible to the LLM under the active scope.
446    ///
447    /// Captures the active scope name into `scope_at_definition` for audit use.
448    fn tool_definitions(&self) -> Vec<ToolDef> {
449        let scope = self.scope.load();
450        self.scope_at_definition.lock().clone_from(&scope.task_type);
451        self.inner
452            .tool_definitions()
453            .into_iter()
454            .filter(|d| {
455                let id = d.id.as_ref();
456                let scope_id: String;
457                let qualified = if id.contains(':') {
458                    id
459                } else {
460                    scope_id = format!("builtin:{id}");
461                    scope_id.as_str()
462                };
463                scope.admits(qualified)
464            })
465            .collect()
466    }
467
468    /// Execute a structured tool call, rejecting out-of-scope ids before any side-effect.
469    ///
470    /// Returns `ToolError::OutOfScope` when the tool id is not in the active scope.
471    /// The audit log entry at the call site must carry `error_category = "out_of_scope"`.
472    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
473        let scope = self.scope.load();
474        let tool_id = call.tool_id.as_str();
475        // Built-in tools dispatch with unqualified ids ("bash", "read", etc.).
476        // Synthesize the "builtin:" prefix at the scope boundary so the admitted set
477        // (which contains "builtin:bash" etc.) resolves correctly.
478        let qualified_id: String;
479        let scope_id = if tool_id.contains(':') {
480            tool_id
481        } else {
482            qualified_id = format!("builtin:{tool_id}");
483            qualified_id.as_str()
484        };
485
486        if !scope.admits(scope_id) {
487            let scope_name = scope.task_type.clone();
488            let scope_def = self.scope_at_definition.lock().clone();
489            tracing::debug!(
490                tool_id,
491                scope = ?scope_name,
492                "ScopedToolExecutor: out-of-scope rejection"
493            );
494            // Signal code 3 = OutOfScope (matches RiskSignal::OutOfScope in zeph-core).
495            if let Some(ref q) = self.signal_queue {
496                q.lock().push(3);
497            }
498            // F4: emit audit entry with error_category = "out_of_scope".
499            if let Some(ref audit) = self.audit {
500                let entry = AuditEntry {
501                    timestamp: chrono_now(),
502                    tool: call.tool_id.clone(),
503                    command: String::new(),
504                    result: AuditResult::Blocked {
505                        reason: "out_of_scope".to_owned(),
506                    },
507                    duration_ms: 0,
508                    error_category: Some("out_of_scope".to_owned()),
509                    error_domain: Some("security".to_owned()),
510                    error_phase: None,
511                    claim_source: None,
512                    mcp_server_id: None,
513                    injection_flagged: false,
514                    embedding_anomalous: false,
515                    cross_boundary_mcp_to_acp: false,
516                    adversarial_policy_decision: None,
517                    exit_code: None,
518                    truncated: false,
519                    caller_id: call.caller_id.clone(),
520                    skill_name: call.skill_name.clone(),
521                    policy_match: None,
522                    correlation_id: None,
523                    vigil_risk: None,
524                    execution_env: None,
525                    resolved_cwd: None,
526                    scope_at_definition: scope_def,
527                    scope_at_dispatch: scope_name,
528                };
529                audit.log(&entry).await;
530            }
531            return Err(ToolError::OutOfScope {
532                tool_id: tool_id.to_owned(),
533                task_type: scope.task_type.clone(),
534            });
535        }
536
537        self.inner.execute_tool_call(call).await
538    }
539
540    async fn execute_tool_call_confirmed(
541        &self,
542        call: &ToolCall,
543    ) -> Result<Option<ToolOutput>, ToolError> {
544        let scope = self.scope.load();
545        let tool_id = call.tool_id.as_str();
546        let qualified_id: String;
547        let scope_id = if tool_id.contains(':') {
548            tool_id
549        } else {
550            qualified_id = format!("builtin:{tool_id}");
551            qualified_id.as_str()
552        };
553        if !scope.admits(scope_id) {
554            let scope_name = scope.task_type.clone();
555            let scope_def = self.scope_at_definition.lock().clone();
556            if let Some(ref q) = self.signal_queue {
557                q.lock().push(3);
558            }
559            if let Some(ref audit) = self.audit {
560                let entry = AuditEntry {
561                    timestamp: chrono_now(),
562                    tool: call.tool_id.clone(),
563                    command: String::new(),
564                    result: AuditResult::Blocked {
565                        reason: "out_of_scope".to_owned(),
566                    },
567                    duration_ms: 0,
568                    error_category: Some("out_of_scope".to_owned()),
569                    error_domain: Some("security".to_owned()),
570                    error_phase: None,
571                    claim_source: None,
572                    mcp_server_id: None,
573                    injection_flagged: false,
574                    embedding_anomalous: false,
575                    cross_boundary_mcp_to_acp: false,
576                    adversarial_policy_decision: None,
577                    exit_code: None,
578                    truncated: false,
579                    caller_id: call.caller_id.clone(),
580                    skill_name: call.skill_name.clone(),
581                    policy_match: None,
582                    correlation_id: None,
583                    vigil_risk: None,
584                    execution_env: None,
585                    resolved_cwd: None,
586                    scope_at_definition: scope_def,
587                    scope_at_dispatch: scope_name,
588                };
589                audit.log(&entry).await;
590            }
591            return Err(ToolError::OutOfScope {
592                tool_id: tool_id.to_owned(),
593                task_type: scope.task_type.clone(),
594            });
595        }
596        self.inner.execute_tool_call_confirmed(call).await
597    }
598
599    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
600        self.inner.set_skill_env(env);
601    }
602
603    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
604        self.inner.set_effective_trust(level);
605    }
606
607    fn is_tool_retryable(&self, tool_id: &str) -> bool {
608        self.inner.is_tool_retryable(tool_id)
609    }
610
611    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
612        self.inner.is_tool_speculatable(tool_id)
613    }
614}
615
616// ── Config-driven builder ──────────────────────────────────────────────────────
617
618/// Build a `ScopedToolExecutor` from a `CapabilityScopesConfig` and a registered tool set.
619///
620/// Returns a fatal `ScopeError` when any strict-namespace pattern matches zero tools.
621/// Emits `ScopeWarning` entries for provisional-namespace zero-match patterns.
622///
623/// # Errors
624///
625/// Returns `ScopeError` when scope configuration is invalid (dead patterns, accidental-full).
626///
627/// # Examples
628///
629/// ```rust,no_run
630/// use std::collections::HashSet;
631/// use zeph_config::CapabilityScopesConfig;
632/// use zeph_tools::scope::build_scoped_executor;
633///
634/// struct Noop;
635/// impl zeph_tools::ToolExecutor for Noop {
636///     async fn execute(&self, _: &str) -> Result<Option<zeph_tools::ToolOutput>, zeph_tools::ToolError> { Ok(None) }
637/// }
638///
639/// let cfg = CapabilityScopesConfig::default();
640/// let registry: HashSet<String> = HashSet::new();
641/// let executor = build_scoped_executor(Noop, &cfg, &registry).expect("build failed");
642/// ```
643pub fn build_scoped_executor<E: ToolExecutor, S: std::hash::BuildHasher>(
644    inner: E,
645    cfg: &CapabilityScopesConfig,
646    registry_ids: &HashSet<String, S>,
647) -> Result<ScopedToolExecutor<E>, ScopeError> {
648    let default_scope_name = &cfg.default_scope;
649    let strictness = cfg.pattern_strictness;
650
651    // The default initial scope is full (no-op) unless a named default_scope is configured.
652    let initial_scope = ToolScope::full();
653    let mut executor = ScopedToolExecutor::new(inner, initial_scope);
654
655    for (task_type, scope_cfg) in &cfg.scopes {
656        let is_general = task_type == default_scope_name;
657        let (scope, warnings) = ToolScope::try_compile(
658            task_type.clone(),
659            &scope_cfg.patterns,
660            registry_ids,
661            strictness,
662            is_general,
663        )?;
664        for w in &warnings {
665            warn!(
666                scope = %w.scope,
667                pattern = %w.pattern,
668                "capability scope: provisional zero-match pattern (will re-resolve on dynamic registration)"
669            );
670        }
671        executor.register_scope(task_type.clone(), scope);
672    }
673
674    // If a default_scope is configured and registered, activate it.
675    if cfg.scopes.contains_key(default_scope_name.as_str()) {
676        executor.set_scope_for_task(default_scope_name);
677    }
678
679    Ok(executor)
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use crate::executor::ToolCall;
686    use crate::registry::{InvocationHint, ToolDef};
687    use std::assert_matches;
688    use zeph_common::ToolName;
689    use zeph_config::{CapabilityScopesConfig, PatternStrictness, ScopeConfig};
690
691    fn make_registry(ids: &[&str]) -> HashSet<String> {
692        ids.iter().map(|s| (*s).to_owned()).collect()
693    }
694
695    struct NullExecutor {
696        defs: Vec<ToolDef>,
697    }
698
699    impl ToolExecutor for NullExecutor {
700        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
701            Ok(None)
702        }
703
704        fn tool_definitions(&self) -> Vec<ToolDef> {
705            self.defs.clone()
706        }
707
708        async fn execute_tool_call(
709            &self,
710            call: &ToolCall,
711        ) -> Result<Option<ToolOutput>, ToolError> {
712            Ok(Some(ToolOutput {
713                tool_name: call.tool_id.clone(),
714                summary: "ok".to_owned(),
715                blocks_executed: 1,
716                filter_stats: None,
717                diff: None,
718                streamed: false,
719                terminal_id: None,
720                locations: None,
721                raw_response: None,
722                claim_source: None,
723            }))
724        }
725    }
726
727    fn null_def(id: &str) -> ToolDef {
728        ToolDef {
729            id: id.to_owned().into(),
730            description: "test tool".into(),
731            schema: schemars::schema_for!(String),
732            invocation: InvocationHint::ToolCall,
733            output_schema: None,
734            server_id: None,
735        }
736    }
737
738    fn make_call(tool_id: &str) -> ToolCall {
739        ToolCall {
740            tool_id: ToolName::new(tool_id),
741            params: serde_json::Map::new(),
742            caller_id: None,
743            context: None,
744
745            tool_call_id: String::new(),
746            skill_name: None,
747        }
748    }
749
750    #[test]
751    fn full_scope_admits_everything() {
752        let scope = ToolScope::full();
753        assert!(scope.admits("builtin:shell"));
754        assert!(scope.admits("mcp:server/tool"));
755        assert!(scope.admits("builtin:read"));
756    }
757
758    #[test]
759    fn compiled_scope_admits_only_matched() {
760        let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
761        let patterns = vec!["builtin:read".to_owned()];
762        let (scope, warnings) = ToolScope::try_compile(
763            "narrow",
764            &patterns,
765            &registry,
766            PatternStrictness::Strict,
767            false,
768        )
769        .unwrap();
770        assert!(warnings.is_empty());
771        assert!(scope.admits("builtin:read"));
772        assert!(!scope.admits("builtin:shell"));
773        assert!(!scope.admits("builtin:write"));
774    }
775
776    #[test]
777    fn dead_pattern_strict_returns_error() {
778        let registry = make_registry(&["builtin:shell"]);
779        let patterns = vec!["builtin:nonexistent".to_owned()];
780        let result = ToolScope::try_compile(
781            "test",
782            &patterns,
783            &registry,
784            PatternStrictness::Strict,
785            false,
786        );
787        assert!(
788            matches!(result, Err(ScopeError::DeadPattern { .. })),
789            "expected DeadPattern, got {result:?}"
790        );
791    }
792
793    #[test]
794    fn dead_pattern_provisional_returns_warning() {
795        let registry = make_registry(&["builtin:shell"]);
796        let patterns = vec!["mcp:server/nonexistent".to_owned()];
797        let result = ToolScope::try_compile(
798            "test",
799            &patterns,
800            &registry,
801            PatternStrictness::ProvisionalForDynamicNamespaces,
802            false,
803        );
804        assert!(result.is_ok());
805        let (_, warnings) = result.unwrap();
806        assert_eq!(warnings.len(), 1);
807    }
808
809    #[test]
810    fn accidentally_full_pattern_returns_error() {
811        let registry = make_registry(&["builtin:shell", "builtin:read"]);
812        let patterns = vec!["*".to_owned()];
813        let result = ToolScope::try_compile(
814            "test",
815            &patterns,
816            &registry,
817            PatternStrictness::Strict,
818            false, // not general scope
819        );
820        assert!(
821            matches!(result, Err(ScopeError::AccidentallyFull { .. })),
822            "expected AccidentallyFull for non-general scope with '*'"
823        );
824    }
825
826    #[test]
827    fn general_scope_allows_wildcard() {
828        let registry = make_registry(&["builtin:shell", "builtin:read"]);
829        let patterns = vec!["*".to_owned()];
830        let result = ToolScope::try_compile(
831            "general",
832            &patterns,
833            &registry,
834            PatternStrictness::Strict,
835            true, // is_general_scope = true
836        );
837        assert!(result.is_ok());
838    }
839
840    #[tokio::test]
841    async fn executor_rejects_out_of_scope_call() {
842        let registry = make_registry(&["builtin:shell", "builtin:read"]);
843        let (scope, _) = ToolScope::try_compile(
844            "narrow",
845            &["builtin:read".to_owned()],
846            &registry,
847            PatternStrictness::Strict,
848            false,
849        )
850        .unwrap();
851        let inner = NullExecutor {
852            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
853        };
854        let executor = ScopedToolExecutor::new(inner, scope);
855        let call = make_call("builtin:shell");
856        let result = executor.execute_tool_call(&call).await;
857        assert_matches!(result, Err(ToolError::OutOfScope { .. }));
858    }
859
860    #[tokio::test]
861    async fn executor_allows_in_scope_call() {
862        let registry = make_registry(&["builtin:shell", "builtin:read"]);
863        let (scope, _) = ToolScope::try_compile(
864            "narrow",
865            &["builtin:read".to_owned()],
866            &registry,
867            PatternStrictness::Strict,
868            false,
869        )
870        .unwrap();
871        let inner = NullExecutor {
872            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
873        };
874        let executor = ScopedToolExecutor::new(inner, scope);
875        let call = make_call("builtin:read");
876        let result = executor.execute_tool_call(&call).await;
877        assert!(result.is_ok());
878    }
879
880    #[test]
881    fn tool_definitions_filtered_by_scope() {
882        let registry = make_registry(&["builtin:shell", "builtin:read"]);
883        let (scope, _) = ToolScope::try_compile(
884            "narrow",
885            &["builtin:read".to_owned()],
886            &registry,
887            PatternStrictness::Strict,
888            false,
889        )
890        .unwrap();
891        let inner = NullExecutor {
892            defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
893        };
894        let executor = ScopedToolExecutor::new(inner, scope);
895        let defs = executor.tool_definitions();
896        assert_eq!(defs.len(), 1);
897        assert_eq!(defs[0].id.as_ref(), "builtin:read");
898    }
899
900    #[tokio::test]
901    async fn unnamespaced_tool_id_admitted_via_builtin_prefix() {
902        // Built-in tools dispatch with unqualified ids (e.g. "bash").
903        // ScopedToolExecutor must normalize "bash" → "builtin:bash" before the admits check.
904        let registry = make_registry(&["builtin:bash", "builtin:read"]);
905        let (scope, _) = ToolScope::try_compile(
906            "narrow",
907            &["builtin:bash".to_owned()],
908            &registry,
909            PatternStrictness::Strict,
910            false,
911        )
912        .unwrap();
913        let inner = NullExecutor {
914            defs: vec![null_def("bash"), null_def("read")],
915        };
916        let executor = ScopedToolExecutor::new(inner, scope);
917        // "bash" (unqualified) must be admitted because admitted set contains "builtin:bash".
918        let call = make_call("bash");
919        let result = executor.execute_tool_call(&call).await;
920        assert!(
921            result.is_ok(),
922            "builtin tool with unqualified id must be admitted"
923        );
924        // "read" is not in the narrow scope, so it must be rejected.
925        let call_read = make_call("read");
926        let result_read = executor.execute_tool_call(&call_read).await;
927        assert!(
928            matches!(result_read, Err(ToolError::OutOfScope { .. })),
929            "out-of-scope built-in tool must be rejected"
930        );
931    }
932
933    #[test]
934    fn build_scoped_executor_accepts_unqualified_registry_id() {
935        // registry_ids in runner.rs are pre-qualified; build_scoped_executor must not
936        // reject unqualified ids itself (caller responsibility).
937        let cfg = CapabilityScopesConfig::default();
938        let registry = make_registry(&["shell"]); // no namespace — still accepted
939        let inner = NullExecutor { defs: vec![] };
940        let result = build_scoped_executor(inner, &cfg, &registry);
941        assert!(
942            result.is_ok(),
943            "build_scoped_executor must accept unqualified registry ids"
944        );
945    }
946
947    #[test]
948    fn build_scoped_executor_with_builtin_prefix_and_glob() {
949        let mut cfg = CapabilityScopesConfig::default();
950        cfg.scopes.insert(
951            "general".to_owned(),
952            ScopeConfig {
953                patterns: vec!["builtin:*".to_owned()],
954            },
955        );
956        cfg.default_scope = "general".to_owned();
957        let registry = make_registry(&["builtin:bash", "builtin:read", "builtin:fetch"]);
958        let inner = NullExecutor { defs: vec![] };
959        let result = build_scoped_executor(inner, &cfg, &registry);
960        assert!(
961            result.is_ok(),
962            "builtin:* glob must match all builtin tools"
963        );
964    }
965
966    #[tokio::test]
967    async fn unqualified_tool_out_of_scope_rejected() {
968        let registry = make_registry(&["builtin:bash", "builtin:read"]);
969        let (scope, _) = ToolScope::try_compile(
970            "narrow",
971            &["builtin:read".to_owned()],
972            &registry,
973            PatternStrictness::Strict,
974            false,
975        )
976        .unwrap();
977        let inner = NullExecutor {
978            defs: vec![null_def("bash"), null_def("read")],
979        };
980        let executor = ScopedToolExecutor::new(inner, scope);
981        let call = make_call("bash"); // unqualified; not in narrow scope
982        let result = executor.execute_tool_call(&call).await;
983        assert!(
984            matches!(result, Err(ToolError::OutOfScope { .. })),
985            "unqualified id not in scope must be rejected after normalization"
986        );
987    }
988
989    #[test]
990    fn tool_definitions_filtered_by_scope_with_unqualified_ids() {
991        // Built-in tool defs have unqualified ids; filtering must still work via builtin: prefix.
992        let registry = make_registry(&["builtin:bash", "builtin:read"]);
993        let (scope, _) = ToolScope::try_compile(
994            "narrow",
995            &["builtin:read".to_owned()],
996            &registry,
997            PatternStrictness::Strict,
998            false,
999        )
1000        .unwrap();
1001        let inner = NullExecutor {
1002            defs: vec![null_def("bash"), null_def("read")],
1003        };
1004        let executor = ScopedToolExecutor::new(inner, scope);
1005        let defs = executor.tool_definitions();
1006        assert_eq!(defs.len(), 1);
1007        assert_eq!(defs[0].id.as_ref(), "read");
1008    }
1009
1010    #[test]
1011    fn scope_for_task_returns_ids() {
1012        let registry = make_registry(&["builtin:shell", "builtin:read"]);
1013        let (scope, _) = ToolScope::try_compile(
1014            "narrow",
1015            &["builtin:read".to_owned()],
1016            &registry,
1017            PatternStrictness::Strict,
1018            false,
1019        )
1020        .unwrap();
1021        let inner = NullExecutor { defs: vec![] };
1022        let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
1023        executor.register_scope("narrow", scope);
1024        let ids = executor.scope_for_task("narrow");
1025        assert!(ids.is_some());
1026        let ids = ids.unwrap();
1027        assert!(ids.contains(&"builtin:read".to_owned()));
1028        assert!(!ids.contains(&"builtin:shell".to_owned()));
1029    }
1030
1031    #[test]
1032    fn scope_for_task_returns_none_for_unknown() {
1033        let inner = NullExecutor { defs: vec![] };
1034        let executor = ScopedToolExecutor::new(inner, ToolScope::full());
1035        assert!(executor.scope_for_task("does_not_exist").is_none());
1036    }
1037
1038    #[test]
1039    fn re_resolve_updates_admitted_set() {
1040        // Initial registry: two tools so builtin:* does not accidentally cover everything.
1041        // Use a specific pattern to keep the test simple.
1042        let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
1043        let (scope, _) = ToolScope::try_compile(
1044            "narrow",
1045            &["builtin:read".to_owned()],
1046            &registry,
1047            PatternStrictness::Strict,
1048            false,
1049        )
1050        .unwrap();
1051        assert!(scope.admits("builtin:read"));
1052        assert!(!scope.admits("builtin:write"));
1053
1054        // After re-resolve with a new registry entry the pattern still only matches "builtin:read".
1055        let mut new_registry = registry.clone();
1056        new_registry.insert("builtin:write".to_owned());
1057        let updated = scope.re_resolve(&new_registry);
1058        assert!(updated.admits("builtin:read"));
1059        // "builtin:write" is not in the original pattern, so it remains excluded.
1060        assert!(!updated.admits("builtin:write"));
1061    }
1062
1063    #[test]
1064    fn build_from_config_with_scopes() {
1065        let mut scopes = std::collections::HashMap::new();
1066        scopes.insert(
1067            "general".to_owned(),
1068            ScopeConfig {
1069                patterns: vec!["*".to_owned()],
1070            },
1071        );
1072        scopes.insert(
1073            "narrow".to_owned(),
1074            ScopeConfig {
1075                patterns: vec!["builtin:read".to_owned()],
1076            },
1077        );
1078        let cfg = CapabilityScopesConfig {
1079            default_scope: "general".to_owned(),
1080            strict: false,
1081            pattern_strictness: PatternStrictness::Strict,
1082            scopes,
1083        };
1084        let registry = make_registry(&["builtin:shell", "builtin:read"]);
1085        let inner = NullExecutor { defs: vec![] };
1086        let executor = build_scoped_executor(inner, &cfg, &registry).unwrap();
1087        // narrow scope should be registered
1088        let narrow_ids = executor.scope_for_task("narrow");
1089        assert!(narrow_ids.is_some());
1090        let ids = narrow_ids.unwrap();
1091        assert!(ids.contains(&"builtin:read".to_owned()));
1092    }
1093}