openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! One answer to "what does a healthy hook install look like?".
//!
//! The question used to be re-derived in three places with three different
//! answers, and #165 is what that divergence costs:
//!
//! - `doctor`'s Check 5 asked "does the file contain these substrings?"
//! - `doctor --fix`'s `heal_hooks` asked the same substring question, so an
//!   entry that *existed* but pointed at a binary that did not was considered
//!   healthy and `--fix` left all 12 commands untouched.
//! - `doctor`'s `check_hook_binding` asked the real question — does the command
//!   resolve to a file that exists? — and reported `ERR Hook binary missing` ×12
//!   while the repair path right next to it declared everything fine.
//!
//! This module owns the predicate. Everything that needs to know whether an
//! install is healthy — diagnose, repair, or report — reads the same struct,
//! so a diagnostic and its own fix can no longer disagree.

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

use crate::error::OlError;

/// Hook events an install cannot function without. A settings.json missing any
/// of these is broken regardless of what the other entries look like.
pub const LOAD_BEARING_EVENTS: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];

/// The state of the OpenLatch hook entries in an agent's settings file.
#[derive(Debug, Clone, Default)]
pub struct HookHealth {
    /// What the hook command should point at, per `resolve_hook_binary_path()`.
    pub expected_bin: PathBuf,
    /// Load-bearing events with no OpenLatch-owned entry at all.
    pub missing_events: Vec<String>,
    /// Binary paths referenced by a hook command that do not exist on disk.
    /// This is the #165 outage: 12 entries present, every one of them dead.
    pub missing_bin: Vec<String>,
    /// Binary paths that exist but are not the current resolution — a stale
    /// link after `cargo install` or a tarball upgrade.
    pub drifted_bin: Vec<String>,
    /// How many OpenLatch-owned hook commands were found.
    pub commands: usize,
}

impl HookHealth {
    /// Whether every load-bearing event is present and every command resolves
    /// to the binary this install actually ships.
    pub fn is_healthy(&self) -> bool {
        self.commands > 0
            && self.missing_events.is_empty()
            && self.missing_bin.is_empty()
            && self.drifted_bin.is_empty()
    }

    /// Whether a reinstall would fix what is wrong.
    ///
    /// Deliberately the negation of [`is_healthy`]: a dangling or stale command
    /// is repaired by rewriting it, exactly like a missing one. Treating only
    /// absence as repairable is what left `doctor --fix` unable to fix the
    /// condition `doctor` had just diagnosed.
    ///
    /// [`is_healthy`]: HookHealth::is_healthy
    pub fn needs_reinstall(&self) -> bool {
        !self.is_healthy()
    }
}

/// Inspect a parsed settings.json.
pub fn inspect(settings: &serde_json::Value) -> HookHealth {
    let expected_bin = super::resolve_hook_binary_path();
    let mut health = HookHealth {
        expected_bin: expected_bin.clone(),
        ..Default::default()
    };

    let events = openlatch_hook_events(settings);
    for required in LOAD_BEARING_EVENTS {
        if !events.iter().any(|(event, _)| event == required) {
            health.missing_events.push(required.to_string());
        }
    }

    for (_, command) in &events {
        health.commands += 1;
        let Some(bin) = extract_quoted_binary(command) else {
            continue;
        };
        let bin_path = PathBuf::from(&bin);
        if !bin_path.exists() {
            health.missing_bin.push(bin);
        } else if bin_path != expected_bin {
            health.drifted_bin.push(bin);
        }
    }
    health.missing_bin.sort();
    health.missing_bin.dedup();
    health.drifted_bin.sort();
    health.drifted_bin.dedup();

    health
}

/// Inspect a settings file on disk.
///
/// A file that cannot be read or parsed is not reported as "healthy but
/// unreadable" — the caller gets the error and decides. `heal_hooks` treats an
/// absent file as "reinstall", since `install_hooks` creates it.
///
/// # Errors
///
/// Returns the I/O error as `OL-1401`, or the parse error from
/// [`jsonc::parse_settings_value`] unchanged.
///
/// [`jsonc::parse_settings_value`]: super::jsonc::parse_settings_value
pub fn inspect_file(settings_path: &Path) -> Result<HookHealth, OlError> {
    let raw = std::fs::read_to_string(settings_path).map_err(|e| {
        OlError::new(
            crate::error::ERR_HOOK_WRITE_FAILED,
            format!("Cannot read '{}': {e}", settings_path.display()),
        )
    })?;
    let parsed = super::jsonc::parse_settings_value(&raw)?;
    Ok(inspect(&parsed))
}

