openlatch-client 0.5.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::{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;

/// The closed feed-selector field vocabulary (D-27) — identical in the crate,
/// the oracle (`tools/zone-eval-ref/effect.py::FEED_SELECTOR_FIELDS`) and the
/// platform's authoring surface. `input.<rfc6901 pointer>` is the one
/// open-ended member and is handled separately, by prefix.
const FEED_SELECTOR_FIELDS: &[&str] = &[
    "tool.name",
    "input.strings",
    "path.class",
    "path.value",
    "url.host",
    "url.tld",
    "url.scheme",
    "url.boundary",
];

/// The closed feed-selector predicate vocabulary (D-27), matching
/// `tools/zone-eval-ref/effect.py::FEED_SELECTOR_PREDICATES` exactly.
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.")
}

/// Validate one selector leaf against the closed D-27 vocabulary, at bundle
/// load time (`bundle::load_with_client_version`), never per event. `Err`
/// carries the stage-2 skip reason for the leaf's whole entry — a selector
/// naming a field or predicate outside this vocabulary drops the entry into
/// `skipped[]` rather than being left to silently never match.
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(())
}

/// 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,
) -> 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
}

/// Whether this feed entry claims the tool call — today exactly "every one of
/// the entry's selectors matches"; an empty `selectors[]` is vacuously true
/// (the known-benign authoring escape hatch, D-28 rule 2).
///
/// Expressed as one predicate so the deferred tool-definition-hash guard
/// (D-28) lands later as one added conjunct (`&& hash_ok(entry, event)`)
/// rather than a rewrite — every miss then falls into the Z19 branch
/// automatically.
fn entry_classifies(entry: &EffectClassEntry, event: &Event, cls: &Classification) -> bool {
    entry
        .selectors
        .iter()
        .all(|leaf| selector_matches(leaf, event, cls))
}

/// Evaluate one feed selector — through Tier 1's own predicate machinery
/// (`tier1::field_values` + `tier1::test_predicate`), not a second, hand-rolled
/// reading of `leaf.pattern` / `leaf.value`. That second copy is what caused
/// the crate's feed predicates to disagree with the oracle on `keyword`,
/// `prefix`, `glob`, `equals` and `in_set`: `tier1::test_predicate` already
/// agrees with the oracle on all five, and now this is the only caller left
/// that did not go through it.
///
/// # Why this is not `tier1::evaluate_node`
///
/// A selector is an ordinary Tier 1 attribute leaf, and the field/predicate
/// primitives below are exactly what `evaluate_node` composes — but the tree
/// evaluator around them 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 this calls the two-valued primitives directly rather than
/// `evaluate_node`/`evaluate_leaf`.
///
/// The vocabulary here is still deliberately **narrow and closed** (D-27):
/// `tool.name`, `input.strings`, `input.<rfc6901 json pointer>`, `path.class`,
/// `path.value`, `url.host`, `url.tld`, `url.scheme` and `url.boundary`, under
/// `exists`, `equals`, `in_set`, `keyword`, `prefix` and `glob` — Tier 1 reads
/// a wider vocabulary (`regex_lite`, `tld_in`, `int_cmp`, `effect`, `fact`,
/// `agent.*`, `session.*`, `command.*`…), and none of it reaches a feed
/// selector: `validate_selector` rejects anything outside the closed set at
/// load, into `skipped[]`, before a selector ever reaches here.
///
/// No scan table of its own: a feed selector's patterns are not scanned in
/// bulk by [`super::super::tier1::ScanTable`] (that table is built from the
/// bundle's predicate trees, never from feed entries), so this reads with
/// `ScanTable::default()` — the same empty-table fallback path
/// `test_predicate` already documents for a tree evaluated against a foreign
/// table, not a second lookup miss.
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("");
    // The oracle's `tool.name exists` reads `event.get("tool_name")` and is
    // only absent when the KEY itself is missing, never merely because the
    // string is `""` — but `tier1::field_values` treats an empty `tool_name`
    // as absent (`Event` carries it as a plain `String`, so there is no wire
    // distinction to preserve, and that reading is shared with every ordinary
    // Tier 1 predicate tree, not just a feed selector). Overriding it HERE,
    // for the feed only, fixes the feed-selector divergence without changing
    // what an authored policy's `tool.name` leaves mean everywhere else.
    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());
    }

    // ── E1: the four predicates that read `leaf.pattern` instead of
    // `leaf.value` (or the wrong shape of either) until this routed through
    // `tier1::test_predicate` — the review's own reproduction, at the
    // `selector_matches` unit level rather than the full binary. ──

    #[test]
    fn prefix_reads_value_as_a_list_the_review_repro() {
        // The exact case from the review: a `prefix` selector on
        // `input.strings` naming `value`, not `pattern`. Pre-fix this read
        // `leaf.pattern` (absent) and never matched.
        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));
    }

    // ── The two LOW findings of the same family (review finding 5) ──

    #[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() {
        // `input./Count` resolves to the number `33`; `keyword "3"` must NOT
        // match by way of `33.to_string().contains("3")`.
        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() {
        // `equals "{\"a\":1}"` must NOT match the object `{"a": 1}` by way of
        // `Value::to_string()`.
        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));
    }
}