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