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