Skip to main content

openlatch_client/hooks/
claude_code.rs

1/// Claude Code hook detection and entry building.
2///
3/// Responsible for:
4/// - Detecting whether Claude Code is installed by checking `~/.claude/` directory
5/// - Resolving the path to `settings.json`
6/// - Building well-formed JSON hook entries for Claude Code's HTTP hook format
7use std::path::{Path, PathBuf};
8
9use serde_json::{json, Value};
10
11/// Detect whether Claude Code is installed.
12///
13/// Returns `Some(claude_dir)` if the `~/.claude/` directory (or `%USERPROFILE%\.claude\`
14/// on Windows) exists, otherwise `None`.
15pub fn detect() -> Option<PathBuf> {
16    // Use the USERPROFILE env var on Windows; dirs::home_dir() works cross-platform.
17    let home = dirs::home_dir()?;
18    let claude_dir = home.join(".claude");
19    if claude_dir.is_dir() {
20        Some(claude_dir)
21    } else {
22        None
23    }
24}
25
26/// Return the path to `settings.json` inside the Claude Code config directory.
27pub fn settings_json_path(claude_dir: &Path) -> PathBuf {
28    claude_dir.join("settings.json")
29}
30
31/// Build a Claude Code HTTP hook entry for the given event type.
32///
33/// # Hook format differences by event type
34///
35/// - `PreToolUse`: includes `"matcher": ""` (empty string — fires on every tool)
36/// - `UserPromptSubmit` and `Stop`: MUST NOT include a `"matcher"` field at all
37///
38/// Each entry carries `"_openlatch": true` as an ownership marker so we can
39/// locate and replace our own entries on re-init without touching others.
40///
41/// # Arguments
42///
43/// - `event_type`: one of `"PreToolUse"`, `"UserPromptSubmit"`, `"Stop"`
44/// - `port`: the daemon port (usually 7443)
45/// - `token_env_var`: the env var name whose value Claude Code injects as the bearer token.
46///   Write the *name* (e.g. `"OPENLATCH_TOKEN"`), not the token value itself.
47///   Claude Code resolves `$OPENLATCH_TOKEN` at runtime via `allowedEnvVars`.
48///
49/// # Security
50///
51/// SECURITY: Never write the actual token value into settings.json.
52/// The token env var name written here is resolved by Claude Code at runtime.
53/// Ref: T-02-02 (info disclosure threat mitigation).
54pub fn build_hook_entry(event_type: &str, port: u16, token_env_var: &str) -> Value {
55    let url_path = match event_type {
56        "PreToolUse" => "pre-tool-use",
57        "UserPromptSubmit" => "user-prompt-submit",
58        "Stop" => "stop",
59        // For unknown event types, kebab-case the name as a best-effort fallback.
60        other => other,
61    };
62
63    let hook_inner = json!({
64        "type": "http",
65        "url": format!("http://localhost:{port}/hooks/{url_path}"),
66        "timeout": 10,
67        "headers": {
68            "Authorization": format!("Bearer ${{{token_env_var}}}")
69        },
70        "allowedEnvVars": [token_env_var]
71    });
72
73    // CRITICAL per Pitfall 2 (RESEARCH.md): UserPromptSubmit and Stop MUST NOT
74    // have a "matcher" field. Only PreToolUse uses an empty-string matcher.
75    if event_type == "PreToolUse" {
76        json!({
77            "matcher": "",
78            "_openlatch": true,
79            "hooks": [hook_inner]
80        })
81    } else {
82        json!({
83            "_openlatch": true,
84            "hooks": [hook_inner]
85        })
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_build_hook_entry_pre_tool_use_has_matcher() {
95        let entry = build_hook_entry("PreToolUse", 7443, "OPENLATCH_TOKEN");
96        assert_eq!(entry["matcher"], "");
97        assert_eq!(entry["_openlatch"], true);
98        let url = entry["hooks"][0]["url"].as_str().unwrap();
99        assert_eq!(url, "http://localhost:7443/hooks/pre-tool-use");
100    }
101
102    #[test]
103    fn test_build_hook_entry_user_prompt_submit_no_matcher() {
104        let entry = build_hook_entry("UserPromptSubmit", 7443, "OPENLATCH_TOKEN");
105        assert!(
106            entry.get("matcher").is_none(),
107            "UserPromptSubmit must not have matcher field"
108        );
109        assert_eq!(entry["_openlatch"], true);
110        let url = entry["hooks"][0]["url"].as_str().unwrap();
111        assert_eq!(url, "http://localhost:7443/hooks/user-prompt-submit");
112    }
113
114    #[test]
115    fn test_build_hook_entry_stop_no_matcher() {
116        let entry = build_hook_entry("Stop", 7443, "OPENLATCH_TOKEN");
117        assert!(
118            entry.get("matcher").is_none(),
119            "Stop must not have matcher field"
120        );
121        assert_eq!(entry["_openlatch"], true);
122        let url = entry["hooks"][0]["url"].as_str().unwrap();
123        assert_eq!(url, "http://localhost:7443/hooks/stop");
124    }
125
126    #[test]
127    fn test_build_hook_entry_uses_env_var_name_not_value() {
128        let entry = build_hook_entry("PreToolUse", 7443, "OPENLATCH_TOKEN");
129        // The Authorization header must reference the env var, not a literal token.
130        let auth = entry["hooks"][0]["headers"]["Authorization"]
131            .as_str()
132            .unwrap();
133        assert!(
134            auth.contains("${OPENLATCH_TOKEN}"),
135            "Auth header must use env var reference, got: {auth}"
136        );
137        // allowedEnvVars must list the env var name.
138        let allowed = &entry["hooks"][0]["allowedEnvVars"];
139        assert_eq!(allowed[0], "OPENLATCH_TOKEN");
140    }
141
142    #[test]
143    fn test_build_hook_entry_openlatch_marker_present() {
144        for event_type in &["PreToolUse", "UserPromptSubmit", "Stop"] {
145            let entry = build_hook_entry(event_type, 7443, "OPENLATCH_TOKEN");
146            assert_eq!(
147                entry["_openlatch"], true,
148                "{event_type} entry must carry _openlatch:true marker"
149            );
150        }
151    }
152}