openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! The `effect_classes` feed lookup — plan 02 §2f, PRD §Bundle schema 2.
//!
//! The feed is keyed by `tool_key` and covers MCP tools and built-ins alike; the
//! MCP table includes the storage-delete vocabulary from canon's worked example
//! (`mcp__s3__delete_object`, `mcp__s3__delete_objects`, and S3 lifecycle-rule
//! creation, all `delete × data_store`), each row pinned by one fixture.
//!
//! # A feed entry is a per-MATCHED-SELECTOR override
//!
//! Not a wholesale replacement of the built-in table, and not a per-tool one
//! (PRD, amended 2026-09-03). An entry whose `tool_key` matches the tool but
//! whose `selectors[]` do not match THIS event is **silence for this event**, and
//! silence falls back to the built-in row, the shell classifier and the twelve
//! shapes.
//!
//! The other reading is worth naming because its consequence is not obvious:
//! under it an entry that claims a tool and then fails to match erases every
//! effect tuple **and every unknown shape** for that tool, the action matches no
//! rule, and it is silently allowed. That is verbatim the D-15 regression the
//! spike exists to prevent — and it would also make a fixture bundle carrying
//! only minimal feed entries pass an entire conformance corpus vacuously.
//! `02-feed-selector-miss.json` is the fixture that holds the line.

use crate::generated::types::{PolicyBundleEffectClasses, T1Leaf};

use super::super::facts::FactSet;
use super::super::types::{Classification, Event};
use super::attrs_of;

/// The entries whose `tool_key` names this tool, in feed order.
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()
}

/// Apply this tool's feed entries. `true` when at least one entry matched.
///
/// The return value is what makes the override per matched selector: an entry
/// whose selectors all miss contributes nothing and does not claim the tool, so
/// the caller falls through to the built-in row or the shell classifier.
pub fn apply(
    entries: &[&crate::generated::types::EffectClassEntry],
    event: &Event,
    cls: &mut Classification,
    facts: &FactSet,
) -> bool {
    let mut matched = false;
    for entry in entries {
        if !entry
            .selectors
            .iter()
            .all(|leaf| selector_matches(leaf, event, cls, facts))
        {
            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
}

/// Evaluate one feed selector.
///
/// # Why this is not `tier1::evaluate_node`
///
/// A selector is an ordinary Tier 1 attribute leaf, and one day it should be
/// read by the one leaf evaluator. It cannot be today, for a reason that is
/// semantic rather than scheduling: Tier 1 answers in **Kleene**, where an
/// unreadable leaf is ⊥ and routes to an atom's `on_inconclusive`. A feed
/// selector has no `on_inconclusive` — it either narrows this event or it does
/// not — and it is evaluated against a context whose effect tuples are still
/// empty, because a selector reading `effect.verb` would be asking the
/// classifier about its own output.
///
/// So the vocabulary here is deliberately **narrow and closed**: `tool.name`,
/// `input.<json pointer>`, `input.strings`, `path.class` and `path.value`, under
/// `exists`, `equals`, `in_set`, `keyword` and `glob`. Anything else does not
/// match, which leaves the entry silent and the built-in classification standing
/// — the safe direction, and the one D-15 asks for.
fn selector_matches(leaf: &T1Leaf, event: &Event, cls: &Classification, _facts: &FactSet) -> bool {
    let Some(field) = leaf.field.as_deref() else {
        return false;
    };
    let pred = leaf.pred.as_deref().unwrap_or("");
    let values = field_values(field, event, cls);
    match pred {
        "exists" => !values.is_empty(),
        "equals" => leaf
            .value
            .as_ref()
            .and_then(|v| v.as_str())
            .is_some_and(|want| values.iter().any(|got| got == want)),
        "in_set" => leaf
            .value
            .as_ref()
            .and_then(|v| v.as_array())
            .is_some_and(|wanted| {
                wanted
                    .iter()
                    .filter_map(|w| w.as_str())
                    .any(|want| values.iter().any(|got| got == want))
            }),
        "keyword" => leaf
            .pattern
            .as_deref()
            .is_some_and(|needle| values.iter().any(|got| got.contains(needle))),
        "prefix" => leaf
            .pattern
            .as_deref()
            .is_some_and(|needle| values.iter().any(|got| got.starts_with(needle))),
        "glob" => leaf.pattern.as_deref().is_some_and(|pattern| {
            values
                .iter()
                .any(|got| super::path::glob_match(pattern, got))
        }),
        _ => false,
    }
}

/// The strings one selector field reads. Empty means "the field is absent",
/// which is what `exists` tests.
fn field_values(field: &str, event: &Event, cls: &Classification) -> Vec<String> {
    if field == "tool.name" {
        return if event.tool_name.is_empty() {
            Vec::new()
        } else {
            vec![event.tool_name.clone()]
        };
    }
    if field == "input.strings" {
        let mut out = Vec::new();
        collect_strings(&event.tool_input, &mut out);
        return out;
    }
    if let Some(pointer) = field.strip_prefix("input.") {
        return json_pointer(&event.tool_input, pointer)
            .map(|value| match value {
                serde_json::Value::String(text) => vec![text.clone()],
                serde_json::Value::Null => Vec::new(),
                other => vec![other.to_string()],
            })
            .unwrap_or_default();
    }
    if field == "path.class" {
        return cls.paths.iter().map(|p| p.class.0.clone()).collect();
    }
    if field == "path.value" {
        return cls.paths.iter().map(|p| p.value.clone()).collect();
    }
    Vec::new()
}

/// RFC 6901, over the tool input. `input./Bucket` carries the pointer `/Bucket`.
fn json_pointer<'a>(
    document: &'a serde_json::Value,
    pointer: &str,
) -> Option<&'a serde_json::Value> {
    if pointer.is_empty() || pointer == "/" {
        return Some(document);
    }
    // `serde_json` owns the `~0` / `~1` unescaping; re-doing it here would be a
    // second, divergent implementation of RFC 6901.
    document.pointer(pointer)
}

fn collect_strings(value: &serde_json::Value, out: &mut Vec<String>) {
    match value {
        serde_json::Value::String(text) => out.push(text.clone()),
        serde_json::Value::Array(items) => items.iter().for_each(|i| collect_strings(i, out)),
        serde_json::Value::Object(map) => map.values().for_each(|v| collect_strings(v, out)),
        _ => {}
    }
}

#[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, &FactSet::default()));
        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, &FactSet::default()),
            "the entry does not claim the tool, so the caller still falls back"
        );
        assert!(cls.effects.is_empty());
    }
}