openlatch-client 0.0.0

The open-source security layer for AI agents — client forwarder
Documentation
/// Claude Code hook detection and entry building.
///
/// Responsible for:
/// - Detecting whether Claude Code is installed by checking `~/.claude/` directory
/// - Resolving the path to `settings.json`
/// - Building well-formed JSON hook entries for Claude Code's HTTP hook format
use std::path::{Path, PathBuf};

use serde_json::{json, Value};

/// 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> {
    // Use the USERPROFILE env var on Windows; dirs::home_dir() works cross-platform.
    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 HTTP hook entry for the given event type.
///
/// # Hook format differences by event type
///
/// - `PreToolUse`: includes `"matcher": ""` (empty string — fires on every tool)
/// - `UserPromptSubmit` and `Stop`: MUST NOT include a `"matcher"` field at all
///
/// 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`: one of `"PreToolUse"`, `"UserPromptSubmit"`, `"Stop"`
/// - `port`: the daemon port (usually 7443)
/// - `token_env_var`: the env var name whose value Claude Code injects as the bearer token.
///   Write the *name* (e.g. `"OPENLATCH_TOKEN"`), not the token value itself.
///   Claude Code resolves `$OPENLATCH_TOKEN` at runtime via `allowedEnvVars`.
///
/// # Security
///
/// SECURITY: Never write the actual token value into settings.json.
/// The token env var name written here is resolved by Claude Code at runtime.
/// Ref: T-02-02 (info disclosure threat mitigation).
pub fn build_hook_entry(event_type: &str, port: u16, token_env_var: &str) -> Value {
    let url_path = match event_type {
        "PreToolUse" => "pre-tool-use",
        "UserPromptSubmit" => "user-prompt-submit",
        "Stop" => "stop",
        // For unknown event types, kebab-case the name as a best-effort fallback.
        other => other,
    };

    let hook_inner = json!({
        "type": "http",
        "url": format!("http://localhost:{port}/hooks/{url_path}"),
        "timeout": 10,
        "headers": {
            "Authorization": format!("Bearer ${{{token_env_var}}}")
        },
        "allowedEnvVars": [token_env_var]
    });

    // CRITICAL per Pitfall 2 (RESEARCH.md): UserPromptSubmit and Stop MUST NOT
    // have a "matcher" field. Only PreToolUse uses an empty-string matcher.
    if event_type == "PreToolUse" {
        json!({
            "matcher": "",
            "_openlatch": true,
            "hooks": [hook_inner]
        })
    } else {
        json!({
            "_openlatch": true,
            "hooks": [hook_inner]
        })
    }
}

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

    #[test]
    fn test_build_hook_entry_pre_tool_use_has_matcher() {
        let entry = build_hook_entry("PreToolUse", 7443, "OPENLATCH_TOKEN");
        assert_eq!(entry["matcher"], "");
        assert_eq!(entry["_openlatch"], true);
        let url = entry["hooks"][0]["url"].as_str().unwrap();
        assert_eq!(url, "http://localhost:7443/hooks/pre-tool-use");
    }

    #[test]
    fn test_build_hook_entry_user_prompt_submit_no_matcher() {
        let entry = build_hook_entry("UserPromptSubmit", 7443, "OPENLATCH_TOKEN");
        assert!(
            entry.get("matcher").is_none(),
            "UserPromptSubmit must not have matcher field"
        );
        assert_eq!(entry["_openlatch"], true);
        let url = entry["hooks"][0]["url"].as_str().unwrap();
        assert_eq!(url, "http://localhost:7443/hooks/user-prompt-submit");
    }

    #[test]
    fn test_build_hook_entry_stop_no_matcher() {
        let entry = build_hook_entry("Stop", 7443, "OPENLATCH_TOKEN");
        assert!(
            entry.get("matcher").is_none(),
            "Stop must not have matcher field"
        );
        assert_eq!(entry["_openlatch"], true);
        let url = entry["hooks"][0]["url"].as_str().unwrap();
        assert_eq!(url, "http://localhost:7443/hooks/stop");
    }

    #[test]
    fn test_build_hook_entry_uses_env_var_name_not_value() {
        let entry = build_hook_entry("PreToolUse", 7443, "OPENLATCH_TOKEN");
        // The Authorization header must reference the env var, not a literal token.
        let auth = entry["hooks"][0]["headers"]["Authorization"]
            .as_str()
            .unwrap();
        assert!(
            auth.contains("${OPENLATCH_TOKEN}"),
            "Auth header must use env var reference, got: {auth}"
        );
        // allowedEnvVars must list the env var name.
        let allowed = &entry["hooks"][0]["allowedEnvVars"];
        assert_eq!(allowed[0], "OPENLATCH_TOKEN");
    }

    #[test]
    fn test_build_hook_entry_openlatch_marker_present() {
        for event_type in &["PreToolUse", "UserPromptSubmit", "Stop"] {
            let entry = build_hook_entry(event_type, 7443, "OPENLATCH_TOKEN");
            assert_eq!(
                entry["_openlatch"], true,
                "{event_type} entry must carry _openlatch:true marker"
            );
        }
    }
}