use crate::generated::types::T1Leaf;
use super::super::facts::FactSet;
use super::super::tier1;
use super::super::types::{
Classification, EvalContext, Event, SKIP_UNKNOWN_FIELD, SKIP_UNKNOWN_PREDICATE,
};
use super::catalogue::ToolEntry;
const FEED_SELECTOR_FIELDS: &[&str] = &[
"tool.name",
"input.strings",
"path.class",
"path.value",
"url.host",
"url.tld",
"url.scheme",
"url.boundary",
];
const FEED_SELECTOR_PREDICATES: &[&str] =
&["exists", "equals", "in_set", "keyword", "prefix", "glob"];
fn feed_field_ok(field: &str) -> bool {
FEED_SELECTOR_FIELDS.contains(&field) || field.starts_with("input.")
}
pub fn validate_selector(leaf: &T1Leaf) -> Result<(), &'static str> {
if !feed_field_ok(leaf.field.as_deref().unwrap_or("")) {
return Err(SKIP_UNKNOWN_FIELD);
}
if !FEED_SELECTOR_PREDICATES.contains(&leaf.pred.as_deref().unwrap_or("")) {
return Err(SKIP_UNKNOWN_PREDICATE);
}
Ok(())
}
pub fn claims(entry: &ToolEntry, event: &Event, cls: &Classification) -> bool {
entry
.selectors
.iter()
.all(|leaf| selector_matches(leaf, event, cls))
}
pub(super) fn selector_matches(leaf: &T1Leaf, event: &Event, cls: &Classification) -> bool {
let Some(field) = leaf.field.as_deref() else {
return false;
};
let pred = leaf.pred.as_deref().unwrap_or("");
if pred == "exists" && field == "tool.name" {
return true;
}
let facts = FactSet::default();
let ctx = EvalContext::new(event, cls, &facts, 0);
let values = tier1::field_values(field, &ctx);
if pred == "exists" {
return !values.is_empty();
}
let scan = tier1::ScanTable::default();
let result = scan.scan(&ctx);
values
.iter()
.any(|value| tier1::test_predicate(pred, field, value, leaf, &scan, &result))
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::catalogue::Catalogue;
use super::super::classify;
fn catalogue(json: serde_json::Value) -> Catalogue {
let feed = serde_json::from_value(json).expect("the fixture feed parses");
Catalogue::from_bundle(None, Some(&feed), &mut Vec::new()).expect("a catalogue")
}
fn event(tool: &str, input: serde_json::Value) -> Event {
Event {
tool_name: tool.to_string(),
tool_input: input,
..Event::default()
}
}
#[test]
fn an_entry_with_no_selectors_classifies_every_call_of_the_tool() {
let catalogue = catalogue(serde_json::json!({"entries": [{
"tool_key": "mcp__s3__delete_object",
"selectors": [],
"effects": [{"verb": "delete", "target_class": "data_store"}],
}]}));
let event = event("mcp__s3__delete_object", serde_json::json!({"Key": "x"}));
let cls = classify(&event, &FactSet::default(), Some(&catalogue));
assert_eq!(cls.effects.len(), 1);
assert!(cls.unknown.is_empty());
}
#[test]
fn a_selector_miss_is_silence_and_never_a_wholesale_erasure() {
let catalogue = catalogue(serde_json::json!({"entries": [{
"tool_key": "mcp__s3__put_bucket_lifecycle_configuration",
"selectors": [{"pred": "exists", "field": "input./Bucket"}],
"effects": [{"verb": "delete", "target_class": "data_store"}],
}]}));
let event = event(
"mcp__s3__put_bucket_lifecycle_configuration",
serde_json::json!({"LifecycleConfiguration": {"Rules": []}}),
);
let cls = classify(&event, &FactSet::default(), Some(&catalogue));
assert!(
cls.effects.iter().all(|e| e.verb.0 == "unknown"),
"the entry does not claim the tool, so the caller still falls back (Z19)"
);
assert_eq!(cls.unknown.len(), 1);
}
#[test]
fn prefix_reads_value_as_a_list_the_review_repro() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "prefix", "field": "input.strings", "value": "prod-",
}))
.unwrap();
let event = event(
"mcp__s3__delete_object",
serde_json::json!({"Bucket": "prod-data"}),
);
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn keyword_is_case_insensitive_and_reads_value_as_a_list() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "keyword", "field": "input.strings", "value": ["TERRAFORM"],
}))
.unwrap();
let event = event(
"Bash",
serde_json::json!({"command": "terraform destroy -auto-approve"}),
);
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn glob_falls_back_from_pattern_to_value() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "glob", "field": "input.strings", "value": "prod-*",
}))
.unwrap();
let event = event(
"mcp__s3__delete_object",
serde_json::json!({"Bucket": "prod-data"}),
);
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn equals_compares_any_json_value_not_just_strings() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "equals", "field": "input./Count", "value": 3,
}))
.unwrap();
let event = event("mcp__s3__list", serde_json::json!({"Count": 3}));
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn in_set_accepts_non_string_members() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "in_set", "field": "input./Count", "value": [1, 2, 3],
}))
.unwrap();
let event = event("mcp__s3__list", serde_json::json!({"Count": 3}));
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn tool_name_exists_is_true_even_when_empty() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "exists", "field": "tool.name",
}))
.unwrap();
let event = event("", serde_json::json!({}));
let cls = Classification::default();
assert!(selector_matches(&leaf, &event, &cls));
}
#[test]
fn a_non_string_input_pointer_value_does_not_stringify_for_keyword() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "keyword", "field": "input./Count", "value": "3",
}))
.unwrap();
let event = event("mcp__s3__list", serde_json::json!({"Count": 33}));
let cls = Classification::default();
assert!(!selector_matches(&leaf, &event, &cls));
}
#[test]
fn a_non_string_input_pointer_value_does_not_stringify_for_equals() {
let leaf: T1Leaf = serde_json::from_value(serde_json::json!({
"pred": "equals", "field": "input./Filter", "value": "{\"a\":1}",
}))
.unwrap();
let event = event("mcp__s3__list", serde_json::json!({"Filter": {"a": 1}}));
let cls = Classification::default();
assert!(!selector_matches(&leaf, &event, &cls));
}
}