openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! 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;

/// Relocates Claude Code's configuration directory (verified on 2.1.220).
///
/// Deliberately the same variable `daemon::identity::provider_account` already
/// honours. That module and this one resolve the *same* `settings.json`, and
/// while only one of them read the variable they could disagree about where it
/// was: a user who relocated their Claude config had identity read from the new
/// path while every hook and boundary write went to `~/.claude`.
pub(crate) const CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";

/// Where Claude Code's configuration directory *would* be, whether or not it
/// exists: `$CLAUDE_CONFIG_DIR` when set and non-empty, else `~/.claude`.
///
/// Split out from [`detect`] because the two callers need different contracts —
/// `detect` answers "is Claude Code installed" and so must stat the directory,
/// while `daemon::identity::provider_account` needs the path regardless of
/// existence. Before this existed each of them open-coded the same match, which
/// is precisely how one of them came to honour `$CLAUDE_CONFIG_DIR` and the
/// others did not.
pub(crate) fn config_dir() -> Option<PathBuf> {
    match relocated_dir() {
        Some(relocated) => Some(relocated),
        None => Some(dirs::home_dir()?.join(".claude")),
    }
}

/// The directory holding Claude Code's `.claude.json` state file:
/// `$CLAUDE_CONFIG_DIR` when set and non-empty, else the home directory itself.
///
/// The *sibling* of [`config_dir`], not a variant of it. With the variable
/// unset the state file is `~/.claude.json` and the settings file is
/// `~/.claude/settings.json`, one level deeper — so the two answers differ by a
/// component. Setting the variable collapses them: Claude Code moves the state
/// file inside the relocated directory. Only that branch is shared, which is why
/// this is a second function over the same variable rather than a caller of the
/// first.
///
/// Lives here rather than in `daemon::identity::provider_account`, its original
/// owner, so the config-monitor manifest can reach the same answer. Two copies
/// is exactly how `$CLAUDE_CONFIG_DIR` came to be honoured by identity and by
/// nothing else.
pub(crate) fn state_dir() -> Option<PathBuf> {
    relocated_dir().or_else(dirs::home_dir)
}

/// `$CLAUDE_CONFIG_DIR` when set to something non-empty.
///
/// An empty value reads as unset — the conventional reading, and the only safe
/// one here: an exported-but-blank variable would otherwise resolve every path
/// below it relative to the process cwd.
fn relocated_dir() -> Option<PathBuf> {
    std::env::var_os(CONFIG_DIR_ENV)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// Detect whether Claude Code is installed.
///
/// Returns `Some(claude_dir)` for `$CLAUDE_CONFIG_DIR` when set and non-empty,
/// otherwise `~/.claude/` (`%USERPROFILE%\.claude\` on Windows), if that
/// directory exists.
///
/// **This is the single resolver.** `hooks::detect_agent` and
/// `hooks::bindings::claude_code::ClaudeCodeBinding::detect` both route through
/// it rather than calling `dirs::home_dir()` themselves — they used to, and the
/// duplication is exactly how the seam came to cover one caller and not the
/// others. The distinction is not academic on Windows, where `dirs::home_dir()`
/// resolves `FOLDERID_Profile` through `SHGetKnownFolderPath` and consults no
/// environment variable at all: a redirected `HOME` cannot reach it, so a test
/// sandbox that only set `HOME` silently resolved to the developer's real
/// `~/.claude` and wrote `ANTHROPIC_BASE_URL` into it. An env seam is the only
/// form of redirection that works on all three platforms.
pub fn detect() -> Option<PathBuf> {
    let claude_dir = config_dir()?;
    claude_dir.is_dir().then_some(claude_dir)
}

/// 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",
        // Config-plane lifecycle events. Declared in EVENT_TYPES since #60 but
        // never mapped here, so all three installed as `--event unknown` and
        // every firing landed in unified_events as type='unknown'.
        "ConfigChange" => "config_change",
        "InstructionsLoaded" => "instructions_loaded",
        "FileChanged" => "file_changed",
        _ => "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");
    }
}