openlatch-client 0.1.14

The open-source security layer for AI agents — client forwarder
/// Agent hook detection and installation.
///
/// Public API:
/// - [`detect_agent`] — detect which (if any) AI agent is installed
/// - [`install_hooks`] — write OpenLatch HTTP hook entries into the agent's config
/// - [`remove_hooks`] — remove all OpenLatch-owned hook entries
///
/// # Module structure
///
/// - `claude_code` — path detection and hook entry building for Claude Code
/// - `jsonc` — JSONC-preserving string surgery on `settings.json`
pub mod atomic;
pub mod binding;
pub mod bindings;
pub mod claude_code;
pub mod jsonc;

use std::path::PathBuf;

use crate::core::hook_state::hmac::compute_entry_hmac;
use crate::core::hook_state::key::HmacKeyStore;
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::core::hook_state::{self, HookStateFile, StateEntry};
use crate::error::{OlError, ERR_HOOK_AGENT_NOT_FOUND};

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

/// A detected AI agent with all paths needed for hook installation.
#[derive(Debug, Clone)]
pub enum DetectedAgent {
    /// Claude Code was found at the given directory.
    ClaudeCode {
        /// Path to the Claude Code config directory (e.g. `~/.claude/`).
        claude_dir: PathBuf,
        /// Path to `settings.json` inside the config directory.
        settings_path: PathBuf,
    },
}

/// The result of a successful [`install_hooks`] call.
#[derive(Debug)]
pub struct HookInstallResult {
    /// Per-hook-event status showing whether the entry was added or replaced.
    pub entries: Vec<HookEntryStatus>,
}

/// Status of a single hook event entry after installation.
#[derive(Debug)]
pub struct HookEntryStatus {
    /// The hook event type (e.g. `"PreToolUse"`, `"UserPromptSubmit"`, `"Stop"`).
    pub event_type: String,
    /// Whether the entry was newly added or replaced an existing OpenLatch entry.
    pub action: HookAction,
}

/// Whether a hook entry was newly created or replaced an existing one.
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
    /// A new hook entry was appended to the array.
    Added,
    /// An existing OpenLatch-owned entry was replaced (idempotent re-install).
    Replaced,
}

