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
//! Cursor's binding — the IDE and the CLI (`cursor-agent`), which read one
//! user-level `~/.cursor/hooks.json`.
//!
//! Cursor's entry is **flat** (`{type, command, timeout}`) and its file root
//! carries `"version": 1`. Nothing else goes into Cursor's file: no `matcher`
//! (its regex flavour is undocumented) and no `_openlatch` marker (Cursor does
//! not document tolerating an unknown key, and a rejected one would stop the
//! whole file loading, the developer's own hooks included). Ownership is
//! therefore a [`MarkerPlacement::Sidecar`].

use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::core::hook_state::marker::OpenlatchMarker;

use super::super::binding::{
    AgentBinding, BindingCapabilities, DaemonChannel, FailureMode, LivenessReport, MarkerPlacement,
    ModelRelayWiring,
};

/// The events this build installs: **record-only**.
///
/// Cursor's four deciding events are [`DECIDING`], and none of them is here.
/// `hook_output::translate` has no `cursor` arm yet and ends in `_ => empty()`,
/// so a deciding hook installed before that arm exists prints `{}` for every
/// verdict — an allow. The deciding events join this list in the same change
/// as the translator, prepended.
const EVENT_TYPES: &[&str] = &[
    "sessionStart",
    "sessionEnd",
    "postToolUse",
    "postToolUseFailure",
    "subagentStop",
    "preCompact",
    "stop",
];

/// Cursor's permission events — the ones whose answer can refuse the action.
///
/// Declared ahead of their installation so the timeout below already knows
/// them and the change that installs them edits [`EVENT_TYPES`] alone.
const DECIDING: &[&str] = &[
    "preToolUse",
    "beforeShellExecution",
    "beforeMCPExecution",
    "beforeSubmitPrompt",
];

/// Cursor's configuration root, and the one file its hooks are registered in.
pub struct CursorBinding {
    /// `$OPENLATCH_CURSOR_DIR`, else `~/.cursor` — resolved by `hooks::cursor`.
    pub root: PathBuf,
    /// `<root>/hooks.json`.
    pub hooks_path: PathBuf,
}

impl CursorBinding {
    /// Delegates to `hooks::cursor`, which owns resolution and detection for
    /// every caller.
    pub fn detect() -> Option<Self> {
        let root = crate::hooks::cursor::detect()?;
        Some(Self {
            hooks_path: crate::hooks::cursor::hooks_json_path(&root),
            root,
        })
    }
}

/// Cursor's camelCase event name → the OpenLatch wire event.
///
/// Its own table, never `pascal_to_snake`: that one answers `"unknown"` for a
/// camelCase name, and the install would write `--event unknown` without a
/// word. `"unknown"` here is a bug too, which is why a test walks every entry
/// of [`EVENT_TYPES`] and [`DECIDING`] against the known wire values.
fn cursor_wire_event(event: &str) -> &'static str {
    match event {
        "preToolUse" | "beforeShellExecution" | "beforeMCPExecution" => "pre_tool_use",
        "beforeSubmitPrompt" => "user_prompt_submit",
        "sessionStart" => "session_start",
        "sessionEnd" => "session_end",
        "postToolUse" => "post_tool_use",
        "postToolUseFailure" => "post_tool_use_failure",
        "subagentStop" => "subagent_stop",
        "preCompact" => "pre_compact",
        "stop" => "stop",
        _ => "unknown",
    }
}