/// Every `(event, command)` pair owned by OpenLatch, identified by the
/// `_openlatch` marker rather than by scanning for substrings.
pub fn openlatch_hook_events(settings: &serde_json::Value) -> Vec<(String, String)> {
    let Some(hooks) = settings.get("hooks").and_then(|v| v.as_object()) else {
        return Vec::new();
    };
    let mut out: Vec<(String, String)> = Vec::new();
    for (event, entries) in hooks {
        let Some(entries) = entries.as_array() else {
            continue;
        };
        for entry in entries {
            if !matches!(
                entry.get("_openlatch"),
                Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::Object(_))
            ) {
                continue;
            }
            let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
                continue;
            };
            for h in inner {
                if let Some(cmd) = h.get("command").and_then(|v| v.as_str()) {
                    out.push((event.clone(), cmd.to_string()));
                }
            }
        }
    }
    out
}

/// The hook command we write always quotes the binary path as its first token
/// (so spaces in Windows paths survive); everything between the first pair of
/// double quotes is the path.
pub fn extract_quoted_binary(command: &str) -> Option<String> {
    let start = command.find('"')? + 1;
    let end = command[start..].find('"')? + start;
    if start == end {
        return None;
    }
    Some(command[start..end].to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn settings_with(command: &str) -> serde_json::Value {
        let entry = || {
            serde_json::json!({
                "matcher": "*",
                "_openlatch": { "entry_id": "test" },
                "hooks": [{ "type": "command", "command": command }]
            })
        };
        serde_json::json!({
            "hooks": {
                "PreToolUse": [entry()],
                "UserPromptSubmit": [entry()],
                "Stop": [entry()],
            }
        })
    }

    /// The exact #165 condition: all 12 entries present, every command pointing
    /// at a binary that does not exist. The old substring predicate called this
    /// healthy; it must not.
    #[test]
    fn entries_present_but_binary_missing_is_not_healthy() {
        let settings = settings_with("\"/nonexistent/openlatch-hook\" --event PreToolUse");
        let health = inspect(&settings);

        assert!(health.missing_events.is_empty(), "entries ARE present");
        assert_eq!(health.commands, 3);
        assert_eq!(health.missing_bin, vec!["/nonexistent/openlatch-hook"]);
        assert!(!health.is_healthy());
        assert!(health.needs_reinstall());
    }

    /// The bare-name last resort `init` used to write. It is not a path that
    /// exists, so it must classify as missing rather than silently pass.
    #[test]
    fn bare_command_name_is_missing() {
        let settings = settings_with("\"openlatch-hook\" --event PreToolUse");
        let health = inspect(&settings);
        assert_eq!(health.missing_bin, vec!["openlatch-hook"]);
        assert!(health.needs_reinstall());
    }

    /// A command pointing at a real file that is not the current resolution is
    /// drift, not absence — same repair, different diagnosis.
    #[test]
    fn existing_but_unexpected_binary_is_drift() {
        let tmp = TempDir::new().unwrap();
        let stale = tmp.path().join("openlatch-hook");
        std::fs::write(&stale, b"x").unwrap();

        let health = inspect(&settings_with(&format!(
            "\"{}\" --event x",
            stale.display()
        )));

        assert!(health.missing_bin.is_empty());
        assert_eq!(health.drifted_bin.len(), 1);
        assert!(health.needs_reinstall());
    }

    #[test]
    fn absent_load_bearing_event_is_reported() {
        let settings = serde_json::json!({
            "hooks": {
                "PreToolUse": [{
                    "matcher": "*",
                    "_openlatch": true,
                    "hooks": [{ "type": "command", "command": "\"openlatch-hook\"" }]
                }]
            }
        });
        let health = inspect(&settings);
        assert_eq!(health.missing_events, vec!["UserPromptSubmit", "Stop"]);
        assert!(health.needs_reinstall());
    }

    /// Entries a user wrote themselves are none of our business — only
    /// `_openlatch`-marked ones are inspected or rewritten.
    #[test]
    fn foreign_entries_are_ignored() {
        let settings = serde_json::json!({
            "hooks": {
                "PreToolUse": [{
                    "matcher": "*",
                    "hooks": [{ "type": "command", "command": "\"/usr/bin/their-hook\"" }]
                }]
            }
        });
        let health = inspect(&settings);
        assert_eq!(health.commands, 0);
        assert!(health.missing_bin.is_empty());
        assert!(health.needs_reinstall(), "no OpenLatch entries at all");
    }

    #[test]
    fn extract_quoted_binary_handles_spaces_and_rejects_empties() {
        assert_eq!(
            extract_quoted_binary("\"C:\\Program Files\\openlatch-hook.exe\" --event Stop"),
            Some("C:\\Program Files\\openlatch-hook.exe".to_string())
        );
        assert_eq!(extract_quoted_binary("\"\" --event Stop"), None);
        assert_eq!(extract_quoted_binary("openlatch-hook --event Stop"), None);
    }
}