/// Resolve the absolute path to the `openlatch-hook` binary that hook
/// configs should invoke.
///
/// Order of precedence:
///
/// 1. `OPENLATCH_HOOK_BIN` env var (override for tests, custom installs).
/// 2. `~/.openlatch/bin/openlatch-hook[.exe]` — the canonical install
///    location populated by `openlatch init` on the first run.
/// 3. `openlatch-hook[.exe]` next to the current running binary (typical
///    during `cargo install` or portable tarball extractions).
/// 4. Bare `"openlatch-hook"` as a last resort — relies on the hook
///    subprocess resolving it via `PATH`.
pub fn resolve_hook_binary_path() -> PathBuf {
    let bin_name = if cfg!(windows) {
        "openlatch-hook.exe"
    } else {
        "openlatch-hook"
    };

    if let Ok(override_path) = std::env::var("OPENLATCH_HOOK_BIN") {
        if !override_path.is_empty() {
            return PathBuf::from(override_path);
        }
    }

    if let Some(home) = dirs::home_dir() {
        let candidate = home.join(".openlatch").join("bin").join(bin_name);
        if candidate.exists() {
            return candidate;
        }
    }

    if let Ok(current_exe) = std::env::current_exe() {
        if let Some(dir) = current_exe.parent() {
            let candidate = dir.join(bin_name);
            if candidate.exists() {
                return candidate;
            }
        }
    }

    PathBuf::from(bin_name)
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Detect which AI agent (if any) is installed on this machine.
///
/// Currently supports Claude Code only. Additional agents will be added in M4.
///
/// # Errors
///
/// Returns `OL-1400` if no supported AI agent is detected.
pub fn detect_agent() -> Result<DetectedAgent, OlError> {
    if let Some(claude_dir) = claude_code::detect() {
        let settings_path = claude_code::settings_json_path(&claude_dir);
        return Ok(DetectedAgent::ClaudeCode {
            claude_dir,
            settings_path,
        });
    }

    Err(
        OlError::new(ERR_HOOK_AGENT_NOT_FOUND, "No AI agents detected")
            .with_suggestion("Install Claude Code (https://claude.ai/download) and try again.")
            .with_docs("https://docs.openlatch.ai/errors/OL-1400"),
    )
}

/// Install OpenLatch HTTP hook entries into the detected agent's config.
///
/// The three hook events written are `PreToolUse`, `UserPromptSubmit`, and `Stop`.
/// Re-running this function is idempotent: existing OpenLatch entries are replaced
/// rather than duplicated. Hooks from other tools are never touched.
///
/// # Arguments
///
/// - `agent`: the agent returned by [`detect_agent`]
/// - `port`: the daemon's listen port (written into each hook URL)
/// - `token`: the bearer token value — the *env var name* `OPENLATCH_TOKEN` is
///   written into settings.json; the actual token is stored separately
///
/// # Errors
///
/// - `OL-1401` if settings.json cannot be read or written.
/// - `OL-1402` if settings.json contains malformed JSONC.
pub fn install_hooks(
    agent: &DetectedAgent,
    port: u16,
    token: &str,
) -> Result<HookInstallResult, OlError> {
    match agent {
        DetectedAgent::ClaudeCode { settings_path, .. } => {
            const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
            const EVENT_TYPES: [&str; 9] = [
                "PreToolUse",
                "PostToolUse",
                "UserPromptSubmit",
                "Notification",
                "Stop",
                "SubagentStop",
                "PreCompact",
                "SessionStart",
                "SessionEnd",
            ];

            let openlatch_dir = crate::config::openlatch_dir();
            let hook_bin = resolve_hook_binary_path();

            let key_store = HmacKeyStore::new(&openlatch_dir);
            let hmac_key = key_store.load_or_create()?;

            let token_fp = crate::core::hook_state::key::key_fingerprint(token.as_bytes());
            let settings_path_hash = hook_state::hash_settings_path(settings_path);

            let mut entries_with_markers: Vec<(String, serde_json::Value, String)> = Vec::new();
            for &et in &EVENT_TYPES {
                let entry_id = uuid::Uuid::now_v7().to_string();
                let mut marker = OpenlatchMarker::new(entry_id.clone());

                let mut entry =
                    claude_code::build_hook_entry(et, port, TOKEN_ENV_VAR, &hook_bin, &marker);

                let hmac_value = compute_entry_hmac(&entry, &hmac_key)?;
                marker = marker.with_hmac(hmac_value.clone());

                let marker_value =
                    serde_json::to_value(&marker).expect("OpenlatchMarker serializes");
                entry["_openlatch"] = marker_value;

                entries_with_markers.push((et.to_string(), entry, entry_id));
            }

            let jsonc_entries: Vec<(String, serde_json::Value)> = entries_with_markers
                .iter()
                .map(|(et, entry, _)| (et.clone(), entry.clone()))
                .collect();

            let token_owned = token.to_string();
            let actions = std::cell::RefCell::new(Vec::new());

            atomic::atomic_rewrite_jsonc(settings_path, |root| {
                let a = jsonc::insert_hook_entries_cst(root, &jsonc_entries)?;
                jsonc::set_env_var_cst(root, TOKEN_ENV_VAR, &token_owned)?;
                *actions.borrow_mut() = a;
                Ok(())
            })?;

            let actions = actions.into_inner();

            let mut state = HookStateFile::load(&openlatch_dir)?
                .unwrap_or_else(|| HookStateFile::new("kid-01".into()));

            for (et, _, entry_id) in &entries_with_markers {
                let hmac_val = entries_with_markers
                    .iter()
                    .find(|(e, _, _)| e == et)
                    .and_then(|(_, entry, _)| {
                        entry["_openlatch"]["hmac"].as_str().map(|s| s.to_string())
                    })
                    .unwrap_or_default();

                state.upsert_entry(StateEntry {
                    id: entry_id.clone(),
                    agent: "claude-code".into(),
                    settings_path_hash: settings_path_hash.clone(),
                    hook_event: et.clone(),
                    expected_entry_hmac: hmac_val,
                    daemon_port_at_install: port,
                    daemon_token_fp: token_fp.clone(),
                    v: 1,
                });
            }

            if let Err(e) = state.save(&openlatch_dir) {
                tracing::warn!(
                    code = crate::error::ERR_STATE_FILE_WRITE_FAILED,
                    error = %e,
                    "failed to write hook state file — hooks installed but state file out of sync"
                );
            }

            let entries = EVENT_TYPES
                .iter()
                .zip(actions)
                .map(|(&et, action)| HookEntryStatus {
                    event_type: et.to_string(),
                    action,
                })
                .collect();

            Ok(HookInstallResult { entries })
        }
    }
}

/// Remove all OpenLatch-owned hook entries from the detected agent's config.
///
/// Only entries carrying `"_openlatch": true` are removed. Hooks from other
/// tools are never touched.
///
/// # Errors
///
/// - `OL-1401` if settings.json cannot be read or written.
/// - `OL-1402` if settings.json contains malformed JSONC.
pub fn remove_hooks(agent: &DetectedAgent) -> Result<(), OlError> {
    match agent {
        DetectedAgent::ClaudeCode { settings_path, .. } => {
            if !settings_path.exists() {
                return Ok(());
            }

            atomic::atomic_rewrite_jsonc(settings_path, |root| {
                jsonc::remove_owned_entries_cst(root)
            })?;

            Ok(())
        }
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    /// Smoke-test detect_agent when ~/.claude/ does NOT exist.
    ///
    /// We override HOME so that dirs::home_dir() points to an empty tempdir.
    #[test]
    #[cfg(unix)]
    fn test_detect_agent_returns_ol_1400_when_no_claude_dir() {
        use super::detect_agent;
        use crate::error::ERR_HOOK_AGENT_NOT_FOUND;

        let dir = tempfile::tempdir().unwrap();
        // Override HOME so ~/.claude/ does not exist.
        std::env::set_var("HOME", dir.path());
        let result = detect_agent();
        std::env::remove_var("HOME");

        let err = result.unwrap_err();
        assert_eq!(
            err.code, ERR_HOOK_AGENT_NOT_FOUND,
            "Expected OL-1400, got {}",
            err.code
        );
    }
}