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 tool_call_id: String::new(),
729 }
730 }
731
732 #[test]
733 fn full_scope_admits_everything() {
734 let scope = ToolScope::full();
735 assert!(scope.admits("builtin:shell"));
736 assert!(scope.admits("mcp:server/tool"));
737 assert!(scope.admits("builtin:read"));
738 }
739
740 #[test]
741 fn compiled_scope_admits_only_matched() {
742 let registry = make_registry(&["builtin:shell", "builtin:read", "builtin:write"]);
743 let patterns = vec!["builtin:read".to_owned()];
744 let (scope, warnings) = ToolScope::try_compile(
745 "narrow",
746 &patterns,
747 ®istry,
748 PatternStrictness::Strict,
749 false,
750 )
751 .unwrap();
752 assert!(warnings.is_empty());
753 assert!(scope.admits("builtin:read"));
754 assert!(!scope.admits("builtin:shell"));
755 assert!(!scope.admits("builtin:write"));
756 }
757
758 #[test]
759 fn dead_pattern_strict_returns_error() {
760 let registry = make_registry(&["builtin:shell"]);
761 let patterns = vec!["builtin:nonexistent".to_owned()];
762 let result = ToolScope::try_compile(
763 "test",
764 &patterns,
765 ®istry,
766 PatternStrictness::Strict,
767 false,
768 );
769 assert!(
770 matches!(result, Err(ScopeError::DeadPattern { .. })),
771 "expected DeadPattern, got {result:?}"
772 );
773 }
774
775 #[test]
776 fn dead_pattern_provisional_returns_warning() {
777 let registry = make_registry(&["builtin:shell"]);
778 let patterns = vec!["mcp:server/nonexistent".to_owned()];
779 let result = ToolScope::try_compile(
780 "test",
781 &patterns,
782 ®istry,
783 PatternStrictness::ProvisionalForDynamicNamespaces,
784 false,
785 );
786 assert!(result.is_ok());
787 let (_, warnings) = result.unwrap();
788 assert_eq!(warnings.len(), 1);
789 }
790
791 #[test]
792 fn accidentally_full_pattern_returns_error() {
793 let registry = make_registry(&["builtin:shell", "builtin:read"]);
794 let patterns = vec!["*".to_owned()];
795 let result = ToolScope::try_compile(
796 "test",
797 &patterns,
798 ®istry,
799 PatternStrictness::Strict,
800 false, );
802 assert!(
803 matches!(result, Err(ScopeError::AccidentallyFull { .. })),
804 "expected AccidentallyFull for non-general scope with '*'"
805 );
806 }
807
808 #[test]
809 fn general_scope_allows_wildcard() {
810 let registry = make_registry(&["builtin:shell", "builtin:read"]);
811 let patterns = vec!["*".to_owned()];
812 let result = ToolScope::try_compile(
813 "general",
814 &patterns,
815 ®istry,
816 PatternStrictness::Strict,
817 true, );
819 assert!(result.is_ok());
820 }
821
822 #[tokio::test]
823 async fn executor_rejects_out_of_scope_call() {
824 let registry = make_registry(&["builtin:shell", "builtin:read"]);
825 let (scope, _) = ToolScope::try_compile(
826 "narrow",
827 &["builtin:read".to_owned()],
828 ®istry,
829 PatternStrictness::Strict,
830 false,
831 )
832 .unwrap();
833 let inner = NullExecutor {
834 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
835 };
836 let executor = ScopedToolExecutor::new(inner, scope);
837 let call = make_call("builtin:shell");
838 let result = executor.execute_tool_call(&call).await;
839 assert!(matches!(result, Err(ToolError::OutOfScope { .. })));
840 }
841
842 #[tokio::test]
843 async fn executor_allows_in_scope_call() {
844 let registry = make_registry(&["builtin:shell", "builtin:read"]);
845 let (scope, _) = ToolScope::try_compile(
846 "narrow",
847 &["builtin:read".to_owned()],
848 ®istry,
849 PatternStrictness::Strict,
850 false,
851 )
852 .unwrap();
853 let inner = NullExecutor {
854 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
855 };
856 let executor = ScopedToolExecutor::new(inner, scope);
857 let call = make_call("builtin:read");
858 let result = executor.execute_tool_call(&call).await;
859 assert!(result.is_ok());
860 }
861
862 #[test]
863 fn tool_definitions_filtered_by_scope() {
864 let registry = make_registry(&["builtin:shell", "builtin:read"]);
865 let (scope, _) = ToolScope::try_compile(
866 "narrow",
867 &["builtin:read".to_owned()],
868 ®istry,
869 PatternStrictness::Strict,
870 false,
871 )
872 .unwrap();
873 let inner = NullExecutor {
874 defs: vec![null_def("builtin:shell"), null_def("builtin:read")],
875 };
876 let executor = ScopedToolExecutor::new(inner, scope);
877 let defs = executor.tool_definitions();
878 assert_eq!(defs.len(), 1);
879 assert_eq!(defs[0].id.as_ref(), "builtin:read");
880 }
881
882 #[tokio::test]
883 async fn unnamespaced_tool_id_rejected() {
884 let scope = ToolScope::full();
885 let inner = NullExecutor {
886 defs: vec![null_def("builtin:shell")],
887 };
888 let executor = ScopedToolExecutor::new(inner, scope);
889 let call = make_call("shell"); let result = executor.execute_tool_call(&call).await;
891 assert!(
892 matches!(result, Err(ToolError::OutOfScope { .. })),
893 "un-namespaced id must be rejected"
894 );
895 }
896
897 #[test]
898 fn build_scoped_executor_rejects_unqualified_registry_id() {
899 let cfg = CapabilityScopesConfig::default();
900 let registry = make_registry(&["shell"]); let inner = NullExecutor { defs: vec![] };
902 let result = build_scoped_executor(inner, &cfg, ®istry);
903 assert!(
904 matches!(result, Err(ScopeError::UnqualifiedId { .. })),
905 "unqualified registry id must be rejected"
906 );
907 }
908
909 #[test]
910 fn scope_for_task_returns_ids() {
911 let registry = make_registry(&["builtin:shell", "builtin:read"]);
912 let (scope, _) = ToolScope::try_compile(
913 "narrow",
914 &["builtin:read".to_owned()],
915 ®istry,
916 PatternStrictness::Strict,
917 false,
918 )
919 .unwrap();
920 let inner = NullExecutor { defs: vec![] };
921 let mut executor = ScopedToolExecutor::new(inner, ToolScope::full());
922 executor.register_scope("narrow", scope);
923 let ids = executor.scope_for_task("narrow");
924 assert!(ids.is_some());
925 let ids = ids.unwrap();
926 assert!(ids.contains(&"builtin:read".to_owned()));
927 assert!(!ids.contains(&"builtin:shell".to_owned()));
928 }
929
930 #[test]
931 fn scope_for_task_returns_none_for_unknown() {
932 let inner = NullExecutor { defs: vec![] };
933 let executor = ScopedToolExecutor::new(inner, ToolScope::full());
934 assert!(executor.scope_for_task("does_not_exist").is_none());
935 }
936
937 #[test]
938 fn re_resolve_updates_admitted_set() {
939 let registry = make_registry(&["builtin:read", "mcp:server/tool"]);
942 let (scope, _) = ToolScope::try_compile(
943 "narrow",
944 &["builtin:read".to_owned()],
945 ®istry,
946 PatternStrictness::Strict,
947 false,
948 )
949 .unwrap();
950 assert!(scope.admits("builtin:read"));
951 assert!(!scope.admits("builtin:write"));
952
953 let mut new_registry = registry.clone();
955 new_registry.insert("builtin:write".to_owned());
956 let updated = scope.re_resolve(&new_registry);
957 assert!(updated.admits("builtin:read"));
958 assert!(!updated.admits("builtin:write"));
960 }
961
962 #[test]
963 fn build_from_config_with_scopes() {
964 let mut scopes = std::collections::HashMap::new();
965 scopes.insert(
966 "general".to_owned(),
967 ScopeConfig {
968 patterns: vec!["*".to_owned()],
969 },
970 );
971 scopes.insert(
972 "narrow".to_owned(),
973 ScopeConfig {
974 patterns: vec!["builtin:read".to_owned()],
975 },
976 );
977 let cfg = CapabilityScopesConfig {
978 default_scope: "general".to_owned(),
979 strict: false,
980 pattern_strictness: PatternStrictness::Strict,
981 scopes,
982 };
983 let registry = make_registry(&["builtin:shell", "builtin:read"]);
984 let inner = NullExecutor { defs: vec![] };
985 let executor = build_scoped_executor(inner, &cfg, ®istry).unwrap();
986 let narrow_ids = executor.scope_for_task("narrow");
988 assert!(narrow_ids.is_some());
989 let ids = narrow_ids.unwrap();
990 assert!(ids.contains(&"builtin:read".to_owned()));
991 }
992}