use meerkat_core::WitnessedToolFilter;
use meerkat_core::tool_scope::{ToolFilter, ToolScopeStageError};
use meerkat_core::types::ToolDef;
use serde::{Deserialize, Serialize};
use std::collections::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) -> Result<WitnessedToolFilter, ToolScopeStageError> {
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>>,
) -> Result<WitnessedToolFilter, ToolScopeStageError> {
self.witnessed_filter_for(self.with_overlays(allow_overlay, deny_overlay))
}
fn witnessed_filter_for(
&self,
filter: ToolFilter,
) -> Result<WitnessedToolFilter, ToolScopeStageError> {
let witnesses = meerkat_core::tool_scope::filter_witnesses_for_tool_defs(
&self.visible_tool_defs,
&filter,
);
meerkat_core::tool_scope::validate_witnessed_filter_authority(&filter, &witnesses)?;
Ok(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(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use meerkat_core::types::{ToolProvenance, ToolSourceKind};
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,
})
}
fn make_provenanced_tool(name: &str) -> Arc<ToolDef> {
Arc::new(ToolDef {
name: name.into(),
description: format!("{name} tool"),
input_schema: serde_json::json!({"type": "object"}),
provenance: Some(ToolProvenance {
kind: ToolSourceKind::Callback,
source_id: format!("parent:{name}").into(),
}),
})
}
#[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_cannot_mint_inherited_filter_witnesses() {
let tools = vec![make_tool("external_tool")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let err = snapshot
.to_witnessed_tool_filter()
.expect_err("unprovenanced parent tool should not mint inherited witness");
assert_eq!(
err,
ToolScopeStageError::MissingFilterWitnesses {
names: vec!["external_tool".into()]
}
);
}
#[test]
fn provenanced_parent_tools_get_inherited_filter_witnesses() {
let tools = vec![make_provenanced_tool("external_tool")];
let snapshot = ParentToolScopeSnapshot::from_tools(&tools);
let authority = snapshot
.to_witnessed_tool_filter()
.expect("provenanced parent tool should carry inherited witness");
let witness = authority
.witnesses
.get("external_tool")
.expect("external tool should have provenance witness");
assert!(
witness.has_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:external_tool")
);
meerkat_core::tool_scope::validate_witnessed_filter_authority(
&authority.filter,
&authority.witnesses,
)
.expect("provenance witness should satisfy inherited visibility validation");
}
}