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#[non_exhaustive]
63#[derive(Debug, thiserror::Error)]
65pub enum ScopeError {
66 #[error("scope '{scope}': pattern '{pattern}' matched zero registered tools (dead pattern)")]
68 DeadPattern { scope: String, pattern: String },
69
70 #[error(
72 "scope '{scope}': pattern '{pattern}' matches the entire registry; use default_scope=\"general\" to opt in"
73 )]
74 AccidentallyFull { scope: String, pattern: String },
75
76 #[error("tool id '{id}' has no namespace prefix (expected '<namespace>:<id>')")]
78 UnqualifiedId { id: String },
79
80 #[error("scope '{scope}': invalid glob pattern '{pattern}': {source}")]
82 InvalidPattern {
83 scope: String,
84 pattern: String,
85 #[source]
86 source: globset::Error,
87 },
88}
89
90#[derive(Debug)]
92pub struct ScopeWarning {
93 pub scope: String,
95 pub pattern: String,
97}
98
99#[derive(Debug, Clone)]
106pub struct ToolScope {
107 pub task_type: Option<String>,
109 admitted: HashSet<String>,
111 is_full: bool,
113 patterns: Vec<String>,
115}
116
117impl ToolScope {
118 #[must_use]
130 pub fn full() -> Self {
131 Self {
132 task_type: None,
133 admitted: HashSet::new(),
134 is_full: true,
135 patterns: vec!["*".to_owned()],
136 }
137 }
138
139 pub fn try_compile<S: std::hash::BuildHasher>(
147 task_type: impl Into<String>,
148 patterns: &[String],
149 registry_ids: &HashSet<String, S>,
150 strictness: PatternStrictness,
151 is_general_scope: bool,
152 ) -> Result<(Self, Vec<ScopeWarning>), ScopeError> {
153 let task_type_str = task_type.into();
154 let mut admitted = HashSet::new();
155 let mut warnings = Vec::new();
156
157 for pattern in patterns {
158 let glob = Glob::new(pattern).map_err(|e| ScopeError::InvalidPattern {
160 scope: task_type_str.clone(),
161 pattern: pattern.clone(),
162 source: e,
163 })?;
164
165 let mut builder = GlobSetBuilder::new();
166 builder.add(glob);
167 let glob_set: GlobSet = builder.build().map_err(|e| ScopeError::InvalidPattern {
168 scope: task_type_str.clone(),
169 pattern: pattern.clone(),
170 source: e,
171 })?;
172
173 let matched: HashSet<String> = registry_ids
174 .iter()
175 .filter(|id| glob_set.is_match(id.as_str()))
176 .cloned()
177 .collect();
178
179 if !is_general_scope && matched.len() == registry_ids.len() && !registry_ids.is_empty()
181 {
182 return Err(ScopeError::AccidentallyFull {
183 scope: task_type_str,
184 pattern: pattern.clone(),
185 });
186 }
187
188 if matched.is_empty() {
189 let is_strict = is_strict_pattern(pattern, strictness);
190 if is_strict {
191 return Err(ScopeError::DeadPattern {
192 scope: task_type_str,
193 pattern: pattern.clone(),
194 });
195 }
196 warnings.push(ScopeWarning {
197 scope: task_type_str.clone(),
198 pattern: pattern.clone(),
199 });
200 }
201
202 admitted.extend(matched);
203 }
204
205 Ok((
206 Self {
207 task_type: Some(task_type_str),
208 admitted,
209 is_full: false,
210 patterns: patterns.to_vec(),
211 },
212 warnings,
213 ))
214 }
215
216 #[must_use]
227 pub fn admits(&self, qualified_tool_id: &str) -> bool {
228 self.is_full || self.admitted.contains(qualified_tool_id)
229 }
230
231 #[must_use]
235 pub fn admitted_ids(&self) -> Vec<&str> {
236 self.admitted.iter().map(String::as_str).collect()
237 }
238
239 #[must_use]
241 pub fn patterns(&self) -> &[String] {
242 &self.patterns
243 }
244
245 #[must_use]
250 pub fn re_resolve<S: std::hash::BuildHasher>(&self, registry_ids: &HashSet<String, S>) -> Self {
251 let task_type_str = self
252 .task_type
253 .clone()
254 .unwrap_or_else(|| "<unknown>".to_owned());
255 let mut admitted = HashSet::new();
256 for pattern in &self.patterns {
257 let Ok(glob) = Glob::new(pattern) else {
258 warn!(scope = %task_type_str, pattern, "re-resolve: invalid glob, skipping");
259 continue;
260 };
261 let mut builder = GlobSetBuilder::new();
262 builder.add(glob);
263 let Ok(glob_set) = builder.build() else {
264 continue;
265 };
266 let matched: HashSet<String> = registry_ids
267 .iter()
268 .filter(|id| glob_set.is_match(id.as_str()))
269 .cloned()
270 .collect();
271 admitted.extend(matched);
272 }
273 Self {
274 task_type: self.task_type.clone(),
275 admitted,
276 is_full: false,
277 patterns: self.patterns.clone(),
278 }
279 }
280}
281
282fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
284 match strictness {
285 PatternStrictness::Strict => true,
286 PatternStrictness::ProvisionalForDynamicNamespaces => {
287 pattern.starts_with("builtin:") || pattern.starts_with("skill:")
289 }
290 _ => false,
291 }
292}
293
294pub struct ScopedToolExecutor<E: ToolExecutor> {
321 inner: E,
322 scope: ArcSwap<ToolScope>,
324 scopes: HashMap<String, Arc<ToolScope>>,
326 scope_at_definition: parking_lot::Mutex<Option<String>>,
328 signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
330 audit: Option<Arc<AuditLogger>>,
332}
333
334impl<E: ToolExecutor> ScopedToolExecutor<E> {
335 #[must_use]
349 pub fn new(inner: E, initial_scope: ToolScope) -> Self {
350 Self {
351 inner,
352 scope: ArcSwap::from_pointee(initial_scope),
353 scopes: HashMap::new(),
354 scope_at_definition: parking_lot::Mutex::new(None),
355 signal_queue: None,
356 audit: None,
357 }
358 }
359
360 #[must_use]
362 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
363 self.audit = Some(audit);
364 self
365 }
366
367 #[must_use]
369 pub fn with_signal_queue(mut self, queue: crate::policy_gate::RiskSignalQueue) -> Self {
370 self.signal_queue = Some(queue);
371 self
372 }
373
374 pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
376 self.scopes.insert(name.into(), Arc::new(scope));
377 }
378
379 pub fn set_scope_for_task(&self, task_type: &str) -> bool {
381 if let Some(scope) = self.scopes.get(task_type) {
382 self.scope.store(Arc::clone(scope));
383 true
384 } else {
385 false
386 }
387 }
388
389 pub fn set_scope(&self, scope: ToolScope) {
391 self.scope.store(Arc::new(scope));
392 }
393
394 #[must_use]
412 pub fn scope_for_task(&self, task_type: &str) -> Option<Vec<String>> {
413 self.scopes.get(task_type).map(|s| {
414 if s.is_full {
415 vec!["*".to_owned()]
416 } else {
417 s.admitted_ids().iter().map(|s| (*s).to_owned()).collect()
418 }
419 })
420 }
421
422 #[must_use]
424 pub fn scope_at_definition_name(&self) -> Option<String> {
425 self.scope_at_definition.lock().clone()
426 }
427
428 #[must_use]
430 pub fn active_scope_name(&self) -> Option<String> {
431 self.scope.load().task_type.clone()
432 }
433}
434
435impl<E: ToolExecutor> ToolExecutor for ScopedToolExecutor<E> {
436 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
438 self.inner.execute(response).await
439 }
440
441 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
442 self.inner.execute_confirmed(response).await
443 }
444
445 fn tool_definitions(&self) -> Vec<ToolDef> {
449 let scope = self.scope.load();
450 self.scope_at_definition.lock().clone_from(&scope.task_type);
451 self.inner
452 .tool_definitions()
453 .into_iter()
454 .filter(|d| {
455 let id = d.id.as_ref();
456 let scope_id: String;
457 let qualified = if id.contains(':') {
458 id
459 } else {
460 scope_id = format!("builtin:{id}");
461 scope_id.as_str()
462 };
463 scope.admits(qualified)
464 })
465 .collect()
466 }
467
468 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
473 let scope = self.scope.load();
474 let tool_id = call.tool_id.as_str();
475 let qualified_id: String;
479 let scope_id = if tool_id.contains(':') {
480 tool_id
481 } else {
482 qualified_id = format!("builtin:{tool_id}");
483 qualified_id.as_str()
484 };
485
486 if !scope.admits(scope_id) {
487 let scope_name = scope.task_type.clone();
488 let scope_def = self.scope_at_definition.lock().clone();
489 tracing::debug!(
490 tool_id,
491 scope = ?scope_name,
492 "ScopedToolExecutor: out-of-scope rejection"
493 );
494 if let Some(ref q) = self.signal_queue {
496 q.lock().push(3);
497 }
498 if let Some(ref audit) = self.audit {
500 let entry = AuditEntry {
501 timestamp: chrono_now(),
502 tool: call.tool_id.clone(),
503 command: String::new(),
504 result: AuditResult::Blocked {
505 reason: "out_of_scope".to_owned(),
506 },
507 duration_ms: 0,
508 error_category: Some("out_of_scope".to_owned()),
509 error_domain: Some("security".to_owned()),
510 error_phase: None,
511 claim_source: None,
512 mcp_server_id: None,
513 injection_flagged: false,
514 embedding_anomalous: false,
515 cross_boundary_mcp_to_acp: false,
516 adversarial_policy_decision: None,
517 exit_code: None,
518 truncated: false,
519 caller_id: call.caller_id.clone(),
520 skill_name: call.skill_name.clone(),
521 policy_match: None,
522 correlation_id: None,
523 vigil_risk: None,
524 execution_env: None,
525 resolved_cwd: None,
526 scope_at_definition: scope_def,
527 scope_at_dispatch: scope_name,
528 };
529 audit.log(&entry).await;
530 }
531 return Err(ToolError::OutOfScope {
532 tool_id: tool_id.to_owned(),
533 task_type: scope.task_type.clone(),
534 });
535 }
536
537 self.inner.execute_tool_call(call).await
538 }
539
540 async fn execute_tool_call_confirmed(
541 &self,
542 call: &ToolCall,
543 ) -> Result<Option<ToolOutput>, ToolError> {
544 let scope = self.scope.load();
545 let tool_id = call.tool_id.as_str();
546 let qualified_id: String;
547 let scope_id = if tool_id.contains(':') {
548 tool_id
549 } else {
550 qualified_id = format!("builtin:{tool_id}");
551 qualified_id.as_str()
552 };
553 if !scope.admits(scope_id) {
554 let scope_name = scope.task_type.clone();
555 let scope_def = self.scope_at_definition.lock().clone();
556 if let Some(ref q) = self.signal_queue {
557 q.lock().push(3);
558 }
559 if let Some(ref audit) = self.audit {
560 let entry = AuditEntry {
561 timestamp: chrono_now(),
562 tool: call.tool_id.clone(),
563 command: String::new(),
564 result: AuditResult::Blocked {
565 reason: "out_of_scope".to_owned(),
566 },
567 duration_ms: 0,
568 error_category: Some("out_of_scope".to_owned()),
569 error_domain: Some("security".to_owned()),
570 error_phase: None,
571 claim_source: None,
572 mcp_server_id: None,
573 injection_flagged: false,
574 embedding_anomalous: false,
575 cross_boundary_mcp_to_acp: false,
576 adversarial_policy_decision: None,
577 exit_code: None,
578 truncated: false,
579 caller_id: call.caller_id.clone(),
580 skill_name: call.skill_name.clone(),
581 policy_match: None,
582 correlation_id: None,
583 vigil_risk: None,
584 execution_env: None,
585 resolved_cwd: None,
586 scope_at_definition: scope_def,
587 scope_at_dispatch: scope_name,
588 };
589 audit.log(&entry).await;
590 }
591 return Err(ToolError::OutOfScope {
592 tool_id: tool_id.to_owned(),
593 task_type: scope.task_type.clone(),
594 });
595 }
596 self.inner.execute_tool_call_confirmed(call).await
597 }
598
599 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
600 self.inner.set_skill_env(env);
601 }
602
603 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
604 self.inner.set_effective_trust(level);
605 }
606
607 fn is_tool_retryable(&self, tool_id: &str) -> bool {
608 self.inner.is_tool_retryable(tool_id)
609 }
610
611 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
612 self.inner.is_tool_speculatable(tool_id)
613 }
614}
615
616pub fn build_scoped_executor<E: ToolExecutor, S: std::hash::BuildHasher>(
644 inner: E,
645 cfg: &CapabilityScopesConfig,
646 registry_ids: &HashSet<String, S>,
647) -> Result<ScopedToolExecutor<E>, ScopeError> {
648 let default_scope_name = &cfg.default_scope;
649 let strictness = cfg.pattern_strictness;
650
651 let initial_scope = ToolScope::full();
653 let mut executor = ScopedToolExecutor::new(inner, initial_scope);
654
655 for (task_type, scope_cfg) in &cfg.scopes {
656 let is_general = task_type == default_scope_name;
657 let (scope, warnings) = ToolScope::try_compile(
658 task_type.clone(),
659 &scope_cfg.patterns,
660 registry_ids,
661 strictness,
662 is_general,
663 )?;
664 for w in &warnings {
665 warn!(
666 scope = %w.scope,
667 pattern = %w.pattern,
668 "capability scope: provisional zero-match pattern (will re-resolve on dynamic registration)"
669 );
670 }
671 executor.register_scope(task_type.clone(), scope);
672 }
673
674 if cfg.scopes.contains_key(default_scope_name.as_str()) {
676 executor.set_scope_for_task(default_scope_name);
677 }
678
679 Ok(executor)
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::executor::ToolCall;
686 use crate::registry::{InvocationHint, ToolDef};
687 use std::assert_matches;
688 use zeph_common::ToolName;
689 use zeph_config::{CapabilityScopesConfig, PatternStrictness, ScopeConfig};
690
691 fn make_registry(ids: &[&str]) -> HashSet<String> {
692 ids.iter().map(|s| (*s).to_owned()).collect()
693 }
694
695 struct NullExecutor {
696 defs: Vec<ToolDef>,
697 }
698
699 impl ToolExecutor for NullExecutor {
700 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
701 Ok(None)
702 }
703
704 fn tool_definitions(&self) -> Vec<ToolDef> {
705 self.defs.clone()
706 }
707
708 async fn execute_tool_call(
709 &self,
710 call: &ToolCall,
711 ) -> Result<Option<ToolOutput>, ToolError> {
712 Ok(Some(ToolOutput {
713 tool_name: call.tool_id.clone(),
714 summary: "ok".to_owned(),
715 blocks_executed: 1,
716 filter_stats: None,
717 diff: None,
718 streamed: false,
719 terminal_id: None,
720 locations: None,
721 raw_response: None,
722 claim_source: None,
723 }))
724 }
725 }
726
727 fn null_def(id: &str) -> ToolDef {
728 ToolDef {
729 id: id.to_owned().into(),
730 description: "test tool".into(),
731 schema: schemars::schema_for!(String),
732 invocation: InvocationHint::ToolCall,
733 output_schema: None,
734 server_id: None,
735 }
736 }
737
738 fn make_call(tool_id: &str) -> ToolCall {
739 ToolCall {
740 tool_id: ToolName::new(tool_id),
741 params: serde_json::Map::new(),
742 caller_id: None,
743 context: None,
744
745 tool_call_id: String::new(),
746 skill_name: None,
747 }
748 }
749
750 #[test]
751 fn full_scope_admits_everything() {
752 let scope = ToolScope::full();
753 assert!(scope.admits("builtin:shell"));
754 assert!(scope.admits("mcp:server/tool"));
755 assert!(scope.admits("builtin:read"));
756 }
757
758 #[test]
759 fn compiled_scope_admits_only_matched() {
760 let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
761 let patterns = vec!["builtin:read".to_owned()];
762 let (scope, warnings) = ToolScope::try_compile(
763 "narrow",
764 &patterns,
765 ®istry,
766 PatternStrictness::Strict,
767 false,
768 )
769 .unwrap();
770 assert!(warnings.is_empty());
771 assert!(scope.admits("builtin:read"));
772 assert!(!scope.admits("builtin:shell"));
773 assert!(!scope.admits("builtin:write"));
774 }
775
776 #[test]
777 fn dead_pattern_strict_returns_error() {
778 let registry = make_registry(&["builtin:shell"]);
779 let patterns = vec!["builtin:nonexistent".to_owned()];
780 let result = ToolScope::try_compile(
781 "test",
782 &patterns,
783 ®istry,
784 PatternStrictness::Strict,
785 false,
786 );
787 assert!(
788 matches!(result, Err(ScopeError::DeadPattern { .. })),
789 "expected DeadPattern, got {result:?}"
790 );
791 }
792
793 #[test]
794 fn dead_pattern_provisional_returns_warning() {
795 let registry = make_registry(&["builtin:shell"]);
796 let patterns = vec!["mcp:server/nonexistent".to_owned()];
797 let result = ToolScope::try_compile(
798 "test",
799 &patterns,
800 ®istry,
801 PatternStrictness::ProvisionalForDynamicNamespaces,
802 false,
803 );
804 assert!(result.is_ok());
805 let (_, warnings) = result.unwrap();
806 assert_eq!(warnings.len(), 1);
807 }
808
809 #[test]
810 fn accidentally_full_pattern_returns_error() {
811 let registry = make_registry(&["builtin:shell", "builtin:read"]);
812 let patterns = vec!["*".to_owned()];
813 let result = ToolScope::try_compile(
814 "test",
815 &patterns,
816 ®istry,
817 PatternStrictness::Strict,
818 false, );
820 assert!(
821 matches!(result, Err(ScopeError::AccidentallyFull { .. })),
822 "expected AccidentallyFull for non-general scope with '*'"
823 );
824 }
825
826 #[test]
827 fn general_scope_allows_wildcard() {
828 let registry = make_registry(&["builtin:shell", "builtin:read"]);
829 let patterns = vec!["*".to_owned()];
830 let result = ToolScope::try_compile(
831 "general",
832 &patterns,
833 ®istry,
834 PatternStrictness::Strict,
835 true, );
837 assert!(result.is_ok());
838 }
839
840 #[tokio::test]
841 async fn executor_rejects_out_of_scope_call() {
842 let registry = make_registry(&["builtin:shell", "builtin:read"]);
843 let (scope, _) = ToolScope::try_compile(
844 "narrow",
845 &["builtin:read".to_owned()],
846 ®istry,
847 PatternStrictness::Strict,
848 false,
849 )
850 .unwrap();
851 let inner = NullExecutor {
852 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
853 };
854 let executor = ScopedToolExecutor::new(inner, scope);
855 let call = make_call("builtin:shell");
856 let result = executor.execute_tool_call(&call).await;
857 assert_matches!(result, Err(ToolError::OutOfScope { .. }));
858 }
859
860 #[tokio::test]
861 async fn executor_allows_in_scope_call() {
862 let registry = make_registry(&["builtin:shell", "builtin:read"]);
863 let (scope, _) = ToolScope::try_compile(
864 "narrow",
865 &["builtin:read".to_owned()],
866 ®istry,
867 PatternStrictness::Strict,
868 false,
869 )
870 .unwrap();
871 let inner = NullExecutor {
872 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
873 };
874 let executor = ScopedToolExecutor::new(inner, scope);
875 let call = make_call("builtin:read");
876 let result = executor.execute_tool_call(&call).await;
877 assert!(result.is_ok());
878 }
879
880 #[test]
881 fn tool_definitions_filtered_by_scope() {
882 let registry = make_registry(&["builtin:shell", "builtin:read"]);
883 let (scope, _) = ToolScope::try_compile(
884 "narrow",
885 &["builtin:read".to_owned()],
886 ®istry,
887 PatternStrictness::Strict,
888 false,
889 )
890 .unwrap();
891 let inner = NullExecutor {
892 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
893 };
894 let executor = ScopedToolExecutor::new(inner, scope);
895 let defs = executor.tool_definitions();
896 assert_eq!(defs.len(), 1);
897 assert_eq!(defs[0].id.as_ref(), "builtin:read");
898 }
899
900 #[tokio::test]
901 async fn unnamespaced_tool_id_admitted_via_builtin_prefix() {
902 let registry = make_registry(&["builtin:bash", "builtin:read"]);
905 let (scope, _) = ToolScope::try_compile(
906 "narrow",
907 &["builtin:bash".to_owned()],
908 ®istry,
909 PatternStrictness::Strict,
910 false,
911 )
912 .unwrap();
913 let inner = NullExecutor {
914 defs: vec![null_def("bash"), null_def("read")],
915 };
916 let executor = ScopedToolExecutor::new(inner, scope);
917 let call = make_call("bash");
919 let result = executor.execute_tool_call(&call).await;
920 assert!(
921 result.is_ok(),
922 "builtin tool with unqualified id must be admitted"
923 );
924 let call_read = make_call("read");
926 let result_read = executor.execute_tool_call(&call_read).await;
927 assert!(
928 matches!(result_read, Err(ToolError::OutOfScope { .. })),
929 "out-of-scope built-in tool must be rejected"
930 );
931 }
932
933 #[test]
934 fn build_scoped_executor_accepts_unqualified_registry_id() {
935 let cfg = CapabilityScopesConfig::default();
938 let registry = make_registry(&["shell"]); let inner = NullExecutor { defs: vec![] };
940 let result = build_scoped_executor(inner, &cfg, ®istry);
941 assert!(
942 result.is_ok(),
943 "build_scoped_executor must accept unqualified registry ids"
944 );
945 }
946
947 #[test]
948 fn build_scoped_executor_with_builtin_prefix_and_glob() {
949 let mut cfg = CapabilityScopesConfig::default();
950 cfg.scopes.insert(
951 "general".to_owned(),
952 ScopeConfig {
953 patterns: vec!["builtin:*".to_owned()],
954 },
955 );
956 cfg.default_scope = "general".to_owned();
957 let registry = make_registry(&["builtin:bash", "builtin:read", "builtin:fetch"]);
958 let inner = NullExecutor { defs: vec![] };
959 let result = build_scoped_executor(inner, &cfg, ®istry);
960 assert!(
961 result.is_ok(),
962 "builtin:* glob must match all builtin tools"
963 );
964 }
965
966 #[tokio::test]
967 async fn unqualified_tool_out_of_scope_rejected() {
968 let registry = make_registry(&["builtin:bash", "builtin:read"]);
969 let (scope, _) = ToolScope::try_compile(
970 "narrow",
971 &["builtin:read".to_owned()],
972 ®istry,
973 PatternStrictness::Strict,
974 false,
975 )
976 .unwrap();
977 let inner = NullExecutor {
978 defs: vec![null_def("bash"), null_def("read")],
979 };
980 let executor = ScopedToolExecutor::new(inner, scope);
981 let call = make_call("bash"); let result = executor.execute_tool_call(&call).await;
983 assert!(
984 matches!(result, Err(ToolError::OutOfScope { .. })),
985 "unqualified id not in scope must be rejected after normalization"
986 );
987 }
988
989 #[test]
990 fn tool_definitions_filtered_by_scope_with_unqualified_ids() {
991 let registry = make_registry(&["builtin:bash", "builtin:read"]);
993 let (scope, _) = ToolScope::try_compile(
994 "narrow",
995 &["builtin:read".to_owned()],
996 ®istry,
997 PatternStrictness::Strict,
998 false,
999 )
1000 .unwrap();
1001 let inner = NullExecutor {
1002 defs: vec![null_def("bash"), null_def("read")],
1003 };
1004 let executor = ScopedToolExecutor::new(inner, scope);
1005 let defs = executor.tool_definitions();
1006 assert_eq!(defs.len(), 1);
1007 assert_eq!(defs[0].id.as_ref(), "read");
1008 }
1009
1010 #[test]
1011 fn scope_for_task_returns_ids() {
1012 let registry = make_registry(&["builtin:shell", "builtin:read"]);
1013 let (scope, _) = ToolScope::try_compile(
1014 "narrow",
1015 &["builtin:read".to_owned()],
1016 ®istry,
1017 PatternStrictness::Strict,
1018 false,
1019 )
1020 .unwrap();
1021 let inner = NullExecutor { defs: vec![] };
1022 let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
1023 executor.register_scope("narrow", scope);
1024 let ids = executor.scope_for_task("narrow");
1025 assert!(ids.is_some());
1026 let ids = ids.unwrap();
1027 assert!(ids.contains(&"builtin:read".to_owned()));
1028 assert!(!ids.contains(&"builtin:shell".to_owned()));
1029 }
1030
1031 #[test]
1032 fn scope_for_task_returns_none_for_unknown() {
1033 let inner = NullExecutor { defs: vec![] };
1034 let executor = ScopedToolExecutor::new(inner, ToolScope::full());
1035 assert!(executor.scope_for_task("does_not_exist").is_none());
1036 }
1037
1038 #[test]
1039 fn re_resolve_updates_admitted_set() {
1040 let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
1043 let (scope, _) = ToolScope::try_compile(
1044 "narrow",
1045 &["builtin:read".to_owned()],
1046 ®istry,
1047 PatternStrictness::Strict,
1048 false,
1049 )
1050 .unwrap();
1051 assert!(scope.admits("builtin:read"));
1052 assert!(!scope.admits("builtin:write"));
1053
1054 let mut new_registry = registry.clone();
1056 new_registry.insert("builtin:write".to_owned());
1057 let updated = scope.re_resolve(&new_registry);
1058 assert!(updated.admits("builtin:read"));
1059 assert!(!updated.admits("builtin:write"));
1061 }
1062
1063 #[test]
1064 fn build_from_config_with_scopes() {
1065 let mut scopes = std::collections::HashMap::new();
1066 scopes.insert(
1067 "general".to_owned(),
1068 ScopeConfig {
1069 patterns: vec!["*".to_owned()],
1070 },
1071 );
1072 scopes.insert(
1073 "narrow".to_owned(),
1074 ScopeConfig {
1075 patterns: vec!["builtin:read".to_owned()],
1076 },
1077 );
1078 let cfg = CapabilityScopesConfig {
1079 default_scope: "general".to_owned(),
1080 strict: false,
1081 pattern_strictness: PatternStrictness::Strict,
1082 scopes,
1083 };
1084 let registry = make_registry(&["builtin:shell", "builtin:read"]);
1085 let inner = NullExecutor { defs: vec![] };
1086 let executor = build_scoped_executor(inner, &cfg, ®istry).unwrap();
1087 let narrow_ids = executor.scope_for_task("narrow");
1089 assert!(narrow_ids.is_some());
1090 let ids = narrow_ids.unwrap();
1091 assert!(ids.contains(&"builtin:read".to_owned()));
1092 }
1093}