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 #[must_use]
157 pub fn empty() -> Self {
158 Self {
159 task_type: None,
160 admitted: HashSet::new(),
161 is_full: false,
162 patterns: Vec::new(),
163 }
164 }
165
166 pub fn try_compile<S: std::hash::BuildHasher>(
174 task_type: impl Into<String>,
175 patterns: &[String],
176 registry_ids: &HashSet<String, S>,
177 strictness: PatternStrictness,
178 is_general_scope: bool,
179 ) -> Result<(Self, Vec<ScopeWarning>), ScopeError> {
180 let task_type_str = task_type.into();
181 let mut admitted = HashSet::new();
182 let mut warnings = Vec::new();
183
184 for pattern in patterns {
185 let glob = Glob::new(pattern).map_err(|e| ScopeError::InvalidPattern {
187 scope: task_type_str.clone(),
188 pattern: pattern.clone(),
189 source: e,
190 })?;
191
192 let mut builder = GlobSetBuilder::new();
193 builder.add(glob);
194 let glob_set: GlobSet = builder.build().map_err(|e| ScopeError::InvalidPattern {
195 scope: task_type_str.clone(),
196 pattern: pattern.clone(),
197 source: e,
198 })?;
199
200 let matched: HashSet<String> = registry_ids
201 .iter()
202 .filter(|id| glob_set.is_match(id.as_str()))
203 .cloned()
204 .collect();
205
206 if !is_general_scope && matched.len() == registry_ids.len() && !registry_ids.is_empty()
208 {
209 return Err(ScopeError::AccidentallyFull {
210 scope: task_type_str,
211 pattern: pattern.clone(),
212 });
213 }
214
215 if matched.is_empty() {
216 let is_strict = is_strict_pattern(pattern, strictness);
217 if is_strict {
218 return Err(ScopeError::DeadPattern {
219 scope: task_type_str,
220 pattern: pattern.clone(),
221 });
222 }
223 warnings.push(ScopeWarning {
224 scope: task_type_str.clone(),
225 pattern: pattern.clone(),
226 });
227 }
228
229 admitted.extend(matched);
230 }
231
232 Ok((
233 Self {
234 task_type: Some(task_type_str),
235 admitted,
236 is_full: false,
237 patterns: patterns.to_vec(),
238 },
239 warnings,
240 ))
241 }
242
243 #[must_use]
254 pub fn admits(&self, qualified_tool_id: &str) -> bool {
255 self.is_full || self.admitted.contains(qualified_tool_id)
256 }
257
258 #[must_use]
262 pub fn admitted_ids(&self) -> Vec<&str> {
263 self.admitted.iter().map(String::as_str).collect()
264 }
265
266 #[must_use]
268 pub fn patterns(&self) -> &[String] {
269 &self.patterns
270 }
271
272 #[must_use]
277 pub fn re_resolve<S: std::hash::BuildHasher>(&self, registry_ids: &HashSet<String, S>) -> Self {
278 let task_type_str = self
279 .task_type
280 .clone()
281 .unwrap_or_else(|| "<unknown>".to_owned());
282 let mut admitted = HashSet::new();
283 for pattern in &self.patterns {
284 let Ok(glob) = Glob::new(pattern) else {
285 warn!(scope = %task_type_str, pattern, "re-resolve: invalid glob, skipping");
286 continue;
287 };
288 let mut builder = GlobSetBuilder::new();
289 builder.add(glob);
290 let Ok(glob_set) = builder.build() else {
291 continue;
292 };
293 let matched: HashSet<String> = registry_ids
294 .iter()
295 .filter(|id| glob_set.is_match(id.as_str()))
296 .cloned()
297 .collect();
298 admitted.extend(matched);
299 }
300 Self {
301 task_type: self.task_type.clone(),
302 admitted,
303 is_full: false,
304 patterns: self.patterns.clone(),
305 }
306 }
307}
308
309fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
311 match strictness {
312 PatternStrictness::Strict => true,
313 PatternStrictness::ProvisionalForDynamicNamespaces => {
314 pattern.starts_with("builtin:") || pattern.starts_with("skill:")
316 }
317 _ => false,
318 }
319}
320
321pub struct ScopedToolExecutor<E: ToolExecutor> {
349 inner: E,
350 scope: ArcSwap<ToolScope>,
352 scopes: HashMap<String, Arc<ToolScope>>,
354 scope_at_definition: parking_lot::Mutex<Option<String>>,
356 signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
358 audit: Option<Arc<AuditLogger>>,
360}
361
362impl<E: ToolExecutor> ScopedToolExecutor<E> {
363 #[must_use]
378 pub fn new(inner: E, initial_scope: ToolScope) -> Self {
379 Self {
380 inner,
381 scope: ArcSwap::from_pointee(initial_scope),
382 scopes: HashMap::new(),
383 scope_at_definition: parking_lot::Mutex::new(None),
384 signal_queue: None,
385 audit: None,
386 }
387 }
388
389 #[must_use]
391 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
392 self.audit = Some(audit);
393 self
394 }
395
396 #[must_use]
398 pub fn with_signal_queue(mut self, queue: crate::policy_gate::RiskSignalQueue) -> Self {
399 self.signal_queue = Some(queue);
400 self
401 }
402
403 pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
405 self.scopes.insert(name.into(), Arc::new(scope));
406 }
407
408 pub fn set_scope_for_task(&self, task_type: &str) -> bool {
410 if let Some(scope) = self.scopes.get(task_type) {
411 self.scope.store(Arc::clone(scope));
412 true
413 } else {
414 false
415 }
416 }
417
418 pub fn set_scope(&self, scope: ToolScope) {
420 self.scope.store(Arc::new(scope));
421 }
422
423 #[must_use]
442 pub fn scope_for_task(&self, task_type: &str) -> Option<Vec<String>> {
443 self.scopes.get(task_type).map(|s| {
444 if s.is_full {
445 vec!["*".to_owned()]
446 } else {
447 s.admitted_ids().iter().map(|s| (*s).to_owned()).collect()
448 }
449 })
450 }
451
452 #[must_use]
454 pub fn scope_at_definition_name(&self) -> Option<String> {
455 self.scope_at_definition.lock().clone()
456 }
457
458 #[must_use]
460 pub fn active_scope_name(&self) -> Option<String> {
461 self.scope.load().task_type.clone()
462 }
463}
464
465impl<E: ToolExecutor> ToolExecutor for ScopedToolExecutor<E> {
466 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
468 self.inner.execute(response).await
469 }
470
471 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
472 self.inner.execute_confirmed(response).await
473 }
474
475 fn tool_definitions(&self) -> Vec<ToolDef> {
479 let scope = self.scope.load();
480 self.scope_at_definition.lock().clone_from(&scope.task_type);
481 self.inner
482 .tool_definitions()
483 .into_iter()
484 .filter(|d| {
485 let id = d.id.as_ref();
486 let scope_id: String;
487 let qualified = if id.contains(':') {
488 id
489 } else {
490 scope_id = format!("builtin:{id}");
491 scope_id.as_str()
492 };
493 scope.admits(qualified)
494 })
495 .collect()
496 }
497
498 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
503 let scope = self.scope.load();
504 let tool_id = call.tool_id.as_str();
505 let qualified_id: String;
509 let scope_id = if tool_id.contains(':') {
510 tool_id
511 } else {
512 qualified_id = format!("builtin:{tool_id}");
513 qualified_id.as_str()
514 };
515
516 if !scope.admits(scope_id) {
517 let scope_name = scope.task_type.clone();
518 let scope_def = self.scope_at_definition.lock().clone();
519 tracing::debug!(
520 tool_id,
521 scope = ?scope_name,
522 "ScopedToolExecutor: out-of-scope rejection"
523 );
524 if let Some(ref q) = self.signal_queue {
526 q.lock().push(3);
527 }
528 if let Some(ref audit) = self.audit {
530 let entry = AuditEntry {
531 source_kind: None,
532 trust_level: None,
533 timestamp: chrono_now(),
534 tool: call.tool_id.clone(),
535 command: String::new(),
536 result: AuditResult::Blocked {
537 reason: "out_of_scope".to_owned(),
538 },
539 duration_ms: 0,
540 error_category: Some("out_of_scope".to_owned()),
541 error_domain: Some("security".to_owned()),
542 error_phase: None,
543 claim_source: None,
544 mcp_server_id: None,
545 injection_flagged: false,
546 embedding_anomalous: false,
547 cross_boundary_mcp_to_acp: false,
548 adversarial_policy_decision: None,
549 exit_code: None,
550 truncated: false,
551 caller_id: call.caller_id.clone(),
552 skill_name: call.skill_name.clone(),
553 policy_match: None,
554 correlation_id: None,
555 vigil_risk: None,
556 execution_env: None,
557 resolved_cwd: None,
558 scope_at_definition: scope_def,
559 scope_at_dispatch: scope_name,
560 };
561 audit.log(&entry).await;
562 }
563 return Err(ToolError::OutOfScope {
564 tool_id: tool_id.to_owned(),
565 task_type: scope.task_type.clone(),
566 });
567 }
568
569 self.inner.execute_tool_call(call).await
570 }
571
572 async fn execute_tool_call_confirmed(
573 &self,
574 call: &ToolCall,
575 ) -> Result<Option<ToolOutput>, ToolError> {
576 let scope = self.scope.load();
577 let tool_id = call.tool_id.as_str();
578 let qualified_id: String;
579 let scope_id = if tool_id.contains(':') {
580 tool_id
581 } else {
582 qualified_id = format!("builtin:{tool_id}");
583 qualified_id.as_str()
584 };
585 if !scope.admits(scope_id) {
586 let scope_name = scope.task_type.clone();
587 let scope_def = self.scope_at_definition.lock().clone();
588 if let Some(ref q) = self.signal_queue {
589 q.lock().push(3);
590 }
591 if let Some(ref audit) = self.audit {
592 let entry = AuditEntry {
593 source_kind: None,
594 trust_level: None,
595 timestamp: chrono_now(),
596 tool: call.tool_id.clone(),
597 command: String::new(),
598 result: AuditResult::Blocked {
599 reason: "out_of_scope".to_owned(),
600 },
601 duration_ms: 0,
602 error_category: Some("out_of_scope".to_owned()),
603 error_domain: Some("security".to_owned()),
604 error_phase: None,
605 claim_source: None,
606 mcp_server_id: None,
607 injection_flagged: false,
608 embedding_anomalous: false,
609 cross_boundary_mcp_to_acp: false,
610 adversarial_policy_decision: None,
611 exit_code: None,
612 truncated: false,
613 caller_id: call.caller_id.clone(),
614 skill_name: call.skill_name.clone(),
615 policy_match: None,
616 correlation_id: None,
617 vigil_risk: None,
618 execution_env: None,
619 resolved_cwd: None,
620 scope_at_definition: scope_def,
621 scope_at_dispatch: scope_name,
622 };
623 audit.log(&entry).await;
624 }
625 return Err(ToolError::OutOfScope {
626 tool_id: tool_id.to_owned(),
627 task_type: scope.task_type.clone(),
628 });
629 }
630 self.inner.execute_tool_call_confirmed(call).await
631 }
632
633 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
634 self.inner.set_skill_env(env);
635 }
636
637 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
638 self.inner.set_effective_trust(level);
639 }
640
641 fn is_tool_retryable(&self, tool_id: &str) -> bool {
642 self.inner.is_tool_retryable(tool_id)
643 }
644
645 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
646 self.inner.is_tool_speculatable(tool_id)
647 }
648
649 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
650 self.inner.checkpoint_undo(n)
651 }
652
653 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
654 self.inner.checkpoint_redo()
655 }
656
657 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
658 self.inner.checkpoint_list()
659 }
660
661 fn requires_confirmation(&self, call: &ToolCall) -> bool {
662 self.inner.requires_confirmation(call)
663 }
664}
665
666pub fn build_scoped_executor<E: ToolExecutor, S: std::hash::BuildHasher>(
695 inner: E,
696 cfg: &CapabilityScopesConfig,
697 registry_ids: &HashSet<String, S>,
698) -> Result<ScopedToolExecutor<E>, ScopeError> {
699 let default_scope_name = &cfg.default_scope;
700 let strictness = cfg.pattern_strictness;
701
702 let initial_scope = ToolScope::full();
704 let mut executor = ScopedToolExecutor::new(inner, initial_scope);
705
706 for (task_type, scope_cfg) in &cfg.scopes {
707 let is_general = task_type == default_scope_name;
708 let (scope, warnings) = ToolScope::try_compile(
709 task_type.clone(),
710 &scope_cfg.patterns,
711 registry_ids,
712 strictness,
713 is_general,
714 )?;
715 for w in &warnings {
716 warn!(
717 scope = %w.scope,
718 pattern = %w.pattern,
719 "capability scope: provisional zero-match pattern (will re-resolve on dynamic registration)"
720 );
721 }
722 executor.register_scope(task_type.clone(), scope);
723 }
724
725 if cfg.scopes.contains_key(default_scope_name.as_str()) {
727 executor.set_scope_for_task(default_scope_name);
728 }
729
730 Ok(executor)
731}
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use crate::executor::ToolCall;
737 use crate::registry::{InvocationHint, ToolDef};
738 use std::assert_matches;
739 use zeph_common::ToolName;
740 use zeph_config::{CapabilityScopesConfig, PatternStrictness, ScopeConfig};
741
742 fn make_registry(ids: &[&str]) -> HashSet<String> {
743 ids.iter().map(|s| (*s).to_owned()).collect()
744 }
745
746 struct NullExecutor {
747 defs: Vec<ToolDef>,
748 }
749
750 impl ToolExecutor for NullExecutor {
751 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
752 Ok(None)
753 }
754
755 fn tool_definitions(&self) -> Vec<ToolDef> {
756 self.defs.clone()
757 }
758
759 async fn execute_tool_call(
760 &self,
761 call: &ToolCall,
762 ) -> Result<Option<ToolOutput>, ToolError> {
763 Ok(Some(ToolOutput {
764 tool_name: call.tool_id.clone(),
765 summary: "ok".to_owned(),
766 blocks_executed: 1,
767 filter_stats: None,
768 diff: None,
769 streamed: false,
770 terminal_id: None,
771 locations: None,
772 raw_response: None,
773 claim_source: None,
774 ..Default::default()
775 }))
776 }
777
778 crate::tool_executor_no_inner_defaults!();
779 }
780
781 struct CheckpointingExecutor;
782
783 impl ToolExecutor for CheckpointingExecutor {
784 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
785 Ok(None)
786 }
787 fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
788 crate::executor::CheckpointActionResult {
789 supported: true,
790 message: "stub".into(),
791 reverted_commands: n,
792 ..Default::default()
793 }
794 }
795 fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
796 crate::executor::CheckpointActionResult {
797 supported: true,
798 message: "stub".into(),
799 ..Default::default()
800 }
801 }
802 fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
803 crate::executor::CheckpointListResult {
804 supported: true,
805 ..Default::default()
806 }
807 }
808 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
809 true
810 }
811 async fn execute_tool_call_confirmed(
812 &self,
813 call: &ToolCall,
814 ) -> Result<Option<ToolOutput>, ToolError> {
815 self.execute_tool_call(call).await
816 }
817 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
818 false
819 }
820 }
821
822 fn null_def(id: &str) -> ToolDef {
823 ToolDef {
824 id: id.to_owned().into(),
825 description: "test tool".into(),
826 schema: schemars::schema_for!(String),
827 invocation: InvocationHint::ToolCall,
828 output_schema: None,
829 server_id: None,
830 }
831 }
832
833 fn make_call(tool_id: &str) -> ToolCall {
834 ToolCall {
835 tool_id: ToolName::new(tool_id),
836 params: serde_json::Map::new(),
837 caller_id: None,
838 context: None,
839
840 tool_call_id: String::new(),
841 skill_name: None,
842 }
843 }
844
845 #[test]
846 fn full_scope_admits_everything() {
847 let scope = ToolScope::full();
848 assert!(scope.admits("builtin:shell"));
849 assert!(scope.admits("mcp:server/tool"));
850 assert!(scope.admits("builtin:read"));
851 }
852
853 #[test]
854 fn compiled_scope_admits_only_matched() {
855 let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
856 let patterns = vec!["builtin:read".to_owned()];
857 let (scope, warnings) = ToolScope::try_compile(
858 "narrow",
859 &patterns,
860 ®istry,
861 PatternStrictness::Strict,
862 false,
863 )
864 .unwrap();
865 assert!(warnings.is_empty());
866 assert!(scope.admits("builtin:read"));
867 assert!(!scope.admits("builtin:shell"));
868 assert!(!scope.admits("builtin:write"));
869 }
870
871 #[test]
872 fn dead_pattern_strict_returns_error() {
873 let registry = make_registry(&["builtin:shell"]);
874 let patterns = vec!["builtin:nonexistent".to_owned()];
875 let result = ToolScope::try_compile(
876 "test",
877 &patterns,
878 ®istry,
879 PatternStrictness::Strict,
880 false,
881 );
882 assert!(
883 matches!(result, Err(ScopeError::DeadPattern { .. })),
884 "expected DeadPattern, got {result:?}"
885 );
886 }
887
888 #[test]
889 fn dead_pattern_provisional_returns_warning() {
890 let registry = make_registry(&["builtin:shell"]);
891 let patterns = vec!["mcp:server/nonexistent".to_owned()];
892 let result = ToolScope::try_compile(
893 "test",
894 &patterns,
895 ®istry,
896 PatternStrictness::ProvisionalForDynamicNamespaces,
897 false,
898 );
899 assert!(result.is_ok());
900 let (_, warnings) = result.unwrap();
901 assert_eq!(warnings.len(), 1);
902 }
903
904 #[test]
905 fn accidentally_full_pattern_returns_error() {
906 let registry = make_registry(&["builtin:shell", "builtin:read"]);
907 let patterns = vec!["*".to_owned()];
908 let result = ToolScope::try_compile(
909 "test",
910 &patterns,
911 ®istry,
912 PatternStrictness::Strict,
913 false, );
915 assert!(
916 matches!(result, Err(ScopeError::AccidentallyFull { .. })),
917 "expected AccidentallyFull for non-general scope with '*'"
918 );
919 }
920
921 #[test]
922 fn general_scope_allows_wildcard() {
923 let registry = make_registry(&["builtin:shell", "builtin:read"]);
924 let patterns = vec!["*".to_owned()];
925 let result = ToolScope::try_compile(
926 "general",
927 &patterns,
928 ®istry,
929 PatternStrictness::Strict,
930 true, );
932 assert!(result.is_ok());
933 }
934
935 #[tokio::test]
936 async fn executor_rejects_out_of_scope_call() {
937 let registry = make_registry(&["builtin:shell", "builtin:read"]);
938 let (scope, _) = ToolScope::try_compile(
939 "narrow",
940 &["builtin:read".to_owned()],
941 ®istry,
942 PatternStrictness::Strict,
943 false,
944 )
945 .unwrap();
946 let inner = NullExecutor {
947 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
948 };
949 let executor = ScopedToolExecutor::new(inner, scope);
950 let call = make_call("builtin:shell");
951 let result = executor.execute_tool_call(&call).await;
952 assert_matches!(result, Err(ToolError::OutOfScope { .. }));
953 }
954
955 #[tokio::test]
956 async fn executor_allows_in_scope_call() {
957 let registry = make_registry(&["builtin:shell", "builtin:read"]);
958 let (scope, _) = ToolScope::try_compile(
959 "narrow",
960 &["builtin:read".to_owned()],
961 ®istry,
962 PatternStrictness::Strict,
963 false,
964 )
965 .unwrap();
966 let inner = NullExecutor {
967 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
968 };
969 let executor = ScopedToolExecutor::new(inner, scope);
970 let call = make_call("builtin:read");
971 let result = executor.execute_tool_call(&call).await;
972 assert!(result.is_ok());
973 }
974
975 #[test]
976 fn tool_definitions_filtered_by_scope() {
977 let registry = make_registry(&["builtin:shell", "builtin:read"]);
978 let (scope, _) = ToolScope::try_compile(
979 "narrow",
980 &["builtin:read".to_owned()],
981 ®istry,
982 PatternStrictness::Strict,
983 false,
984 )
985 .unwrap();
986 let inner = NullExecutor {
987 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
988 };
989 let executor = ScopedToolExecutor::new(inner, scope);
990 let defs = executor.tool_definitions();
991 assert_eq!(defs.len(), 1);
992 assert_eq!(defs[0].id.as_ref(), "builtin:read");
993 }
994
995 #[tokio::test]
996 async fn unnamespaced_tool_id_admitted_via_builtin_prefix() {
997 let registry = make_registry(&["builtin:bash", "builtin:read"]);
1000 let (scope, _) = ToolScope::try_compile(
1001 "narrow",
1002 &["builtin:bash".to_owned()],
1003 ®istry,
1004 PatternStrictness::Strict,
1005 false,
1006 )
1007 .unwrap();
1008 let inner = NullExecutor {
1009 defs: vec![null_def("bash"), null_def("read")],
1010 };
1011 let executor = ScopedToolExecutor::new(inner, scope);
1012 let call = make_call("bash");
1014 let result = executor.execute_tool_call(&call).await;
1015 assert!(
1016 result.is_ok(),
1017 "builtin tool with unqualified id must be admitted"
1018 );
1019 let call_read = make_call("read");
1021 let result_read = executor.execute_tool_call(&call_read).await;
1022 assert!(
1023 matches!(result_read, Err(ToolError::OutOfScope { .. })),
1024 "out-of-scope built-in tool must be rejected"
1025 );
1026 }
1027
1028 #[test]
1029 fn build_scoped_executor_accepts_unqualified_registry_id() {
1030 let cfg = CapabilityScopesConfig::default();
1033 let registry = make_registry(&["shell"]); let inner = NullExecutor { defs: vec![] };
1035 let result = build_scoped_executor(inner, &cfg, ®istry);
1036 assert!(
1037 result.is_ok(),
1038 "build_scoped_executor must accept unqualified registry ids"
1039 );
1040 }
1041
1042 #[test]
1043 fn build_scoped_executor_with_builtin_prefix_and_glob() {
1044 let mut cfg = CapabilityScopesConfig::default();
1045 cfg.scopes.insert(
1046 "general".to_owned(),
1047 ScopeConfig {
1048 patterns: vec!["builtin:*".to_owned()],
1049 },
1050 );
1051 cfg.default_scope = "general".to_owned();
1052 let registry = make_registry(&["builtin:bash", "builtin:read", "builtin:fetch"]);
1053 let inner = NullExecutor { defs: vec![] };
1054 let result = build_scoped_executor(inner, &cfg, ®istry);
1055 assert!(
1056 result.is_ok(),
1057 "builtin:* glob must match all builtin tools"
1058 );
1059 }
1060
1061 #[tokio::test]
1062 async fn unqualified_tool_out_of_scope_rejected() {
1063 let registry = make_registry(&["builtin:bash", "builtin:read"]);
1064 let (scope, _) = ToolScope::try_compile(
1065 "narrow",
1066 &["builtin:read".to_owned()],
1067 ®istry,
1068 PatternStrictness::Strict,
1069 false,
1070 )
1071 .unwrap();
1072 let inner = NullExecutor {
1073 defs: vec![null_def("bash"), null_def("read")],
1074 };
1075 let executor = ScopedToolExecutor::new(inner, scope);
1076 let call = make_call("bash"); let result = executor.execute_tool_call(&call).await;
1078 assert!(
1079 matches!(result, Err(ToolError::OutOfScope { .. })),
1080 "unqualified id not in scope must be rejected after normalization"
1081 );
1082 }
1083
1084 #[test]
1085 fn tool_definitions_filtered_by_scope_with_unqualified_ids() {
1086 let registry = make_registry(&["builtin:bash", "builtin:read"]);
1088 let (scope, _) = ToolScope::try_compile(
1089 "narrow",
1090 &["builtin:read".to_owned()],
1091 ®istry,
1092 PatternStrictness::Strict,
1093 false,
1094 )
1095 .unwrap();
1096 let inner = NullExecutor {
1097 defs: vec![null_def("bash"), null_def("read")],
1098 };
1099 let executor = ScopedToolExecutor::new(inner, scope);
1100 let defs = executor.tool_definitions();
1101 assert_eq!(defs.len(), 1);
1102 assert_eq!(defs[0].id.as_ref(), "read");
1103 }
1104
1105 #[test]
1106 fn scope_for_task_returns_ids() {
1107 let registry = make_registry(&["builtin:shell", "builtin:read"]);
1108 let (scope, _) = ToolScope::try_compile(
1109 "narrow",
1110 &["builtin:read".to_owned()],
1111 ®istry,
1112 PatternStrictness::Strict,
1113 false,
1114 )
1115 .unwrap();
1116 let inner = NullExecutor { defs: vec![] };
1117 let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
1118 executor.register_scope("narrow", scope);
1119 let ids = executor.scope_for_task("narrow");
1120 assert!(ids.is_some());
1121 let ids = ids.unwrap();
1122 assert!(ids.contains(&"builtin:read".to_owned()));
1123 assert!(!ids.contains(&"builtin:shell".to_owned()));
1124 }
1125
1126 #[test]
1127 fn scope_for_task_returns_none_for_unknown() {
1128 let inner = NullExecutor { defs: vec![] };
1129 let executor = ScopedToolExecutor::new(inner, ToolScope::full());
1130 assert!(executor.scope_for_task("does_not_exist").is_none());
1131 }
1132
1133 #[test]
1134 fn re_resolve_updates_admitted_set() {
1135 let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
1138 let (scope, _) = ToolScope::try_compile(
1139 "narrow",
1140 &["builtin:read".to_owned()],
1141 ®istry,
1142 PatternStrictness::Strict,
1143 false,
1144 )
1145 .unwrap();
1146 assert!(scope.admits("builtin:read"));
1147 assert!(!scope.admits("builtin:write"));
1148
1149 let mut new_registry = registry.clone();
1151 new_registry.insert("builtin:write".to_owned());
1152 let updated = scope.re_resolve(&new_registry);
1153 assert!(updated.admits("builtin:read"));
1154 assert!(!updated.admits("builtin:write"));
1156 }
1157
1158 #[test]
1159 fn checkpoint_methods_delegated_to_inner() {
1160 let executor = ScopedToolExecutor::new(CheckpointingExecutor, ToolScope::full());
1161 let undo_result = executor.checkpoint_undo(7);
1162 assert!(undo_result.supported);
1163 assert_eq!(
1164 undo_result.reverted_commands, 7,
1165 "n must be forwarded, not hardcoded"
1166 );
1167 assert!(executor.checkpoint_redo().supported);
1168 assert!(executor.checkpoint_list().supported);
1169 }
1170
1171 #[test]
1172 fn requires_confirmation_delegated_to_inner() {
1173 let executor = ScopedToolExecutor::new(CheckpointingExecutor, ToolScope::full());
1174 assert!(executor.requires_confirmation(&make_call("builtin:shell")));
1175 }
1176
1177 #[test]
1178 fn build_from_config_with_scopes() {
1179 let mut scopes = std::collections::HashMap::new();
1180 scopes.insert(
1181 "general".to_owned(),
1182 ScopeConfig {
1183 patterns: vec!["*".to_owned()],
1184 },
1185 );
1186 scopes.insert(
1187 "narrow".to_owned(),
1188 ScopeConfig {
1189 patterns: vec!["builtin:read".to_owned()],
1190 },
1191 );
1192 let cfg = CapabilityScopesConfig {
1193 default_scope: "general".to_owned(),
1194 strict: false,
1195 pattern_strictness: PatternStrictness::Strict,
1196 scopes,
1197 };
1198 let registry = make_registry(&["builtin:shell", "builtin:read"]);
1199 let inner = NullExecutor { defs: vec![] };
1200 let executor = build_scoped_executor(inner, &cfg, ®istry).unwrap();
1201 let narrow_ids = executor.scope_for_task("narrow");
1203 assert!(narrow_ids.is_some());
1204 let ids = narrow_ids.unwrap();
1205 assert!(ids.contains(&"builtin:read".to_owned()));
1206 }
1207}