1use std::collections::{HashMap, HashSet};
46use std::sync::Arc;
47
48use arc_swap::ArcSwap;
49use globset::{Glob, GlobSet, GlobSetBuilder};
50use tracing::warn;
51
52use crate::audit::{AuditEntry, AuditLogger, AuditResult, chrono_now};
53use crate::executor::{ToolCall, ToolError, ToolExecutor, ToolOutput};
54use crate::registry::ToolDef;
55use zeph_config::{CapabilityScopesConfig, PatternStrictness};
56
57#[derive(Debug, thiserror::Error)]
61pub enum ScopeError {
62 #[error("scope '{scope}': pattern '{pattern}' matched zero registered tools (dead pattern)")]
64 DeadPattern { scope: String, pattern: String },
65
66 #[error(
68 "scope '{scope}': pattern '{pattern}' matches the entire registry; use default_scope=\"general\" to opt in"
69 )]
70 AccidentallyFull { scope: String, pattern: String },
71
72 #[error("tool id '{id}' has no namespace prefix (expected '<namespace>:<id>')")]
74 UnqualifiedId { id: String },
75
76 #[error("scope '{scope}': invalid glob pattern '{pattern}': {source}")]
78 InvalidPattern {
79 scope: String,
80 pattern: String,
81 #[source]
82 source: globset::Error,
83 },
84}
85
86#[derive(Debug)]
88pub struct ScopeWarning {
89 pub scope: String,
91 pub pattern: String,
93}
94
95#[derive(Debug, Clone)]
102pub struct ToolScope {
103 pub task_type: Option<String>,
105 admitted: HashSet<String>,
107 is_full: bool,
109 patterns: Vec<String>,
111}
112
113impl ToolScope {
114 #[must_use]
126 pub fn full() -> Self {
127 Self {
128 task_type: None,
129 admitted: HashSet::new(),
130 is_full: true,
131 patterns: vec!["*".to_owned()],
132 }
133 }
134
135 pub fn try_compile<S: std::hash::BuildHasher>(
143 task_type: impl Into<String>,
144 patterns: &[String],
145 registry_ids: &HashSet<String, S>,
146 strictness: PatternStrictness,
147 is_general_scope: bool,
148 ) -> Result<(Self, Vec<ScopeWarning>), ScopeError> {
149 let task_type_str = task_type.into();
150 let mut admitted = HashSet::new();
151 let mut warnings = Vec::new();
152
153 for pattern in patterns {
154 let glob = Glob::new(pattern).map_err(|e| ScopeError::InvalidPattern {
156 scope: task_type_str.clone(),
157 pattern: pattern.clone(),
158 source: e,
159 })?;
160
161 let mut builder = GlobSetBuilder::new();
162 builder.add(glob);
163 let glob_set: GlobSet = builder.build().map_err(|e| ScopeError::InvalidPattern {
164 scope: task_type_str.clone(),
165 pattern: pattern.clone(),
166 source: e,
167 })?;
168
169 let matched: HashSet<String> = registry_ids
170 .iter()
171 .filter(|id| glob_set.is_match(id.as_str()))
172 .cloned()
173 .collect();
174
175 if !is_general_scope && matched.len() == registry_ids.len() && !registry_ids.is_empty()
177 {
178 return Err(ScopeError::AccidentallyFull {
179 scope: task_type_str,
180 pattern: pattern.clone(),
181 });
182 }
183
184 if matched.is_empty() {
185 let is_strict = is_strict_pattern(pattern, strictness);
186 if is_strict {
187 return Err(ScopeError::DeadPattern {
188 scope: task_type_str,
189 pattern: pattern.clone(),
190 });
191 }
192 warnings.push(ScopeWarning {
193 scope: task_type_str.clone(),
194 pattern: pattern.clone(),
195 });
196 }
197
198 admitted.extend(matched);
199 }
200
201 Ok((
202 Self {
203 task_type: Some(task_type_str),
204 admitted,
205 is_full: false,
206 patterns: patterns.to_vec(),
207 },
208 warnings,
209 ))
210 }
211
212 #[must_use]
223 pub fn admits(&self, qualified_tool_id: &str) -> bool {
224 self.is_full || self.admitted.contains(qualified_tool_id)
225 }
226
227 #[must_use]
231 pub fn admitted_ids(&self) -> Vec<&str> {
232 self.admitted.iter().map(String::as_str).collect()
233 }
234
235 #[must_use]
237 pub fn patterns(&self) -> &[String] {
238 &self.patterns
239 }
240
241 #[must_use]
246 pub fn re_resolve<S: std::hash::BuildHasher>(&self, registry_ids: &HashSet<String, S>) -> Self {
247 let task_type_str = self
248 .task_type
249 .clone()
250 .unwrap_or_else(|| "<unknown>".to_owned());
251 let mut admitted = HashSet::new();
252 for pattern in &self.patterns {
253 let Ok(glob) = Glob::new(pattern) else {
254 warn!(scope = %task_type_str, pattern, "re-resolve: invalid glob, skipping");
255 continue;
256 };
257 let mut builder = GlobSetBuilder::new();
258 builder.add(glob);
259 let Ok(glob_set) = builder.build() else {
260 continue;
261 };
262 let matched: HashSet<String> = registry_ids
263 .iter()
264 .filter(|id| glob_set.is_match(id.as_str()))
265 .cloned()
266 .collect();
267 admitted.extend(matched);
268 }
269 Self {
270 task_type: self.task_type.clone(),
271 admitted,
272 is_full: false,
273 patterns: self.patterns.clone(),
274 }
275 }
276}
277
278fn is_strict_pattern(pattern: &str, strictness: PatternStrictness) -> bool {
280 match strictness {
281 PatternStrictness::Strict => true,
282 PatternStrictness::Permissive => false,
283 PatternStrictness::ProvisionalForDynamicNamespaces => {
284 pattern.starts_with("builtin:") || pattern.starts_with("skill:")
286 }
287 }
288}
289
290pub struct ScopedToolExecutor<E: ToolExecutor> {
317 inner: E,
318 scope: ArcSwap<ToolScope>,
320 scopes: HashMap<String, Arc<ToolScope>>,
322 scope_at_definition: parking_lot::Mutex<Option<String>>,
324 signal_queue: Option<crate::policy_gate::RiskSignalQueue>,
326 audit: Option<Arc<AuditLogger>>,
328}
329
330impl<E: ToolExecutor> ScopedToolExecutor<E> {
331 #[must_use]
345 pub fn new(inner: E, initial_scope: ToolScope) -> Self {
346 Self {
347 inner,
348 scope: ArcSwap::from_pointee(initial_scope),
349 scopes: HashMap::new(),
350 scope_at_definition: parking_lot::Mutex::new(None),
351 signal_queue: None,
352 audit: None,
353 }
354 }
355
356 #[must_use]
358 pub fn with_audit(mut self, audit: Arc<AuditLogger>) -> Self {
359 self.audit = Some(audit);
360 self
361 }
362
363 #[must_use]
365 pub fn with_signal_queue(mut self, queue: crate::policy_gate::RiskSignalQueue) -> Self {
366 self.signal_queue = Some(queue);
367 self
368 }
369
370 pub fn register_scope(&mut self, name: impl Into<String>, scope: ToolScope) {
372 self.scopes.insert(name.into(), Arc::new(scope));
373 }
374
375 pub fn set_scope_for_task(&self, task_type: &str) -> bool {
377 if let Some(scope) = self.scopes.get(task_type) {
378 self.scope.store(Arc::clone(scope));
379 true
380 } else {
381 false
382 }
383 }
384
385 pub fn set_scope(&self, scope: ToolScope) {
387 self.scope.store(Arc::new(scope));
388 }
389
390 #[must_use]
408 pub fn scope_for_task(&self, task_type: &str) -> Option<Vec<String>> {
409 self.scopes.get(task_type).map(|s| {
410 if s.is_full {
411 vec!["*".to_owned()]
412 } else {
413 s.admitted_ids().iter().map(|s| (*s).to_owned()).collect()
414 }
415 })
416 }
417
418 #[must_use]
420 pub fn scope_at_definition_name(&self) -> Option<String> {
421 self.scope_at_definition.lock().clone()
422 }
423
424 #[must_use]
426 pub fn active_scope_name(&self) -> Option<String> {
427 self.scope.load().task_type.clone()
428 }
429}
430
431impl<E: ToolExecutor> ToolExecutor for ScopedToolExecutor<E> {
432 async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
434 self.inner.execute(response).await
435 }
436
437 async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
438 self.inner.execute_confirmed(response).await
439 }
440
441 fn tool_definitions(&self) -> Vec<ToolDef> {
445 let scope = self.scope.load();
446 self.scope_at_definition.lock().clone_from(&scope.task_type);
447 self.inner
448 .tool_definitions()
449 .into_iter()
450 .filter(|d| {
451 let id = d.id.as_ref();
452 scope.admits(id)
453 })
454 .collect()
455 }
456
457 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
462 let scope = self.scope.load();
463 let tool_id = call.tool_id.as_str();
464
465 if !tool_id.contains(':') {
467 return Err(ToolError::OutOfScope {
468 tool_id: tool_id.to_owned(),
469 task_type: scope.task_type.clone(),
470 });
471 }
472
473 if !scope.admits(tool_id) {
474 let scope_name = scope.task_type.clone();
475 let scope_def = self.scope_at_definition.lock().clone();
476 tracing::debug!(
477 tool_id,
478 scope = ?scope_name,
479 "ScopedToolExecutor: out-of-scope rejection"
480 );
481 if let Some(ref q) = self.signal_queue {
483 q.lock().push(3);
484 }
485 if let Some(ref audit) = self.audit {
487 let entry = AuditEntry {
488 timestamp: chrono_now(),
489 tool: call.tool_id.clone(),
490 command: String::new(),
491 result: AuditResult::Blocked {
492 reason: "out_of_scope".to_owned(),
493 },
494 duration_ms: 0,
495 error_category: Some("out_of_scope".to_owned()),
496 error_domain: Some("security".to_owned()),
497 error_phase: None,
498 claim_source: None,
499 mcp_server_id: None,
500 injection_flagged: false,
501 embedding_anomalous: false,
502 cross_boundary_mcp_to_acp: false,
503 adversarial_policy_decision: None,
504 exit_code: None,
505 truncated: false,
506 caller_id: call.caller_id.clone(),
507 policy_match: None,
508 correlation_id: None,
509 vigil_risk: None,
510 execution_env: None,
511 resolved_cwd: None,
512 scope_at_definition: scope_def,
513 scope_at_dispatch: scope_name,
514 };
515 audit.log(&entry).await;
516 }
517 return Err(ToolError::OutOfScope {
518 tool_id: tool_id.to_owned(),
519 task_type: scope.task_type.clone(),
520 });
521 }
522
523 self.inner.execute_tool_call(call).await
524 }
525
526 async fn execute_tool_call_confirmed(
527 &self,
528 call: &ToolCall,
529 ) -> Result<Option<ToolOutput>, ToolError> {
530 let scope = self.scope.load();
531 let tool_id = call.tool_id.as_str();
532 if !tool_id.contains(':') || !scope.admits(tool_id) {
533 let scope_name = scope.task_type.clone();
534 let scope_def = self.scope_at_definition.lock().clone();
535 if let Some(ref q) = self.signal_queue {
536 q.lock().push(3);
537 }
538 if let Some(ref audit) = self.audit {
539 let entry = AuditEntry {
540 timestamp: chrono_now(),
541 tool: call.tool_id.clone(),
542 command: String::new(),
543 result: AuditResult::Blocked {
544 reason: "out_of_scope".to_owned(),
545 },
546 duration_ms: 0,
547 error_category: Some("out_of_scope".to_owned()),
548 error_domain: Some("security".to_owned()),
549 error_phase: None,
550 claim_source: None,
551 mcp_server_id: None,
552 injection_flagged: false,
553 embedding_anomalous: false,
554 cross_boundary_mcp_to_acp: false,
555 adversarial_policy_decision: None,
556 exit_code: None,
557 truncated: false,
558 caller_id: call.caller_id.clone(),
559 policy_match: None,
560 correlation_id: None,
561 vigil_risk: None,
562 execution_env: None,
563 resolved_cwd: None,
564 scope_at_definition: scope_def,
565 scope_at_dispatch: scope_name,
566 };
567 audit.log(&entry).await;
568 }
569 return Err(ToolError::OutOfScope {
570 tool_id: tool_id.to_owned(),
571 task_type: scope.task_type.clone(),
572 });
573 }
574 self.inner.execute_tool_call_confirmed(call).await
575 }
576
577 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
578 self.inner.set_skill_env(env);
579 }
580
581 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
582 self.inner.set_effective_trust(level);
583 }
584
585 fn is_tool_retryable(&self, tool_id: &str) -> bool {
586 self.inner.is_tool_retryable(tool_id)
587 }
588
589 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
590 self.inner.is_tool_speculatable(tool_id)
591 }
592}
593
594pub fn build_scoped_executor<E: ToolExecutor, S: std::hash::BuildHasher>(
622 inner: E,
623 cfg: &CapabilityScopesConfig,
624 registry_ids: &HashSet<String, S>,
625) -> Result<ScopedToolExecutor<E>, ScopeError> {
626 for id in registry_ids {
628 if !id.contains(':') {
629 return Err(ScopeError::UnqualifiedId { id: id.clone() });
630 }
631 }
632
633 let default_scope_name = &cfg.default_scope;
634 let strictness = cfg.pattern_strictness;
635
636 let initial_scope = ToolScope::full();
638 let mut executor = ScopedToolExecutor::new(inner, initial_scope);
639
640 for (task_type, scope_cfg) in &cfg.scopes {
641 let is_general = task_type == default_scope_name;
642 let (scope, warnings) = ToolScope::try_compile(
643 task_type.clone(),
644 &scope_cfg.patterns,
645 registry_ids,
646 strictness,
647 is_general,
648 )?;
649 for w in &warnings {
650 warn!(
651 scope = %w.scope,
652 pattern = %w.pattern,
653 "capability scope: provisional zero-match pattern (will re-resolve on dynamic registration)"
654 );
655 }
656 executor.register_scope(task_type.clone(), scope);
657 }
658
659 if cfg.scopes.contains_key(default_scope_name.as_str()) {
661 executor.set_scope_for_task(default_scope_name);
662 }
663
664 Ok(executor)
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use crate::executor::ToolCall;
671 use crate::registry::{InvocationHint, ToolDef};
672 use zeph_common::ToolName;
673 use zeph_config::{CapabilityScopesConfig, PatternStrictness, ScopeConfig};
674
675 fn make_registry(ids: &[&str]) -> HashSet<String> {
676 ids.iter().map(|s| (*s).to_owned()).collect()
677 }
678
679 struct NullExecutor {
680 defs: Vec<ToolDef>,
681 }
682
683 impl ToolExecutor for NullExecutor {
684 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
685 Ok(None)
686 }
687
688 fn tool_definitions(&self) -> Vec<ToolDef> {
689 self.defs.clone()
690 }
691
692 async fn execute_tool_call(
693 &self,
694 call: &ToolCall,
695 ) -> Result<Option<ToolOutput>, ToolError> {
696 Ok(Some(ToolOutput {
697 tool_name: call.tool_id.clone(),
698 summary: "ok".to_owned(),
699 blocks_executed: 1,
700 filter_stats: None,
701 diff: None,
702 streamed: false,
703 terminal_id: None,
704 locations: None,
705 raw_response: None,
706 claim_source: None,
707 }))
708 }
709 }
710
711 fn null_def(id: &str) -> ToolDef {
712 ToolDef {
713 id: id.to_owned().into(),
714 description: "test tool".into(),
715 schema: schemars::schema_for!(String),
716 invocation: InvocationHint::ToolCall,
717 output_schema: None,
718 }
719 }
720
721 fn make_call(tool_id: &str) -> ToolCall {
722 ToolCall {
723 tool_id: ToolName::new(tool_id),
724 params: serde_json::Map::new(),
725 caller_id: None,
726 context: None,
727 }
728 }
729
730 #[test]
731 fn full_scope_admits_everything() {
732 let scope = ToolScope::full();
733 assert!(scope.admits("builtin:shell"));
734 assert!(scope.admits("mcp:server/tool"));
735 assert!(scope.admits("builtin:read"));
736 }
737
738 #[test]
739 fn compiled_scope_admits_only_matched() {
740 let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
741 let patterns = vec!["builtin:read".to_owned()];
742 let (scope, warnings) = ToolScope::try_compile(
743 "narrow",
744 &patterns,
745 ®istry,
746 PatternStrictness::Strict,
747 false,
748 )
749 .unwrap();
750 assert!(warnings.is_empty());
751 assert!(scope.admits("builtin:read"));
752 assert!(!scope.admits("builtin:shell"));
753 assert!(!scope.admits("builtin:write"));
754 }
755
756 #[test]
757 fn dead_pattern_strict_returns_error() {
758 let registry = make_registry(&["builtin:shell"]);
759 let patterns = vec!["builtin:nonexistent".to_owned()];
760 let result = ToolScope::try_compile(
761 "test",
762 &patterns,
763 ®istry,
764 PatternStrictness::Strict,
765 false,
766 );
767 assert!(
768 matches!(result, Err(ScopeError::DeadPattern { .. })),
769 "expected DeadPattern, got {result:?}"
770 );
771 }
772
773 #[test]
774 fn dead_pattern_provisional_returns_warning() {
775 let registry = make_registry(&["builtin:shell"]);
776 let patterns = vec!["mcp:server/nonexistent".to_owned()];
777 let result = ToolScope::try_compile(
778 "test",
779 &patterns,
780 ®istry,
781 PatternStrictness::ProvisionalForDynamicNamespaces,
782 false,
783 );
784 assert!(result.is_ok());
785 let (_, warnings) = result.unwrap();
786 assert_eq!(warnings.len(), 1);
787 }
788
789 #[test]
790 fn accidentally_full_pattern_returns_error() {
791 let registry = make_registry(&["builtin:shell", "builtin:read"]);
792 let patterns = vec!["*".to_owned()];
793 let result = ToolScope::try_compile(
794 "test",
795 &patterns,
796 ®istry,
797 PatternStrictness::Strict,
798 false, );
800 assert!(
801 matches!(result, Err(ScopeError::AccidentallyFull { .. })),
802 "expected AccidentallyFull for non-general scope with '*'"
803 );
804 }
805
806 #[test]
807 fn general_scope_allows_wildcard() {
808 let registry = make_registry(&["builtin:shell", "builtin:read"]);
809 let patterns = vec!["*".to_owned()];
810 let result = ToolScope::try_compile(
811 "general",
812 &patterns,
813 ®istry,
814 PatternStrictness::Strict,
815 true, );
817 assert!(result.is_ok());
818 }
819
820 #[tokio::test]
821 async fn executor_rejects_out_of_scope_call() {
822 let registry = make_registry(&["builtin:shell", "builtin:read"]);
823 let (scope, _) = ToolScope::try_compile(
824 "narrow",
825 &["builtin:read".to_owned()],
826 ®istry,
827 PatternStrictness::Strict,
828 false,
829 )
830 .unwrap();
831 let inner = NullExecutor {
832 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
833 };
834 let executor = ScopedToolExecutor::new(inner, scope);
835 let call = make_call("builtin:shell");
836 let result = executor.execute_tool_call(&call).await;
837 assert!(matches!(result, Err(ToolError::OutOfScope { .. })));
838 }
839
840 #[tokio::test]
841 async fn executor_allows_in_scope_call() {
842 let registry = make_registry(&["builtin:shell", "builtin:read"]);
843 let (scope, _) = ToolScope::try_compile(
844 "narrow",
845 &["builtin:read".to_owned()],
846 ®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:read");
856 let result = executor.execute_tool_call(&call).await;
857 assert!(result.is_ok());
858 }
859
860 #[test]
861 fn tool_definitions_filtered_by_scope() {
862 let registry = make_registry(&["builtin:shell", "builtin:read"]);
863 let (scope, _) = ToolScope::try_compile(
864 "narrow",
865 &["builtin:read".to_owned()],
866 ®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 defs = executor.tool_definitions();
876 assert_eq!(defs.len(), 1);
877 assert_eq!(defs[0].id.as_ref(), "builtin:read");
878 }
879
880 #[tokio::test]
881 async fn unnamespaced_tool_id_rejected() {
882 let scope = ToolScope::full();
883 let inner = NullExecutor {
884 defs: vec![null_def("builtin:shell")],
885 };
886 let executor = ScopedToolExecutor::new(inner, scope);
887 let call = make_call("shell"); let result = executor.execute_tool_call(&call).await;
889 assert!(
890 matches!(result, Err(ToolError::OutOfScope { .. })),
891 "un-namespaced id must be rejected"
892 );
893 }
894
895 #[test]
896 fn build_scoped_executor_rejects_unqualified_registry_id() {
897 let cfg = CapabilityScopesConfig::default();
898 let registry = make_registry(&["shell"]); let inner = NullExecutor { defs: vec![] };
900 let result = build_scoped_executor(inner, &cfg, ®istry);
901 assert!(
902 matches!(result, Err(ScopeError::UnqualifiedId { .. })),
903 "unqualified registry id must be rejected"
904 );
905 }
906
907 #[test]
908 fn scope_for_task_returns_ids() {
909 let registry = make_registry(&["builtin:shell", "builtin:read"]);
910 let (scope, _) = ToolScope::try_compile(
911 "narrow",
912 &["builtin:read".to_owned()],
913 ®istry,
914 PatternStrictness::Strict,
915 false,
916 )
917 .unwrap();
918 let inner = NullExecutor { defs: vec![] };
919 let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
920 executor.register_scope("narrow", scope);
921 let ids = executor.scope_for_task("narrow");
922 assert!(ids.is_some());
923 let ids = ids.unwrap();
924 assert!(ids.contains(&"builtin:read".to_owned()));
925 assert!(!ids.contains(&"builtin:shell".to_owned()));
926 }
927
928 #[test]
929 fn scope_for_task_returns_none_for_unknown() {
930 let inner = NullExecutor { defs: vec![] };
931 let executor = ScopedToolExecutor::new(inner, ToolScope::full());
932 assert!(executor.scope_for_task("does_not_exist").is_none());
933 }
934
935 #[test]
936 fn re_resolve_updates_admitted_set() {
937 let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
940 let (scope, _) = ToolScope::try_compile(
941 "narrow",
942 &["builtin:read".to_owned()],
943 ®istry,
944 PatternStrictness::Strict,
945 false,
946 )
947 .unwrap();
948 assert!(scope.admits("builtin:read"));
949 assert!(!scope.admits("builtin:write"));
950
951 let mut new_registry = registry.clone();
953 new_registry.insert("builtin:write".to_owned());
954 let updated = scope.re_resolve(&new_registry);
955 assert!(updated.admits("builtin:read"));
956 assert!(!updated.admits("builtin:write"));
958 }
959
960 #[test]
961 fn build_from_config_with_scopes() {
962 let mut scopes = std::collections::HashMap::new();
963 scopes.insert(
964 "general".to_owned(),
965 ScopeConfig {
966 patterns: vec!["*".to_owned()],
967 },
968 );
969 scopes.insert(
970 "narrow".to_owned(),
971 ScopeConfig {
972 patterns: vec!["builtin:read".to_owned()],
973 },
974 );
975 let cfg = CapabilityScopesConfig {
976 default_scope: "general".to_owned(),
977 strict: false,
978 pattern_strictness: PatternStrictness::Strict,
979 scopes,
980 };
981 let registry = make_registry(&["builtin:shell", "builtin:read"]);
982 let inner = NullExecutor { defs: vec![] };
983 let executor = build_scoped_executor(inner, &cfg, ®istry).unwrap();
984 let narrow_ids = executor.scope_for_task("narrow");
986 assert!(narrow_ids.is_some());
987 let ids = narrow_ids.unwrap();
988 assert!(ids.contains(&"builtin:read".to_owned()));
989 }
990}