Skip to main content

meerkat_core/
tool_scope.rs

1//! Tool visibility scope and external filter staging.
2
3use 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/// Visibility filter for tools.
12#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub enum ToolFilter {
14    /// All tools are visible.
15    #[default]
16    All,
17    /// Only listed tools are visible.
18    Allow(HashSet<String>),
19    /// Listed tools are hidden.
20    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
32/// Session metadata key storing the persisted external tool filter.
33pub const EXTERNAL_TOOL_FILTER_METADATA_KEY: &str = "tool_scope_external_filter";
34
35/// Session metadata key storing the inherited tool filter from a parent agent.
36///
37/// When a child session is created from a parent mob, the parent's visible tool
38/// set is captured as a base filter. On session rebuild, this key is recovered
39/// and applied as the `ToolScope` base filter.
40pub const INHERITED_TOOL_FILTER_METADATA_KEY: &str = "tool_scope_inherited_filter";
41
42/// Monotonic revision for staged external visibility updates.
43#[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/// Composed filter representation using most-restrictive semantics.
75#[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/// Runtime tool scope used to determine provider-visible tools.
89#[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    /// Build a scope with no base restriction.
123    pub fn new(base_tools: Arc<[Arc<ToolDef>]>) -> Self {
124        Self::new_with_projection_names(base_tools, HashSet::new(), HashSet::new())
125    }
126
127    /// Build a scope with an explicit set of control-plane tool names.
128    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    /// Build a scope with explicit control-plane and deferred-session names.
136    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    /// Returns the currently visible tools using base + active external filter composition.
160    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    /// Returns current visible tools, or an explicit error for boundary fail-safe handling.
172    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    /// Return a handle for thread-safe staged external updates.
194    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    /// Atomically apply staged state at CallingLlm boundary.
202    ///
203    /// Sequence:
204    /// 1) Refresh base from dispatcher snapshot.
205    /// 2) Prune active/pending filters against base deltas.
206    /// 3) Apply staged external filter revision.
207    /// 4) Compute visible tools.
208    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    /// Atomically apply staged state and refresh the live projection names.
226    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    /// Compose filters with most-restrictive semantics.
288    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    /// Helper: intersection for allow-list composition.
311    fn allow_intersection(left: &HashSet<String>, right: &HashSet<String>) -> HashSet<String> {
312        left.intersection(right).cloned().collect()
313    }
314
315    /// Helper: union for deny-list composition.
316    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    /// Set the base filter for this scope.
433    ///
434    /// The base filter is the most fundamental restriction layer — it is
435    /// composed with external and turn-level filters using most-restrictive
436    /// semantics. This is used for inherited tool visibility from a parent
437    /// agent's scope.
438    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    /// Replace the durable tool visibility state carried by this projection bridge.
449    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    /// Snapshot the current durable visibility state.
466    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    /// Return the names currently visible to the session plane.
474    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    /// Return whether the staged durable session filters would allow a session-plane tool name
484    /// to become visible after the next boundary.
485    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    /// Return the current base tool snapshot.
508    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    /// Return the current base tool names.
516    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    /// Return the currently configured active and staged revisions.
530    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    /// Return any requested deferred names that are not currently present in the base snapshot.
543    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    /// Return any durable filter names that are not currently present in the base snapshot.
559    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    /// Record durable requested deferred names for the next boundary.
572    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    /// Add durable requested deferred names for the next boundary.
587    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/// Thread-safe handle for staging external scope updates.
617#[derive(Debug, Clone)]
618pub struct ToolScopeHandle {
619    state: Arc<RwLock<ToolScopeState>>,
620    next_revision: Arc<AtomicU64>,
621}
622
623impl ToolScopeHandle {
624    /// Stage an external filter update and return its monotonic revision.
625    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    /// Set or clear an ephemeral per-turn overlay.
656    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    /// Clear ephemeral per-turn overlay.
682    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        // Allow lists intersect.
908        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        // Deny lists union.
914        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        // Deny wins after allow.
920        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        // Still unchanged until boundary apply.
944        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        // Pending filter still references `c`, and the durable state should
989        // preserve that dormant intent even after the base snapshot shrinks.
990        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        // All visible initially
1151        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        // Set base filter to allow only a and b
1161        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        // Base restricts to a and b
1181        scope
1182            .set_base_filter(ToolFilter::Allow(set(&["a", "b"])))
1183            .unwrap();
1184
1185        // External further restricts to b and c
1186        handle
1187            .stage_external_filter(ToolFilter::Allow(set(&["b", "c"])))
1188            .unwrap();
1189        scope.apply_staged(tools(&["a", "b", "c"])).unwrap();
1190
1191        // Most-restrictive: intersection = b only
1192        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}