use meerkat_core::WitnessedToolFilter;
use meerkat_core::tool_scope::ToolFilter;
use meerkat_core::types::{ToolDef, ToolProvenance, ToolSourceId, ToolSourceKind};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParentToolScopeSnapshot {
pub visible_tool_names: HashSet<String>,
pub visible_tool_defs: Vec<ToolDef>,
pub captured_at: chrono::DateTime<chrono::Utc>,
}
impl ParentToolScopeSnapshot {
pub fn to_tool_filter(&self) -> ToolFilter {
ToolFilter::Allow(self.visible_tool_names.iter().cloned().collect())
}
pub fn to_witnessed_tool_filter(&self) -> WitnessedToolFilter {
self.witnessed_filter_for(self.to_tool_filter())
}
pub fn with_overlays(
&self,
allow_overlay: Option<&HashSet<String>>,
deny_overlay: Option<&HashSet<String>>,
) -> ToolFilter {
let mut names = self.visible_tool_names.clone();
if let Some(allow) = allow_overlay {
names = names.intersection(allow).cloned().collect();
}
if let Some(deny) = deny_overlay {
for name in deny {
names.remove(name);
}
}
ToolFilter::Allow(names.into_iter().collect())
}
pub fn with_witnessed_overlays(
&self,
allow_overlay: Option<&HashSet<String>>,
deny_overlay: Option<&HashSet<String>>,
) -> WitnessedToolFilter {
self.witnessed_filter_for(self.with_overlays(allow_overlay, deny_overlay))
}
fn witnessed_filter_for(&self, filter: ToolFilter) -> WitnessedToolFilter {
let witnesses = inherited_filter_witnesses_for_defs(&filter, &self.visible_tool_defs);
WitnessedToolFilter::new(filter, witnesses)
}
pub fn from_tools(tools: &[Arc<ToolDef>]) -> Self {
Self {
visible_tool_names: tools.iter().map(|t| t.name.to_string()).collect(),
visible_tool_defs: tools.iter().map(|t| (**t).clone()).collect(),
captured_at: chrono::Utc::now(),
}
}
}
fn inherited_filter_witnesses_for_defs(
filter: &ToolFilter,
tool_defs: &[ToolDef],
) -> BTreeMap<String, meerkat_core::ToolVisibilityWitness> {
let filter_names = match filter {
ToolFilter::All => return BTreeMap::new(),
ToolFilter::Allow(names) | ToolFilter::Deny(names) => names,
};
filter_names
.iter()
.filter_map(|name| {
let tool = tool_defs.iter().find(|tool| tool.name == name.as_str())?;
let provenance = tool
.provenance
.clone()
.unwrap_or_else(|| synthetic_parent_tool_provenance(name));
Some((
name.as_str().to_string(),
meerkat_core::ToolVisibilityWitness {
stable_owner_key: Some(
meerkat_core::tool_catalog::stable_owner_key_from_provenance(&provenance),
),
last_seen_provenance: Some(provenance),
},
))
})
.collect()
}
fn synthetic_parent_tool_provenance(tool_name: &str) -> ToolProvenance {
ToolProvenance {
kind: ToolSourceKind::Callback,
source_id: ToolSourceId::new(format!("parent_snapshot:{tool_name}")),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json;
use std::collections::HashSet;
fn make_tool(name: &str) -> Arc<ToolDef> {
Arc::new(ToolDef {
name: name.into(),
description: format!("{name} tool"),
input_schema: serde_json::json!({"type": "object"}),
provenance: None,
})
}
#[test]
fn roundtrip_serialization() {
let tools = vec![make_tool("read"), make_tool("write")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let json = serde_json::to_string(&snapshot).unwrap();
let parsed: ParentToolScopeSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.visible_tool_names, snapshot.visible_tool_names);
assert_eq!(parsed.visible_tool_defs.len(), 2);
}
#[test]
fn to_tool_filter_produces_allow() {
let tools = vec![make_tool("a"), make_tool("b")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let filter = snapshot.to_tool_filter();
match filter {
ToolFilter::Allow(names) => {
assert!(names.contains("a"));
assert!(names.contains("b"));
assert_eq!(names.len(), 2);
}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn with_overlays_intersects_allow() {
let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let allow: HashSet<String> = ["b", "c", "d"].iter().copied().map(String::from).collect();
let filter = snapshot.with_overlays(Some(&allow), None);
match filter {
ToolFilter::Allow(names) => {
assert!(names.contains("b"));
assert!(names.contains("c"));
assert!(!names.contains("a")); assert!(!names.contains("d")); }
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn with_overlays_removes_deny() {
let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let deny: HashSet<String> = ["b"].iter().copied().map(String::from).collect();
let filter = snapshot.with_overlays(None, Some(&deny));
match filter {
ToolFilter::Allow(names) => {
assert!(names.contains("a"));
assert!(names.contains("c"));
assert!(!names.contains("b"));
}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn with_overlays_both() {
let tools = vec![make_tool("a"), make_tool("b"), make_tool("c")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let allow: HashSet<String> = ["a", "b"].iter().copied().map(String::from).collect();
let deny: HashSet<String> = ["b"].iter().copied().map(String::from).collect();
let filter = snapshot.with_overlays(Some(&allow), Some(&deny));
match filter {
ToolFilter::Allow(names) => {
assert!(names.contains("a"));
assert!(!names.contains("b"));
assert!(!names.contains("c"));
assert_eq!(names.len(), 1);
}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn from_tools_captures_names_and_defs() {
let tools = vec![make_tool("x"), make_tool("y")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
assert_eq!(snapshot.visible_tool_names.len(), 2);
assert_eq!(snapshot.visible_tool_defs.len(), 2);
assert!(snapshot.visible_tool_names.contains("x"));
assert!(snapshot.visible_tool_names.contains("y"));
}
#[test]
fn unprovenanced_parent_tools_get_inherited_filter_witnesses() {
let tools = vec![make_tool("external_tool")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let authority = snapshot.to_witnessed_tool_filter();
let witness = authority
.witnesses
.get("external_tool")
.expect("external tool should have synthesized witness");
assert!(
witness.has_provenance_identity_witness(),
"inherited filters need provenance-backed witnesses"
);
assert_eq!(
witness
.last_seen_provenance
.as_ref()
.map(|provenance| provenance.source_id.as_str()),
Some("parent_snapshot:external_tool")
);
meerkat_core::tool_scope::validate_witnessed_filter_authority(
&authority.filter,
&authority.witnesses,
)
.expect("synthesized witness should satisfy inherited visibility validation");
}
}