openlatch-client 0.1.5

The open-source security layer for AI agents — client forwarder
//! Claude Code hook detection and entry building.
//!
//! Post-CloudEvents migration: hook entries invoke the `openlatch-hook`
//! binary as a command-type hook (Mode A). The binary wraps the raw event
//! from stdin into a CloudEvents v1.0.2 envelope and POSTs it to the daemon
//! at `/hooks`. The agent slug and event type are passed as CLI flags so the
//! command string is identical across POSIX shells and Windows cmd.exe.
use std::path::{Path, PathBuf};

use serde_json::{json, Value};

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

/// Detect whether Claude Code is installed.
///
/// Returns `Some(claude_dir)` if the `~/.claude/` directory (or `%USERPROFILE%\.claude\`
/// on Windows) exists, otherwise `None`.
pub fn detect() -> Option<PathBuf> {
    let home = dirs::home_dir()?;
    let claude_dir = home.join(".claude");
    if claude_dir.is_dir() {
        Some(claude_dir)
    } else {
        None
    }
}

/// Return the path to `settings.json` inside the Claude Code config directory.
pub fn settings_json_path(claude_dir: &Path) -> PathBuf {
    claude_dir.join("settings.json")
}

/// Build a Claude Code command-type hook entry for the given event type.
///
/// # Hook format differences by event type
///
/// - `PreToolUse`: includes `"matcher": ""` (empty string — fires on every tool)
/// - All other events: MUST NOT include a `"matcher"` field
///
/// Each entry carries `"_openlatch": true` as an ownership marker so we can
/// locate and replace our own entries on re-init without touching others.
///
/// # Arguments
///
/// - `event_type`: Claude Code event name, e.g. `"PreToolUse"`, `"Stop"`, `"SessionEnd"`
/// - `_port`: the daemon port (forwarded to the openlatch-hook binary via env)
/// - `token_env_var`: the env var name whose value carries the bearer token.
///   The hook binary reads `OPENLATCH_TOKEN` from the process env, so
///   `allowedEnvVars` ensures Claude Code propagates it.
/// - `binary_path`: absolute path to the `openlatch-hook` binary, typically
///   `~/.openlatch/bin/openlatch-hook` (or `.exe` on Windows).
///
/// # Security
///
/// The token value is NEVER written into settings.json. The command string
/// references the env var name; Claude Code propagates the value at runtime.
/// Ref: T-02-02 (info disclosure threat mitigation).
pub fn build_hook_entry(
    event_type: &str,
    _port: u16,
    token_env_var: &str,
    binary_path: &Path,
    marker: &OpenlatchMarker,
) -> Value {
    let wire_event = pascal_to_snake(event_type);
    let binary_str = binary_path.display().to_string();

    // Quote paths with spaces. Windows paths often contain spaces; POSIX
    // shells interpret unquoted spaces as argument separators. Double-quoting
    // is safe in cmd.exe, PowerShell, bash, and zsh.
    let command = format!(r#""{binary_str}" --agent claude-code --event {wire_event}"#);

    let hook_inner = json!({
        "type": "command",
        "command": command,
        "timeout": 10,
        "allowedEnvVars": [token_env_var, "OPENLATCH_PORT"]
    });

    let marker_value =
        serde_json::to_value(marker).expect("OpenlatchMarker is always serializable");

    if event_type == "PreToolUse" {
        json!({
            "matcher": "",
            "_openlatch": marker_value,
            "hooks": [hook_inner]
        })
    } else {
        json!({
            "_openlatch": marker_value,
            "hooks": [hook_inner]
        })
    }
}

/// Map Claude Code's PascalCase hook event names to snake_case CloudEvents
/// `type` values. Keeps the canonical vocabulary aligned with
/// `x-known-values` in `schemas/enums.schema.json`.
fn pascal_to_snake(event: &str) -> &'static str {
    match event {
        "PreToolUse" => "pre_tool_use",
        "PostToolUse" => "post_tool_use",
        "UserPromptSubmit" => "user_prompt_submit",
        "Notification" => "notification",
        "Stop" => "stop",
        "SubagentStop" => "subagent_stop",
        "PreCompact" => "pre_compact",
        "SessionStart" => "session_start",
        "SessionEnd" => "session_end",
        _ => "unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn test_bin() -> PathBuf {
        PathBuf::from("/opt/openlatch/bin/openlatch-hook")
    }

    fn test_marker() -> OpenlatchMarker {
        OpenlatchMarker {
            v: 1,
            id: "test-marker-id".into(),
            installed_at: chrono::DateTime::parse_from_rfc3339("2026-04-16T12:00:00Z")
                .unwrap()
                .with_timezone(&chrono::Utc),
            hmac: Some("test-hmac".into()),
        }
    }

    #[test]
    fn test_build_hook_entry_pre_tool_use_has_matcher() {
        let entry = build_hook_entry(
            "PreToolUse",
            7443,
            "OPENLATCH_TOKEN",
            &test_bin(),
            &test_marker(),
        );
        assert_eq!(entry["matcher"], "");
        assert!(
            entry["_openlatch"].is_object(),
            "_openlatch must be an object marker"
        );
        assert_eq!(entry["_openlatch"]["v"], 1);
        assert_eq!(entry["_openlatch"]["id"], "test-marker-id");
        let cmd = entry["hooks"][0]["command"].as_str().unwrap();
        assert!(cmd.contains("openlatch-hook"));
        assert!(cmd.contains("--agent claude-code"));
        assert!(cmd.contains("--event pre_tool_use"));
    }

    #[test]
    fn test_build_hook_entry_user_prompt_submit_no_matcher() {
        let entry = build_hook_entry(
            "UserPromptSubmit",
            7443,
            "OPENLATCH_TOKEN",
            &test_bin(),
            &test_marker(),
        );
        assert!(
            entry.get("matcher").is_none(),
            "UserPromptSubmit must not have matcher field"
        );
        assert!(entry["_openlatch"].is_object());
        let cmd = entry["hooks"][0]["command"].as_str().unwrap();
        assert!(cmd.contains("--event user_prompt_submit"));
    }

    #[test]
    fn test_build_hook_entry_stop_no_matcher() {
        let entry = build_hook_entry("Stop", 7443, "OPENLATCH_TOKEN", &test_bin(), &test_marker());
        assert!(entry.get("matcher").is_none());
        assert!(entry["_openlatch"].is_object());
        let cmd = entry["hooks"][0]["command"].as_str().unwrap();
        assert!(cmd.contains("--event stop"));
    }

    #[test]
    fn test_build_hook_entry_uses_command_type() {
        let entry = build_hook_entry(
            "PreToolUse",
            7443,
            "OPENLATCH_TOKEN",
            &test_bin(),
            &test_marker(),
        );
        assert_eq!(
            entry["hooks"][0]["type"], "command",
            "post-migration hooks must use command type (Mode A), not http"
        );
    }

    #[test]
    fn test_build_hook_entry_never_writes_token_value() {
        let entry = build_hook_entry(
            "PreToolUse",
            7443,
            "OPENLATCH_TOKEN",
            &test_bin(),
            &test_marker(),
        );
        let json = serde_json::to_string(&entry).unwrap();
        assert!(
            !json.contains("Bearer "),
            "rendered hook must not contain an inline bearer prefix"
        );
        assert!(entry["hooks"][0]["allowedEnvVars"]
            .as_array()
            .unwrap()
            .iter()
            .any(|v| v == "OPENLATCH_TOKEN"));
    }

    #[test]
    fn test_build_hook_entry_openlatch_marker_is_object() {
        for event_type in &["PreToolUse", "UserPromptSubmit", "Stop", "SessionEnd"] {
            let entry = build_hook_entry(
                event_type,
                7443,
                "OPENLATCH_TOKEN",
                &test_bin(),
                &test_marker(),
            );
            assert!(
                entry["_openlatch"].is_object(),
                "{event_type} entry must carry _openlatch object marker"
            );
            assert_eq!(entry["_openlatch"]["v"], 1);
        }
    }

    #[test]
    fn test_pascal_to_snake_covers_canonical_vocabulary() {
        // Every x-known-values HookEventType must map here, or the hook
        // generator emits `unknown` — which the daemon still accepts but
        // renders the telemetry vocabulary useless.
        assert_eq!(pascal_to_snake("PreToolUse"), "pre_tool_use");
        assert_eq!(pascal_to_snake("PostToolUse"), "post_tool_use");
        assert_eq!(pascal_to_snake("UserPromptSubmit"), "user_prompt_submit");
        assert_eq!(pascal_to_snake("Notification"), "notification");
        assert_eq!(pascal_to_snake("Stop"), "stop");
        assert_eq!(pascal_to_snake("SubagentStop"), "subagent_stop");
        assert_eq!(pascal_to_snake("PreCompact"), "pre_compact");
        assert_eq!(pascal_to_snake("SessionStart"), "session_start");
        assert_eq!(pascal_to_snake("SessionEnd"), "session_end");
    }
}