openlatch-client 0.6.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 two shapes a hook entry comes in, read in one place.
//!
//! Every shared helper used to assume the **nested** entry Claude Code and
//! Codex CLI write — `{matcher, _openlatch, hooks: [{type, command, timeout}]}`.
//! Cursor's entry is **flat** — `{type, command, timeout}` — and carries no
//! marker at all ([`crate::hooks::binding::MarkerPlacement::Sidecar`]). A helper
//! that reads `hooks[].command` alone sees nothing in a Cursor file, so shared
//! code reads commands through [`entry_commands`] and never re-derives the
//! walk.

use serde_json::Value;

/// Every `command` an entry runs, whatever its shape:
///
/// - nested (Claude Code, Codex CLI): `{ "hooks": [ { "command": "…" }, … ] }`
/// - flat (Cursor): `{ "command": "…" }`
///
/// A nested entry is read as nested even when it also carries a top-level
/// `command`: the handlers are what the agent runs.
pub fn entry_commands(entry: &Value) -> Vec<&str> {
    if let Some(hooks) = entry.get("hooks").and_then(Value::as_array) {
        return hooks
            .iter()
            .filter_map(|h| h.get("command").and_then(Value::as_str))
            .collect();
    }
    entry
        .get("command")
        .and_then(Value::as_str)
        .into_iter()
        .collect()
}

/// The handler an entry runs first, whatever its shape: `hooks[0]` for a
/// nested entry, the entry itself for a flat one (Cursor's entry IS its
/// handler). `None` for a nested entry with no handler, or an entry that is
/// neither shape.
///
/// Read nested-first, as [`entry_commands`] is.
pub fn primary_handler(entry: &Value) -> Option<&Value> {
    if let Some(hooks) = entry.get("hooks").and_then(Value::as_array) {
        return hooks.first();
    }
    entry.get("command").is_some().then_some(entry)
}

/// Does `cmd` run OpenLatch's hook binary? The orphan test — broad on purpose,
/// for an in-entry-marker file where a pre-marker leftover must still be
/// claimed.
pub fn is_openlatch_command(cmd: &str) -> bool {
    cmd.contains("openlatch-hook")
}

/// Is `cmd` the exact command OpenLatch writes for `agent`?
///
/// Sidecar ownership is **narrower** than orphan detection. With no in-file
/// marker, a developer's own wrapper that merely mentions `openlatch-hook` must
/// never be claimed — replaced on install, removed on uninstall, healed by the
/// reconciler. Ours is our exact signature: the hook binary **and** the
/// `--agent <agent> ` argument the binding renders.
pub fn is_sidecar_owned(cmd: &str, agent: &str) -> bool {
    is_openlatch_command(cmd) && cmd.contains(&format!("--agent {agent} "))
}

/// [`is_sidecar_owned`] for a flat entry: its `command`, and nothing else.
///
/// A nested entry is never a sidecar entry, whatever its handlers say — the
/// flat shape is part of the signature.
pub fn is_sidecar_owned_entry(entry: &Value, agent: &str) -> bool {
    entry.get("hooks").is_none()
        && entry_commands(entry)
            .first()
            .is_some_and(|cmd| is_sidecar_owned(cmd, agent))
}

/// The CST twin of [`is_sidecar_owned_entry`], for `jsonc`'s surgery, which
/// holds `CstNode`s rather than values.
pub fn is_sidecar_owned_node(node: &jsonc_parser::cst::CstNode, agent: &str) -> bool {
    node.to_serde_value()
        .is_some_and(|value| is_sidecar_owned_entry(&value, agent))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn entry_commands_reads_nested_and_flat() {
        let nested = json!({
            "matcher": "",
            "_openlatch": {"v": 1},
            "hooks": [
                {"type": "command", "command": "a"},
                {"type": "command", "command": "b"},
                {"type": "command"}
            ]
        });
        assert_eq!(entry_commands(&nested), ["a", "b"]);

        let flat = json!({"type": "command", "command": "c", "timeout": 10});
        assert_eq!(entry_commands(&flat), ["c"]);

        assert!(entry_commands(&json!({"type": "command"})).is_empty());
        assert!(entry_commands(&json!("not an object")).is_empty());
    }

    #[test]
    fn primary_handler_is_first_nested_or_the_flat_entry() {
        let nested = json!({
            "matcher": "",
            "hooks": [{"command": "a"}, {"command": "b"}]
        });
        assert_eq!(primary_handler(&nested), Some(&json!({"command": "a"})));

        let flat = json!({"type": "command", "command": "c"});
        assert_eq!(primary_handler(&flat), Some(&flat));

        // Nested wins over a stray top-level command, as in `entry_commands`.
        let both = json!({"command": "x", "hooks": [{"command": "a"}]});
        assert_eq!(primary_handler(&both), Some(&json!({"command": "a"})));

        assert_eq!(primary_handler(&json!({"hooks": []})), None);
        assert_eq!(primary_handler(&json!({"type": "command"})), None);
        assert_eq!(primary_handler(&json!("not an object")), None);
    }

    #[test]
    fn sidecar_ownership_is_our_exact_signature() {
        let ours = r#""/x/openlatch-hook" --agent cursor --event stop --openlatch-dir "/o""#;
        assert!(is_sidecar_owned(ours, "cursor"));
        assert!(
            !is_sidecar_owned(ours, "codex-cli"),
            "another agent's is not ours"
        );

        // A developer's wrapper that mentions the binary is not ours.
        let wrapper = "my-wrapper --then openlatch-hook";
        assert!(is_openlatch_command(wrapper));
        assert!(!is_sidecar_owned(wrapper, "cursor"));

        // The flat shape is part of the signature.
        assert!(is_sidecar_owned_entry(&json!({"command": ours}), "cursor"));
        assert!(!is_sidecar_owned_entry(
            &json!({"hooks": [{"command": ours}]}),
            "cursor"
        ));
    }
}