impl AgentBinding for CursorBinding {
    fn agent_type(&self) -> &'static str {
        "cursor"
    }

    fn display_name(&self) -> &'static str {
        "Cursor"
    }

    fn config_dir(&self) -> PathBuf {
        self.root.clone()
    }

    fn hook_config_path(&self) -> PathBuf {
        self.hooks_path.clone()
    }

    fn hook_event_types(&self) -> &'static [&'static str] {
        EVENT_TYPES
    }

    fn load_bearing_events(&self) -> &'static [&'static str] {
        // Record-only until the deciding events land, which widen this.
        &["sessionStart"]
    }

    fn daemon_channel(&self) -> DaemonChannel {
        // Cursor's entry has no environment block to pin a token in, and an
        // undocumented key there is exactly what this binding refuses to write.
        // The hook is told where to look on its own command line, as Codex's is.
        DaemonChannel::OpenlatchDirArg
    }

    fn liveness(&self) -> LivenessReport {
        // Cursor has no trust gate: installed is armed. Proving it is live is
        // evidence-based and lands with the doctor attestation.
        LivenessReport {
            armed: None,
            detail: None,
            remedy: None,
            code: None,
            off: false,
            pending: false,
        }
    }

    fn build_hook_entry(
        &self,
        event: &str,
        binary: &Path,
        _port: u16,
        _marker: &OpenlatchMarker,
    ) -> Value {
        let wire = cursor_wire_event(event);
        let openlatch_dir = crate::config::openlatch_dir();
        // Both paths quoted, on every OS: Windows paths carry spaces, and the
        // documented Windows wrapper is `& "<command path>" <args>`.
        let command = format!(
            r#""{}" --agent cursor --event {wire} --openlatch-dir "{}""#,
            binary.display(),
            openlatch_dir.display()
        );
        // Seconds, and always explicit: Cursor's default is unpublished.
        let timeout = if DECIDING.contains(&event) { 900 } else { 10 };
        // The documented keys and nothing else — no marker, no matcher.
        json!({ "type": "command", "command": command, "timeout": timeout })
    }

    fn config_is_machine_global(&self) -> bool {
        crate::hooks::cursor::config_is_machine_global()
    }

    fn capabilities(&self) -> BindingCapabilities {
        BindingCapabilities {
            // Cursor's permission hooks take `allow` / `deny`; `ask` is not
            // honoured on every surface, so it is not declared.
            expressible: &["allow", "deny"],
            can_mutate_arguments: false,
            native_failure_mode: FailureMode::FailOpen,
            admin_owned_settings: false,
            declares_session_in_request: true,
        }
    }

    fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
        // Cursor's lane is decided (intercept) and ships with the relay
        // initiative (I-2); see `request_plane_pending`.
        None
    }

    fn request_plane_pending(&self) -> bool {
        // I-2 sets `model_relay_wiring()` and deletes this override.
        true
    }

    fn marker_placement(&self) -> MarkerPlacement {
        MarkerPlacement::Sidecar
    }

    fn file_root_defaults(&self) -> &'static [(&'static str, i64)] {
        // Cursor requires it, and a created file must carry it.
        &[("version", 1)]
    }

    fn byte_identical_restore(&self) -> bool {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::envelope::known_types::KNOWN_HOOK_EVENT_TYPES;

    fn binding() -> CursorBinding {
        CursorBinding {
            root: PathBuf::from("/home/test/.cursor"),
            hooks_path: PathBuf::from("/home/test/.cursor/hooks.json"),
        }
    }

    #[test]
    fn every_event_maps_to_a_known_wire_value() {
        for event in EVENT_TYPES.iter().chain(DECIDING) {
            let wire = cursor_wire_event(event);
            assert_ne!(wire, "unknown", "{event} has no wire value");
            assert!(
                KNOWN_HOOK_EVENT_TYPES.contains(&wire),
                "{event} maps to {wire}, which is not a known hook event type"
            );
        }
    }

    /// Plan 01 installs the record-only events and no deciding one.
    #[test]
    fn no_deciding_event_is_installed_yet() {
        for event in DECIDING {
            assert!(
                !EVENT_TYPES.contains(event),
                "{event} must not be installed before the cursor translator exists"
            );
        }
        assert!(binding()
            .load_bearing_events()
            .iter()
            .all(|e| EVENT_TYPES.contains(e)));
    }

    #[test]
    fn entry_is_flat_with_only_documented_keys() {
        let marker = OpenlatchMarker::new("id".into());
        for event in EVENT_TYPES.iter().chain(DECIDING) {
            let entry =
                binding().build_hook_entry(event, Path::new("/bin/openlatch-hook"), 7443, &marker);
            let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect();
            assert!(
                keys.iter()
                    .all(|k| ["type", "command", "timeout"].contains(&k.as_str())),
                "{event}: undocumented key in {entry}"
            );
            assert_eq!(entry["type"], "command");
            let expected = if DECIDING.contains(event) { 900 } else { 10 };
            assert_eq!(entry["timeout"], expected, "{event}");
        }
    }

    #[test]
    fn command_quotes_both_paths() {
        // The command and the assertion both read `OPENLATCH_DIR`; a sibling
        // redirecting it between the two would fail this for the wrong reason.
        let _dir_lock = crate::config::OPENLATCH_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let marker = OpenlatchMarker::new("id".into());
        let bin = Path::new("/Applications/Open Latch/openlatch-hook");
        let entry = binding().build_hook_entry("stop", bin, 7443, &marker);
        let command = entry["command"].as_str().expect("a command");
        let dir = crate::config::openlatch_dir();
        assert_eq!(
            command,
            format!(
                r#""{}" --agent cursor --event stop --openlatch-dir "{}""#,
                bin.display(),
                dir.display()
            )
        );
        assert!(crate::hooks::entry_shape::is_sidecar_owned(
            command, "cursor"
        ));
    }

    #[test]
    fn cursor_declares_the_sidecar_and_byte_identical_restore() {
        let b = binding();
        assert_eq!(b.marker_placement(), MarkerPlacement::Sidecar);
        assert_eq!(b.file_root_defaults(), &[("version", 1)]);
        assert!(b.byte_identical_restore());
        assert!(matches!(b.daemon_channel(), DaemonChannel::OpenlatchDirArg));
    }
}