use crate::generated::types::{EffectClassEntry, PolicyBundleEffectClasses, 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::attrs_of;
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 entries_for<'a>(
feed: Option<&'a PolicyBundleEffectClasses>,
tool_name: &str,
) -> Vec<&'a crate::generated::types::EffectClassEntry> {
feed.map(|feed| {
feed.entries
.iter()
.filter(|entry| entry.tool_key.as_deref() == Some(tool_name))
.collect()
})
.unwrap_or_default()
}
pub fn apply(
entries: &[&crate::generated::types::EffectClassEntry],
event: &Event,
cls: &mut Classification,
) -> bool {
let mut matched = false;
for entry in entries {
if !entry_classifies(entry, event, cls) {
continue;
}
matched = true;
for tuple in &entry.effects {
let verb = tuple
.verb
.as_ref()
.map(|v| v.0.as_str())
.unwrap_or("unknown");
let target = tuple
.target_class
.as_ref()
.map(|t| t.0.as_str())
.unwrap_or("shell");
super::add_effect(cls, verb, target, attrs_of(&[]));
}
}
matched
}
fn entry_classifies(entry: &EffectClassEntry, event: &Event, cls: &Classification) -> bool {
entry
.selectors
.iter()
.all(|leaf| selector_matches(leaf, event, cls))
}
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::*;
fn feed(json: serde_json::Value) -> PolicyBundleEffectClasses {
serde_json::from_value(json).expect("the fixture feed parses")
}
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 feed = feed(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 mut cls = Classification::default();
let entries = entries_for(Some(&feed), &event.tool_name);
assert!(apply(&entries, &event, &mut cls));
assert_eq!(cls.effects.len(), 1);
}
#[test]
fn a_selector_miss_is_silence_and_never_a_wholesale_erasure() {
let feed = feed(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 mut cls = Classification::default();
let entries = entries_for(Some(&feed), &event.tool_name);
assert!(
!apply(&entries, &event, &mut cls),
"the entry does not claim the tool, so the caller still falls back"
);
assert!(cls.effects.is_empty());
}
#[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));
}
}