1use crate::session::{SessionToolVisibilityState, ToolVisibilityWitness};
4use crate::tool_catalog::stable_owner_key_for_tool;
5use crate::types::ToolDef;
6use std::collections::BTreeSet;
7use std::collections::HashSet;
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::{Arc, RwLock};
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub enum ToolFilter {
14 #[default]
16 All,
17 Allow(HashSet<String>),
19 Deny(HashSet<String>),
21}
22
23impl ToolFilter {
24 fn names(&self) -> Option<&HashSet<String>> {
25 match self {
26 Self::All => None,
27 Self::Allow(names) | Self::Deny(names) => Some(names),
28 }
29 }
30}
31
32pub const EXTERNAL_TOOL_FILTER_METADATA_KEY: &str = "tool_scope_external_filter";
34
35pub const INHERITED_TOOL_FILTER_METADATA_KEY: &str = "tool_scope_inherited_filter";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44#[repr(transparent)]
45pub struct ToolScopeRevision(pub u64);
46
47#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
48pub enum ToolScopeStageError {
49 #[error("Unknown tool(s) in filter: {names:?}")]
50 UnknownTools { names: Vec<String> },
51 #[error("Tool scope state lock poisoned")]
52 LockPoisoned,
53}
54
55#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
56pub enum ToolScopeApplyError {
57 #[error("Tool scope state lock poisoned")]
58 LockPoisoned,
59 #[error("Injected boundary failure for testing")]
60 InjectedFailure,
61}
62
63#[derive(Debug, Clone)]
64struct ToolScopeState {
65 base_tools: Arc<[Arc<ToolDef>]>,
66 known_base_names: HashSet<String>,
67 control_tool_names: HashSet<String>,
68 deferred_tool_names: HashSet<String>,
69 durable_state: SessionToolVisibilityState,
70 active_turn_allow: Option<HashSet<String>>,
71 active_turn_deny: HashSet<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct ComposedToolFilter {
77 allow: Option<HashSet<String>>,
78 deny: HashSet<String>,
79}
80
81impl ComposedToolFilter {
82 pub fn allows(&self, name: &str) -> bool {
83 let allowed = self.allow.as_ref().is_none_or(|set| set.contains(name));
84 allowed && !self.deny.contains(name)
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct ToolScope {
91 state: Arc<RwLock<ToolScopeState>>,
92 next_revision: Arc<AtomicU64>,
93 fail_next_boundary_apply: Arc<AtomicBool>,
94}
95
96#[derive(Debug, Clone)]
97pub struct ToolScopeBoundaryResult {
98 pub previous_base_names: HashSet<String>,
99 pub current_base_names: HashSet<String>,
100 pub previous_visible_names: Vec<String>,
101 pub visible_names: Vec<String>,
102 pub previous_active_revision: ToolScopeRevision,
103 pub applied_revision: ToolScopeRevision,
104 pub tools: Arc<[Arc<ToolDef>]>,
105}
106
107impl ToolScopeBoundaryResult {
108 pub fn base_changed(&self) -> bool {
109 self.previous_base_names != self.current_base_names
110 }
111
112 pub fn visible_changed(&self) -> bool {
113 self.previous_visible_names != self.visible_names
114 }
115
116 pub fn changed(&self) -> bool {
117 self.base_changed() || self.visible_changed()
118 }
119}
120
121impl ToolScope {
122 pub fn new(base_tools: Arc<[Arc<ToolDef>]>) -> Self {
124 Self::new_with_projection_names(base_tools, HashSet::new(), HashSet::new())
125 }
126
127 pub fn new_with_control_tool_names(
129 base_tools: Arc<[Arc<ToolDef>]>,
130 control_tool_names: HashSet<String>,
131 ) -> Self {
132 Self::new_with_projection_names(base_tools, control_tool_names, HashSet::new())
133 }
134
135 pub fn new_with_projection_names(
137 base_tools: Arc<[Arc<ToolDef>]>,
138 control_tool_names: HashSet<String>,
139 deferred_tool_names: HashSet<String>,
140 ) -> Self {
141 let known_base_names: HashSet<String> =
142 base_tools.iter().map(|tool| tool.name.clone()).collect();
143
144 Self {
145 state: Arc::new(RwLock::new(ToolScopeState {
146 base_tools,
147 known_base_names,
148 control_tool_names,
149 deferred_tool_names,
150 durable_state: SessionToolVisibilityState::default(),
151 active_turn_allow: None,
152 active_turn_deny: HashSet::new(),
153 })),
154 next_revision: Arc::new(AtomicU64::new(0)),
155 fail_next_boundary_apply: Arc::new(AtomicBool::new(false)),
156 }
157 }
158
159 pub fn visible_tools(&self) -> Arc<[Arc<ToolDef>]> {
161 match self.visible_tools_result() {
162 Ok(tools) => tools,
163 Err(_) => self
164 .state
165 .read()
166 .map(|state| Arc::clone(&state.base_tools))
167 .unwrap_or_else(|_| Vec::<Arc<ToolDef>>::new().into()),
168 }
169 }
170
171 pub fn visible_tools_result(&self) -> Result<Arc<[Arc<ToolDef>]>, ToolScopeApplyError> {
173 let state = self
174 .state
175 .read()
176 .map_err(|_| ToolScopeApplyError::LockPoisoned)?;
177
178 let composed = Self::compose_state_filters(&state);
179
180 Ok(state
181 .base_tools
182 .iter()
183 .filter(|tool| {
184 state.control_tool_names.contains(tool.name.as_str())
185 || (Self::is_requested_session_tool_visible(&state, tool.as_ref())
186 && composed.allows(tool.name.as_str()))
187 })
188 .map(Arc::clone)
189 .collect::<Vec<_>>()
190 .into())
191 }
192
193 pub fn handle(&self) -> ToolScopeHandle {
195 ToolScopeHandle {
196 state: Arc::clone(&self.state),
197 next_revision: Arc::clone(&self.next_revision),
198 }
199 }
200
201 pub fn apply_staged(
209 &self,
210 new_base_tools: Arc<[Arc<ToolDef>]>,
211 ) -> Result<ToolScopeBoundaryResult, ToolScopeApplyError> {
212 let (control_tool_names, deferred_tool_names) = self
213 .state
214 .read()
215 .map(|state| {
216 (
217 state.control_tool_names.clone(),
218 state.deferred_tool_names.clone(),
219 )
220 })
221 .map_err(|_| ToolScopeApplyError::LockPoisoned)?;
222 self.apply_staged_projection(new_base_tools, control_tool_names, deferred_tool_names)
223 }
224
225 pub fn apply_staged_projection(
227 &self,
228 new_base_tools: Arc<[Arc<ToolDef>]>,
229 control_tool_names: HashSet<String>,
230 deferred_tool_names: HashSet<String>,
231 ) -> Result<ToolScopeBoundaryResult, ToolScopeApplyError> {
232 if self
233 .fail_next_boundary_apply
234 .swap(false, std::sync::atomic::Ordering::SeqCst)
235 {
236 return Err(ToolScopeApplyError::InjectedFailure);
237 }
238
239 let mut state = self
240 .state
241 .write()
242 .map_err(|_| ToolScopeApplyError::LockPoisoned)?;
243
244 let previous_base_names = state.known_base_names.clone();
245 let previous_visible_names = Self::visible_names_for_state(&state);
246 let previous_active_revision = ToolScopeRevision(state.durable_state.active_revision);
247
248 state.base_tools = new_base_tools;
249 state.control_tool_names = control_tool_names;
250 state.deferred_tool_names = deferred_tool_names;
251 state.known_base_names = state
252 .base_tools
253 .iter()
254 .map(|tool| tool.name.clone())
255 .collect::<HashSet<_>>();
256
257 let known_base_names = state.known_base_names.clone();
258 if let Some(allow) = state.active_turn_allow.as_mut() {
259 allow.retain(|name| known_base_names.contains(name));
260 }
261 state
262 .active_turn_deny
263 .retain(|name| known_base_names.contains(name));
264
265 state.durable_state.active_filter = state.durable_state.staged_filter.clone();
266 state.durable_state.active_requested_deferred_names =
267 state.durable_state.staged_requested_deferred_names.clone();
268 state.durable_state.active_revision = state.durable_state.staged_revision;
269
270 let tools = Self::visible_tools_for_state(&state);
271 let visible_names = tools
272 .iter()
273 .map(|tool| tool.name.clone())
274 .collect::<Vec<_>>();
275
276 Ok(ToolScopeBoundaryResult {
277 previous_base_names,
278 current_base_names: state.known_base_names.clone(),
279 previous_visible_names,
280 visible_names,
281 previous_active_revision,
282 applied_revision: ToolScopeRevision(state.durable_state.active_revision),
283 tools,
284 })
285 }
286
287 pub fn compose(filters: &[ToolFilter]) -> ComposedToolFilter {
289 let mut allow: Option<HashSet<String>> = None;
290 let mut deny: HashSet<String> = HashSet::new();
291
292 for filter in filters {
293 match filter {
294 ToolFilter::All => {}
295 ToolFilter::Allow(names) => {
296 allow = Some(match allow {
297 Some(existing) => Self::allow_intersection(&existing, names),
298 None => names.clone(),
299 });
300 }
301 ToolFilter::Deny(names) => {
302 deny = Self::deny_union(&deny, names);
303 }
304 }
305 }
306
307 ComposedToolFilter { allow, deny }
308 }
309
310 fn allow_intersection(left: &HashSet<String>, right: &HashSet<String>) -> HashSet<String> {
312 left.intersection(right).cloned().collect()
313 }
314
315 fn deny_union(left: &HashSet<String>, right: &HashSet<String>) -> HashSet<String> {
317 left.union(right).cloned().collect()
318 }
319
320 fn visible_names_for_state(state: &ToolScopeState) -> Vec<String> {
321 let tools = Self::visible_tools_for_state(state);
322 tools.iter().map(|tool| tool.name.clone()).collect()
323 }
324
325 fn visible_tools_for_state(state: &ToolScopeState) -> Arc<[Arc<ToolDef>]> {
326 let composed = Self::compose_state_filters(state);
327
328 state
329 .base_tools
330 .iter()
331 .filter(|tool| {
332 state.control_tool_names.contains(tool.name.as_str())
333 || (Self::is_requested_session_tool_visible(state, tool.as_ref())
334 && composed.allows(tool.name.as_str()))
335 })
336 .map(Arc::clone)
337 .collect::<Vec<_>>()
338 .into()
339 }
340
341 fn compose_state_filters(state: &ToolScopeState) -> ComposedToolFilter {
342 let mut filters = vec![
343 Self::effective_filter_for_current_projection(
344 state,
345 &state.durable_state.inherited_base_filter,
346 ),
347 Self::effective_filter_for_current_projection(
348 state,
349 &state.durable_state.active_filter,
350 ),
351 ];
352 if let Some(allow) = &state.active_turn_allow {
353 filters.push(ToolFilter::Allow(allow.clone()));
354 }
355 if !state.active_turn_deny.is_empty() {
356 filters.push(ToolFilter::Deny(state.active_turn_deny.clone()));
357 }
358 Self::compose(&filters)
359 }
360
361 fn current_projection_tool<'a>(state: &'a ToolScopeState, name: &str) -> Option<&'a ToolDef> {
362 state
363 .base_tools
364 .iter()
365 .find(|tool| tool.name == name)
366 .map(Arc::as_ref)
367 }
368
369 fn witness_matches_tool(witness: Option<&ToolVisibilityWitness>, tool: &ToolDef) -> bool {
370 let Some(witness) = witness else {
371 return true;
372 };
373 if let Some(expected_owner) = witness.stable_owner_key.as_deref()
374 && stable_owner_key_for_tool(tool).as_deref() != Some(expected_owner)
375 {
376 return false;
377 }
378 if let Some(expected_provenance) = witness.last_seen_provenance.as_ref()
379 && tool.provenance.as_ref() != Some(expected_provenance)
380 {
381 return false;
382 }
383 true
384 }
385
386 fn filter_name_applies(state: &ToolScopeState, name: &str) -> bool {
387 Self::current_projection_tool(state, name).is_none_or(|tool| {
388 Self::witness_matches_tool(state.durable_state.filter_witnesses.get(name), tool)
389 })
390 }
391
392 fn effective_filter_for_current_projection(
393 state: &ToolScopeState,
394 filter: &ToolFilter,
395 ) -> ToolFilter {
396 match filter {
397 ToolFilter::All => ToolFilter::All,
398 ToolFilter::Allow(names) => ToolFilter::Allow(
399 names
400 .iter()
401 .filter(|name| Self::filter_name_applies(state, name))
402 .cloned()
403 .collect(),
404 ),
405 ToolFilter::Deny(names) => ToolFilter::Deny(
406 names
407 .iter()
408 .filter(|name| Self::filter_name_applies(state, name))
409 .cloned()
410 .collect(),
411 ),
412 }
413 }
414
415 fn is_requested_session_tool_visible(state: &ToolScopeState, tool: &ToolDef) -> bool {
416 if !state.deferred_tool_names.contains(tool.name.as_str()) {
417 return true;
418 }
419 state
420 .durable_state
421 .active_requested_deferred_names
422 .contains(tool.name.as_str())
423 && Self::witness_matches_tool(
424 state
425 .durable_state
426 .requested_witnesses
427 .get(tool.name.as_str()),
428 tool,
429 )
430 }
431
432 pub fn set_base_filter(&self, filter: ToolFilter) -> Result<(), ToolScopeApplyError> {
439 let mut state = self
440 .state
441 .write()
442 .map_err(|_| ToolScopeApplyError::LockPoisoned)?;
443 record_filter_witnesses(&mut state, &filter);
444 state.durable_state.inherited_base_filter = filter;
445 Ok(())
446 }
447
448 pub fn set_visibility_state(
450 &self,
451 visibility_state: SessionToolVisibilityState,
452 ) -> Result<(), ToolScopeApplyError> {
453 let mut state = self
454 .state
455 .write()
456 .map_err(|_| ToolScopeApplyError::LockPoisoned)?;
457 let next_revision = visibility_state
458 .active_revision
459 .max(visibility_state.staged_revision);
460 state.durable_state = visibility_state;
461 self.next_revision.store(next_revision, Ordering::SeqCst);
462 Ok(())
463 }
464
465 pub fn visibility_state(&self) -> Result<SessionToolVisibilityState, ToolScopeApplyError> {
467 self.state
468 .read()
469 .map(|state| state.durable_state.clone())
470 .map_err(|_| ToolScopeApplyError::LockPoisoned)
471 }
472
473 pub fn visible_tool_names(&self) -> Result<BTreeSet<String>, ToolScopeApplyError> {
475 self.visible_tools_result().map(|tools| {
476 tools
477 .iter()
478 .map(|tool| tool.name.clone())
479 .collect::<BTreeSet<_>>()
480 })
481 }
482
483 pub fn staged_session_filters_allow_name(
486 &self,
487 name: &str,
488 ) -> Result<bool, ToolScopeApplyError> {
489 self.state
490 .read()
491 .map(|state| {
492 Self::compose(&[
493 Self::effective_filter_for_current_projection(
494 &state,
495 &state.durable_state.inherited_base_filter,
496 ),
497 Self::effective_filter_for_current_projection(
498 &state,
499 &state.durable_state.staged_filter,
500 ),
501 ])
502 .allows(name)
503 })
504 .map_err(|_| ToolScopeApplyError::LockPoisoned)
505 }
506
507 pub fn base_tools_snapshot(&self) -> Result<Arc<[Arc<ToolDef>]>, ToolScopeApplyError> {
509 self.state
510 .read()
511 .map(|state| Arc::clone(&state.base_tools))
512 .map_err(|_| ToolScopeApplyError::LockPoisoned)
513 }
514
515 pub fn base_tool_names(&self) -> Result<BTreeSet<String>, ToolScopeApplyError> {
517 self.state
518 .read()
519 .map(|state| {
520 state
521 .known_base_names
522 .iter()
523 .cloned()
524 .collect::<BTreeSet<_>>()
525 })
526 .map_err(|_| ToolScopeApplyError::LockPoisoned)
527 }
528
529 pub fn revisions(&self) -> Result<(ToolScopeRevision, ToolScopeRevision), ToolScopeApplyError> {
531 self.state
532 .read()
533 .map(|state| {
534 (
535 ToolScopeRevision(state.durable_state.active_revision),
536 ToolScopeRevision(state.durable_state.staged_revision),
537 )
538 })
539 .map_err(|_| ToolScopeApplyError::LockPoisoned)
540 }
541
542 pub fn missing_requested_names(&self) -> Result<BTreeSet<String>, ToolScopeApplyError> {
544 self.state
545 .read()
546 .map(|state| {
547 state
548 .durable_state
549 .active_requested_deferred_names
550 .iter()
551 .filter(|name| !state.known_base_names.contains(name.as_str()))
552 .cloned()
553 .collect::<BTreeSet<_>>()
554 })
555 .map_err(|_| ToolScopeApplyError::LockPoisoned)
556 }
557
558 pub fn missing_filter_names(&self) -> Result<BTreeSet<String>, ToolScopeApplyError> {
560 self.state
561 .read()
562 .map(|state| {
563 durable_filter_names(&state.durable_state)
564 .into_iter()
565 .filter(|name| !state.known_base_names.contains(name.as_str()))
566 .collect::<BTreeSet<_>>()
567 })
568 .map_err(|_| ToolScopeApplyError::LockPoisoned)
569 }
570
571 pub fn stage_requested_deferred_names(
573 &self,
574 names: BTreeSet<String>,
575 ) -> Result<ToolScopeRevision, ToolScopeStageError> {
576 let mut state = self
577 .state
578 .write()
579 .map_err(|_| ToolScopeStageError::LockPoisoned)?;
580 let revision = ToolScopeRevision(self.next_revision.fetch_add(1, Ordering::SeqCst) + 1);
581 state.durable_state.staged_requested_deferred_names = names;
582 state.durable_state.staged_revision = revision.0;
583 Ok(revision)
584 }
585
586 pub fn add_requested_deferred_names(
588 &self,
589 names: &BTreeSet<String>,
590 witnesses: &std::collections::BTreeMap<String, ToolVisibilityWitness>,
591 ) -> Result<ToolScopeRevision, ToolScopeStageError> {
592 let mut state = self
593 .state
594 .write()
595 .map_err(|_| ToolScopeStageError::LockPoisoned)?;
596 let revision = ToolScopeRevision(self.next_revision.fetch_add(1, Ordering::SeqCst) + 1);
597 state
598 .durable_state
599 .staged_requested_deferred_names
600 .extend(names.iter().cloned());
601 state.durable_state.requested_witnesses.extend(
602 witnesses
603 .iter()
604 .map(|(name, witness)| (name.clone(), witness.clone())),
605 );
606 state.durable_state.staged_revision = revision.0;
607 Ok(revision)
608 }
609
610 #[cfg(test)]
611 pub(crate) fn inject_boundary_failure_once_for_test(&self) {
612 self.fail_next_boundary_apply.store(true, Ordering::SeqCst);
613 }
614}
615
616#[derive(Debug, Clone)]
618pub struct ToolScopeHandle {
619 state: Arc<RwLock<ToolScopeState>>,
620 next_revision: Arc<AtomicU64>,
621}
622
623impl ToolScopeHandle {
624 pub fn stage_external_filter(
626 &self,
627 filter: ToolFilter,
628 ) -> Result<ToolScopeRevision, ToolScopeStageError> {
629 let mut state = self
630 .state
631 .write()
632 .map_err(|_| ToolScopeStageError::LockPoisoned)?;
633
634 let mut known_names = state.known_base_names.clone();
635 for control_name in &state.control_tool_names {
636 known_names.remove(control_name);
637 }
638 known_names.extend(durable_filter_names(&state.durable_state));
639 validate_filter(&filter, &known_names)?;
640
641 let revision = ToolScopeRevision(self.next_revision.fetch_add(1, Ordering::SeqCst) + 1);
642 record_filter_witnesses(&mut state, &filter);
643 state.durable_state.staged_filter = filter;
644 state.durable_state.staged_revision = revision.0;
645 Ok(revision)
646 }
647
648 pub(crate) fn staged_revision(&self) -> Result<ToolScopeRevision, ToolScopeStageError> {
649 self.state
650 .read()
651 .map(|state| ToolScopeRevision(state.durable_state.staged_revision))
652 .map_err(|_| ToolScopeStageError::LockPoisoned)
653 }
654
655 pub fn set_turn_overlay(
657 &self,
658 allow: Option<HashSet<String>>,
659 deny: HashSet<String>,
660 ) -> Result<(), ToolScopeStageError> {
661 let mut state = self
662 .state
663 .write()
664 .map_err(|_| ToolScopeStageError::LockPoisoned)?;
665
666 if let Some(allow_set) = &allow {
667 validate_filter(
668 &ToolFilter::Allow(allow_set.clone()),
669 &state.known_base_names,
670 )?;
671 }
672 if !deny.is_empty() {
673 validate_filter(&ToolFilter::Deny(deny.clone()), &state.known_base_names)?;
674 }
675
676 state.active_turn_allow = allow;
677 state.active_turn_deny = deny;
678 Ok(())
679 }
680
681 pub fn clear_turn_overlay(&self) {
683 if let Ok(mut state) = self.state.write() {
684 state.active_turn_allow = None;
685 state.active_turn_deny.clear();
686 }
687 }
688}
689
690fn validate_filter(
691 filter: &ToolFilter,
692 known_base_names: &HashSet<String>,
693) -> Result<(), ToolScopeStageError> {
694 let Some(names) = filter.names() else {
695 return Ok(());
696 };
697
698 let mut unknown: Vec<String> = names
699 .iter()
700 .filter(|name| !known_base_names.contains(*name))
701 .cloned()
702 .collect();
703
704 if unknown.is_empty() {
705 return Ok(());
706 }
707
708 unknown.sort_unstable();
709 unknown.dedup();
710 Err(ToolScopeStageError::UnknownTools { names: unknown })
711}
712
713fn durable_filter_names(state: &SessionToolVisibilityState) -> HashSet<String> {
714 let mut names = HashSet::new();
715 for filter in [
716 &state.inherited_base_filter,
717 &state.active_filter,
718 &state.staged_filter,
719 ] {
720 if let Some(filter_names) = filter.names() {
721 names.extend(filter_names.iter().cloned());
722 }
723 }
724 names
725}
726
727fn record_filter_witnesses(state: &mut ToolScopeState, filter: &ToolFilter) {
728 let Some(filter_names) = filter.names() else {
729 return;
730 };
731
732 for name in filter_names {
733 if let Some(tool) = state.base_tools.iter().find(|tool| tool.name == *name) {
734 state.durable_state.filter_witnesses.insert(
735 name.clone(),
736 ToolVisibilityWitness {
737 stable_owner_key: stable_owner_key_for_tool(tool),
738 last_seen_provenance: tool.provenance.clone(),
739 },
740 );
741 }
742 }
743}
744
745#[cfg(test)]
746#[allow(clippy::unwrap_used, clippy::expect_used)]
747mod tests {
748 use super::{ToolFilter, ToolScope, ToolScopeStageError};
749 use crate::types::{ToolDef, ToolProvenance, ToolSourceKind};
750 use std::collections::HashSet;
751 use std::sync::Arc;
752
753 fn set(names: &[&str]) -> HashSet<String> {
754 names.iter().map(|name| (*name).to_string()).collect()
755 }
756
757 fn tools(names: &[&str]) -> Arc<[Arc<ToolDef>]> {
758 names
759 .iter()
760 .map(|name| {
761 Arc::new(ToolDef {
762 name: (*name).to_string(),
763 description: format!("{name} tool"),
764 input_schema: serde_json::json!({ "type": "object" }),
765 provenance: None,
766 })
767 })
768 .collect::<Vec<_>>()
769 .into()
770 }
771
772 fn tool_with_provenance(name: &str, source_id: &str) -> Arc<ToolDef> {
773 Arc::new(ToolDef {
774 name: name.to_string(),
775 description: format!("{name} tool"),
776 input_schema: serde_json::json!({ "type": "object" }),
777 provenance: Some(ToolProvenance {
778 kind: ToolSourceKind::Callback,
779 source_id: source_id.to_string(),
780 }),
781 })
782 }
783
784 #[test]
785 fn stage_revision_is_monotonic() {
786 let scope = ToolScope::new(tools(&["a", "b", "c"]));
787 let handle = scope.handle();
788
789 let first = handle
790 .stage_external_filter(ToolFilter::Deny(set(&["a"])))
791 .unwrap();
792 let second = handle
793 .stage_external_filter(ToolFilter::Allow(set(&["b", "c"])))
794 .unwrap();
795
796 assert!(second > first);
797 assert_eq!(handle.staged_revision().unwrap(), second);
798 }
799
800 #[test]
801 fn stage_rejects_unknown_tools() {
802 let scope = ToolScope::new(tools(&["known"]));
803 let handle = scope.handle();
804
805 let err = handle
806 .stage_external_filter(ToolFilter::Allow(set(&["known", "missing"])))
807 .unwrap_err();
808
809 assert_eq!(
810 err,
811 ToolScopeStageError::UnknownTools {
812 names: vec!["missing".to_string()],
813 }
814 );
815 }
816
817 #[test]
818 fn control_tools_remain_visible_and_unfilterable() {
819 let scope = ToolScope::new_with_control_tool_names(
820 tools(&["visible", "tool_catalog_search"]),
821 set(&["tool_catalog_search"]),
822 );
823 let handle = scope.handle();
824
825 let err = handle
826 .stage_external_filter(ToolFilter::Deny(set(&["tool_catalog_search"])))
827 .unwrap_err();
828 assert_eq!(
829 err,
830 ToolScopeStageError::UnknownTools {
831 names: vec!["tool_catalog_search".to_string()],
832 }
833 );
834
835 handle
836 .stage_external_filter(ToolFilter::Deny(set(&["visible"])))
837 .unwrap();
838 let applied = scope
839 .apply_staged(tools(&["visible", "tool_catalog_search"]))
840 .unwrap();
841
842 assert_eq!(
843 applied.visible_names,
844 vec!["tool_catalog_search".to_string()],
845 "control tools should remain visible even when the session plane is filtered out"
846 );
847 }
848
849 #[test]
850 fn deferred_tools_stay_hidden_until_requested_boundary_applies() {
851 let scope = ToolScope::new_with_projection_names(
852 tools(&["visible", "deferred"]),
853 HashSet::new(),
854 set(&["deferred"]),
855 );
856
857 assert_eq!(
858 scope.visible_tool_names().unwrap(),
859 ["visible".to_string()].into_iter().collect(),
860 "deferred tools should be hidden until requested"
861 );
862
863 scope
864 .add_requested_deferred_names(
865 &["deferred".to_string()].into_iter().collect(),
866 &std::collections::BTreeMap::new(),
867 )
868 .unwrap();
869
870 assert_eq!(
871 scope.visible_tool_names().unwrap(),
872 ["visible".to_string()].into_iter().collect(),
873 "staged requests should not publish deferred tools before the next boundary"
874 );
875
876 let applied = scope.apply_staged(tools(&["visible", "deferred"])).unwrap();
877 assert_eq!(
878 applied.visible_names,
879 vec!["visible".to_string(), "deferred".to_string()],
880 "the next boundary should promote requested deferred tools into the visible set"
881 );
882 }
883
884 #[test]
885 fn late_deferred_names_stay_hidden_until_requested_after_projection_refresh() {
886 let scope = ToolScope::new(tools(&["visible"]));
887
888 let late_deferred = tools(&["visible", "late_deferred"]);
889 let applied = scope
890 .apply_staged_projection(late_deferred, HashSet::new(), set(&["late_deferred"]))
891 .expect("projection refresh should succeed");
892
893 assert_eq!(
894 applied.visible_names,
895 vec!["visible".to_string()],
896 "late deferred additions should stay hidden until explicitly requested"
897 );
898 }
899
900 #[test]
901 fn filter_algebra_is_most_restrictive() {
902 let allow_a_b = ToolFilter::Allow(set(&["a", "b"]));
903 let allow_b_c = ToolFilter::Allow(set(&["b", "c"]));
904 let deny_c = ToolFilter::Deny(set(&["c"]));
905 let deny_b = ToolFilter::Deny(set(&["b"]));
906
907 let composed_allow = ToolScope::compose(&[allow_a_b.clone(), allow_b_c]);
909 assert!(composed_allow.allows("b"));
910 assert!(!composed_allow.allows("a"));
911 assert!(!composed_allow.allows("c"));
912
913 let composed_deny = ToolScope::compose(&[deny_c, deny_b.clone()]);
915 assert!(!composed_deny.allows("b"));
916 assert!(!composed_deny.allows("c"));
917 assert!(composed_deny.allows("a"));
918
919 let composed_precedence = ToolScope::compose(&[allow_a_b, deny_b]);
921 assert!(composed_precedence.allows("a"));
922 assert!(!composed_precedence.allows("b"));
923 }
924
925 #[test]
926 fn staged_update_is_boundary_only_until_apply_staged() {
927 let scope = ToolScope::new(tools(&["visible", "secret"]));
928 let handle = scope.handle();
929
930 assert_eq!(
931 scope
932 .visible_tools()
933 .iter()
934 .map(|t| t.name.clone())
935 .collect::<Vec<_>>(),
936 vec!["visible".to_string(), "secret".to_string()]
937 );
938
939 handle
940 .stage_external_filter(ToolFilter::Deny(set(&["secret"])))
941 .unwrap();
942
943 assert_eq!(
945 scope
946 .visible_tools()
947 .iter()
948 .map(|t| t.name.clone())
949 .collect::<Vec<_>>(),
950 vec!["visible".to_string(), "secret".to_string()]
951 );
952
953 let applied = scope
954 .apply_staged(tools(&["visible", "secret"]))
955 .expect("boundary apply should succeed");
956 assert!(applied.visible_changed());
957 assert_eq!(applied.visible_names, vec!["visible".to_string()]);
958 assert_eq!(
959 scope
960 .visible_tools()
961 .iter()
962 .map(|t| t.name.clone())
963 .collect::<Vec<_>>(),
964 vec!["visible".to_string()]
965 );
966 }
967
968 #[test]
969 fn structural_base_change_preserves_dormant_filter_names() {
970 let scope = ToolScope::new(tools(&["a", "b", "c"]));
971 let handle = scope.handle();
972
973 handle
974 .stage_external_filter(ToolFilter::Deny(set(&["c"])))
975 .unwrap();
976 scope
977 .apply_staged(tools(&["a", "b", "c"]))
978 .expect("initial apply should succeed");
979 assert_eq!(
980 scope
981 .visible_tools()
982 .iter()
983 .map(|t| t.name.clone())
984 .collect::<Vec<_>>(),
985 vec!["a".to_string(), "b".to_string()]
986 );
987
988 handle
991 .stage_external_filter(ToolFilter::Allow(set(&["b", "c"])))
992 .unwrap();
993
994 let applied = scope
995 .apply_staged(tools(&["a", "b"]))
996 .expect("boundary apply after structural delta should succeed");
997
998 assert!(applied.base_changed());
999 assert_eq!(applied.visible_names, vec!["b".to_string()]);
1000 assert_eq!(
1001 scope
1002 .visible_tools()
1003 .iter()
1004 .map(|t| t.name.clone())
1005 .collect::<Vec<_>>(),
1006 vec!["b".to_string()]
1007 );
1008 let visibility_state = scope.visibility_state().expect("visibility state");
1009 assert_eq!(
1010 visibility_state.active_filter,
1011 ToolFilter::Allow(set(&["b", "c"])),
1012 "missing names should remain in durable active filter state"
1013 );
1014 assert_eq!(
1015 visibility_state.staged_filter,
1016 ToolFilter::Allow(set(&["b", "c"])),
1017 "missing names should remain in durable staged filter state"
1018 );
1019 }
1020
1021 #[test]
1022 fn requested_witness_mismatch_prevents_rebinding_a_dormant_deferred_name() {
1023 let requested = tool_with_provenance("deferred", "owner-a");
1024 let rebound = tool_with_provenance("deferred", "owner-b");
1025 let scope = ToolScope::new_with_projection_names(
1026 vec![Arc::clone(&requested)].into(),
1027 HashSet::new(),
1028 set(&["deferred"]),
1029 );
1030
1031 scope
1032 .add_requested_deferred_names(
1033 &["deferred".to_string()].into_iter().collect(),
1034 &[(
1035 "deferred".to_string(),
1036 crate::ToolVisibilityWitness {
1037 stable_owner_key: Some("callback:owner-a".to_string()),
1038 last_seen_provenance: requested.provenance.clone(),
1039 },
1040 )]
1041 .into_iter()
1042 .collect(),
1043 )
1044 .unwrap();
1045 scope
1046 .apply_staged_projection(
1047 vec![Arc::clone(&requested)].into(),
1048 HashSet::new(),
1049 set(&["deferred"]),
1050 )
1051 .unwrap();
1052 assert_eq!(
1053 scope.visible_tool_names().unwrap(),
1054 ["deferred".to_string()].into_iter().collect(),
1055 "the matching owner should remain visible"
1056 );
1057
1058 scope
1059 .apply_staged_projection(
1060 vec![Arc::clone(&rebound)].into(),
1061 HashSet::new(),
1062 set(&["deferred"]),
1063 )
1064 .unwrap();
1065 assert!(
1066 scope.visible_tool_names().unwrap().is_empty(),
1067 "a different owner must not inherit prior deferred visibility intent"
1068 );
1069 }
1070
1071 #[test]
1072 fn filter_witness_mismatch_prevents_rebinding_a_dormant_filter_name() {
1073 let original = tool_with_provenance("a", "owner-a");
1074 let rebound = tool_with_provenance("a", "owner-b");
1075 let visible = tool_with_provenance("b", "owner-b");
1076 let scope = ToolScope::new(vec![Arc::clone(&original), Arc::clone(&visible)].into());
1077 let handle = scope.handle();
1078
1079 handle
1080 .stage_external_filter(ToolFilter::Deny(set(&["a"])))
1081 .unwrap();
1082 scope
1083 .apply_staged(vec![Arc::clone(&original), Arc::clone(&visible)].into())
1084 .unwrap();
1085 assert_eq!(
1086 scope.visible_tool_names().unwrap(),
1087 ["b".to_string()].into_iter().collect(),
1088 "the original owner should be hidden by the deny filter"
1089 );
1090
1091 scope
1092 .apply_staged(vec![Arc::clone(&rebound), Arc::clone(&visible)].into())
1093 .unwrap();
1094 assert_eq!(
1095 scope.visible_tool_names().unwrap(),
1096 ["a".to_string(), "b".to_string()].into_iter().collect(),
1097 "a different owner must not inherit the dormant filter intent"
1098 );
1099 }
1100
1101 #[test]
1102 fn turn_overlay_is_ephemeral_and_most_restrictive() {
1103 let scope = ToolScope::new(tools(&["a", "b", "c"]));
1104 let handle = scope.handle();
1105
1106 handle
1107 .stage_external_filter(ToolFilter::Allow(set(&["a", "b"])))
1108 .unwrap();
1109 scope
1110 .apply_staged(tools(&["a", "b", "c"]))
1111 .expect("initial apply should succeed");
1112
1113 assert_eq!(
1114 scope
1115 .visible_tools()
1116 .iter()
1117 .map(|t| t.name.clone())
1118 .collect::<Vec<_>>(),
1119 vec!["a".to_string(), "b".to_string()]
1120 );
1121
1122 handle
1123 .set_turn_overlay(Some(set(&["b", "c"])), set(&["b"]))
1124 .unwrap();
1125 assert_eq!(
1126 scope
1127 .visible_tools()
1128 .iter()
1129 .map(|t| t.name.clone())
1130 .collect::<Vec<_>>(),
1131 Vec::<String>::new(),
1132 "external allow(a,b) + turn allow(b,c) + turn deny(b) should be empty"
1133 );
1134
1135 handle.clear_turn_overlay();
1136 assert_eq!(
1137 scope
1138 .visible_tools()
1139 .iter()
1140 .map(|t| t.name.clone())
1141 .collect::<Vec<_>>(),
1142 vec!["a".to_string(), "b".to_string()]
1143 );
1144 }
1145
1146 #[test]
1147 fn set_base_filter_restricts_visible_tools() {
1148 let scope = ToolScope::new(tools(&["a", "b", "c"]));
1149
1150 assert_eq!(
1152 scope
1153 .visible_tools()
1154 .iter()
1155 .map(|t| t.name.clone())
1156 .collect::<Vec<_>>(),
1157 vec!["a".to_string(), "b".to_string(), "c".to_string()]
1158 );
1159
1160 scope
1162 .set_base_filter(ToolFilter::Allow(set(&["a", "b"])))
1163 .unwrap();
1164
1165 assert_eq!(
1166 scope
1167 .visible_tools()
1168 .iter()
1169 .map(|t| t.name.clone())
1170 .collect::<Vec<_>>(),
1171 vec!["a".to_string(), "b".to_string()]
1172 );
1173 }
1174
1175 #[test]
1176 fn set_base_filter_composes_with_external_filter() {
1177 let scope = ToolScope::new(tools(&["a", "b", "c"]));
1178 let handle = scope.handle();
1179
1180 scope
1182 .set_base_filter(ToolFilter::Allow(set(&["a", "b"])))
1183 .unwrap();
1184
1185 handle
1187 .stage_external_filter(ToolFilter::Allow(set(&["b", "c"])))
1188 .unwrap();
1189 scope.apply_staged(tools(&["a", "b", "c"])).unwrap();
1190
1191 assert_eq!(
1193 scope
1194 .visible_tools()
1195 .iter()
1196 .map(|t| t.name.clone())
1197 .collect::<Vec<_>>(),
1198 vec!["b".to_string()]
1199 );
1200 }
1201
1202 #[test]
1203 fn inherited_metadata_key_is_distinct_from_external() {
1204 assert_ne!(
1205 super::INHERITED_TOOL_FILTER_METADATA_KEY,
1206 super::EXTERNAL_TOOL_FILTER_METADATA_KEY
1207 );
1208 }
1209}