openlatch-client 0.6.5

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 v2 `effect_classes` feed, read as organization tool entries — plan 02 §2f,
//! PRD §Bundle schema 2, AZ-4 D-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.
//!
//! The lookup itself is the catalogue's now: [`super::catalogue::Catalogue::from_bundle`]
//! turns each feed entry into a tool entry that keeps its `selectors` and claims
//! the tool. What stays here is what only a feed entry has — its selectors, the
//! closed D-27 vocabulary they are validated against at load, and the test that
//! decides whether an entry claims one call.
//!
//! # 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 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::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;

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

/// Whether this tool 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), which is every v3
/// entry.
///
/// 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.
pub fn claims(entry: &ToolEntry, 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.
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("");
    // 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::*;

    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);
    }

    // ── 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));
    }
}