1use 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#[derive(Debug, thiserror::Error)]
64pub enum ScopeError {
65 #[error("scope '{scope}': pattern '{pattern}' matched zero registered tools (dead pattern)")]
67 DeadPattern { scope: String, pattern: String },
68
69 #[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 #[error("tool id '{id}' has no namespace prefix (expected '<namespace>:<id>')")]
77 UnqualifiedId { id: String },
78
79 #[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#[derive(Debug)]
91pub struct ScopeWarning {
92 pub scope: String,
94 pub pattern: String,
96}
97
98#[derive(Debug, Clone)]
105pub struct ToolScope {
106 pub task_type: Option<String>,
108 admitted: HashSet<String>,
110 is_full: bool,
112 patterns: Vec<String>,
114}
115
116impl ToolScope {
117 #[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 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 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 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 #[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 #[must_use]
234 pub fn admitted_ids(&self) -> Vec<&str> {
235 self.admitted.iter().map(String::as_str).collect()
236 }
237
238 #[must_use]
240 pub fn patterns(&self) -> &[String] {
241 &self.patterns
242 }
243
244 #[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
281fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
283 match strictness {
284 PatternStrictness::Strict => true,
285 PatternStrictness::Permissive => false,
286 PatternStrictness::ProvisionalForDynamicNamespaces => {
287 pattern.starts_with("builtin:") || pattern.starts_with("skill:")
289 }
290 }
291}
292
293pub struct ScopedToolExecutor<E: ToolExecutor> {
320 inner: E,
321 scope: ArcSwap<ToolScope>,
323 scopes: HashMap<String, Arc<ToolScope>>,
325 scope_at_definition: parking_lot::Mutex<Option<String>>,
327 signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
329 audit: Option<Arc<AuditLogger>>,
331}
332
333impl<E: ToolExecutor> ScopedToolExecutor<E> {
334 #[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 #[must_use]
361 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
362 self.audit = Some(audit);
363 self
364 }
365
366 #[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 pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
375 self.scopes.insert(name.into(), Arc::new(scope));
376 }
377
378 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 pub fn set_scope(&self, scope: ToolScope) {
390 self.scope.store(Arc::new(scope));
391 }
392
393 #[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 #[must_use]
423 pub fn scope_at_definition_name(&self) -> Option<String> {
424 self.scope_at_definition.lock().clone()
425 }
426
427 #[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 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 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 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 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 if let Some(ref q) = self.signal_queue {
495 q.lock().push(3);
496 }
497 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
613pub 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 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 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 ®istry,
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 ®istry,
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 ®istry,
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 ®istry,
811 PatternStrictness::Strict,
812 false, );
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 ®istry,
828 PatternStrictness::Strict,
829 true, );
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 ®istry,
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 ®istry,
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 ®istry,
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 let registry = make_registry(&["builtin:bash", "builtin:read"]);
899 let (scope, _) = ToolScope::try_compile(
900 "narrow",
901 &["builtin:bash".to_owned()],
902 ®istry,
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 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 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 let cfg = CapabilityScopesConfig::default();
932 let registry = make_registry(&["shell"]); let inner = NullExecutor { defs: vec![] };
934 let result = build_scoped_executor(inner, &cfg, ®istry);
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, ®istry);
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 ®istry,
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"); 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 let registry = make_registry(&["builtin:bash", "builtin:read"]);
987 let (scope, _) = ToolScope::try_compile(
988 "narrow",
989 &["builtin:read".to_owned()],
990 ®istry,
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 ®istry,
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 let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
1037 let (scope, _) = ToolScope::try_compile(
1038 "narrow",
1039 &["builtin:read".to_owned()],
1040 ®istry,
1041 PatternStrictness::Strict,
1042 false,
1043 )
1044 .unwrap();
1045 assert!(scope.admits("builtin:read"));
1046 assert!(!scope.admits("builtin:write"));
1047
1048 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 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, ®istry).unwrap();
1081 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}