openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! 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 model relay write went to `~/.claude`.
pub(crate) const CONFIG_DIR_ENV: &str = "CLAUDE_CONFIG_DIR";

/// Serializes the tests that mutate [`CONFIG_DIR_ENV`] or `HOME`.
///
/// Environment variables are **process-wide**, not per-test, and `cargo test`
/// runs tests as threads in one process. Two suites already mutate this pair —
/// `cli::commands::doctor_fix` points `CLAUDE_CONFIG_DIR` at a tempdir that
/// *does* contain a Claude install, and `hooks::test_detect_agent_returns_
/// ol_1400_when_no_claude_dir` points `HOME` at one that does not — so without
/// this lock the second reads the first's value and [`detect`] finds an install
/// it was asserting could not exist. That is a flaky failure that looks exactly
/// like a real regression, which is worse than a slow test.
///
/// Lives here rather than in either test module because the variable's meaning
/// is defined here: anything that mutates it must take this lock. Poison is
/// ignored — a panicking test has already failed, and the guard only orders
/// access.
#[cfg(test)]
pub(crate) static CONFIG_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// 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)
}

/// Is the agent config this process would write the **machine-global** one?
///
/// `~/.claude/settings.json` is shared by every Claude Code session on the host
/// and has exactly one owner: the canonical OpenLatch daemon. A relocated
/// `$CLAUDE_CONFIG_DIR` is a different file, used only by sessions launched
/// with the same variable — writing it takes nothing away from anybody.
///
/// This is the question [`crate::config::ModelRelayConfig::owns_agent_wiring`]
/// actually needs answered. It used to ask "am I on the default model relay port?"
/// instead, which is a proxy for it and answers wrong in the case that matters
/// for development: a fully isolated instance — own state dir, own agent config
/// dir, own ports — was refused the right to wire its own throwaway
/// settings.json, so every sandbox session needed `ANTHROPIC_BASE_URL` exported
/// by hand.
///
/// Paths are canonicalized, so `$CLAUDE_CONFIG_DIR` pointed deliberately at the
/// real `~/.claude` (through a symlink, or with a trailing slash) is still
/// recognised as machine-global. Anything we cannot resolve answers `true`:
/// declining to write is the safe direction.
pub fn config_is_machine_global() -> bool {
    let Some(resolved) = config_dir() else {
        return true;
    };
    let Some(default) = dirs::home_dir().map(|home| home.join(".claude")) else {
        return true;
    };
    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    canonical(&resolved) == canonical(&default)
}

/// 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 timeout = if event_type == "PreToolUse" { 900 } else { 10 };
    let hook_inner = json!({
        "type": "command",
        "command": command,
        "timeout": timeout,
        "allowedEnvVars": [token_env_var, super::OPENLATCH_PORT_ENV]
    });

    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 an agent'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`.
///
/// `pub(crate)` and no longer Claude-only: `bindings::codex_cli` builds its
/// command strings from the same map, and a second copy is how three event
/// names came to install as `--event unknown` the first time (see the arms
/// below). One map, one vocabulary.
pub(crate) fn pascal_to_snake(event: &str) -> &'static str {
    match event {
        "PreToolUse" => "pre_tool_use",
        "PostToolUse" => "post_tool_use",
        "PostToolUseFailure" => "post_tool_use_failure",
        "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",
        // Codex CLI's native events that Claude Code has no counterpart for.
        // Mapped here rather than in a per-agent copy for the reason the three
        // arms above exist: an unmapped name is not a compile error, it
        // silently installs as `--event unknown`.
        "PermissionRequest" => "permission_request",
        "PostCompact" => "post_compact",
        "SubagentStart" => "subagent_start",
        "Interrupt" => "interrupt",
        // Cline's six. Mechanical snake_case of the FILE NAME we install, not
        // of Cline's internal event name: its `HOOK_CONFIG_FILE_EVENT_MAP`
        // calls `TaskStart` `agent_start`, but that is an SDK detail invisible
        // at our boundary. The file name is what we write, what doctor reports
        // and what every other arm here is derived from.
        //
        // `session_shutdown` stays distinct from Claude's `session_end` above:
        // different events, not synonyms.
        "TaskStart" => "task_start",
        "TaskResume" => "task_resume",
        "TaskCancel" => "task_cancel",
        "TaskComplete" => "task_complete",
        "TaskError" => "task_error",
        "SessionShutdown" => "session_shutdown",
        _ => "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 only_pre_tool_use_receives_the_hold_timeout() {
        for event in ["PreToolUse", "PostToolUse", "SessionStart", "Stop"] {
            let entry =
                build_hook_entry(event, 7443, "OPENLATCH_TOKEN", &test_bin(), &test_marker());
            assert_eq!(
                entry["hooks"][0]["timeout"].as_u64(),
                Some(if event == "PreToolUse" { 900 } else { 10 }),
                "event={event}"
            );
        }
    }

    #[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");
        // Codex CLI's four, added with their x-known-values entries.
        assert_eq!(pascal_to_snake("PermissionRequest"), "permission_request");
        assert_eq!(pascal_to_snake("PostCompact"), "post_compact");
        assert_eq!(pascal_to_snake("SubagentStart"), "subagent_start");
        assert_eq!(pascal_to_snake("Interrupt"), "interrupt");
    }

    /// The guard against the bug this repository has already shipped twice.
    ///
    /// An unmapped name is not a compile error: it falls to `_ => "unknown"`,
    /// installs as `--event unknown`, and every firing lands in the platform
    /// as `type='unknown'` — six hook files indistinguishable from each other
    /// and from any other agent's unmapped event.
    ///
    /// The ten names are Cline's `HookConfigFileName` spellings, which are the
    /// file names the installer writes. The wire spellings are stated here
    /// because they are what this test is about; the *names* are asserted
    /// against `CLINE_HOOK_FILES`, so the installer's list and this one cannot
    /// drift apart.
    #[test]
    fn every_cline_event_maps_to_a_wire_name() {
        let expected = [
            ("TaskStart", "task_start"),
            ("TaskResume", "task_resume"),
            ("TaskCancel", "task_cancel"),
            ("TaskComplete", "task_complete"),
            ("TaskError", "task_error"),
            ("PreToolUse", "pre_tool_use"),
            ("PostToolUse", "post_tool_use"),
            ("UserPromptSubmit", "user_prompt_submit"),
            ("PreCompact", "pre_compact"),
            ("SessionShutdown", "session_shutdown"),
        ];
        assert_eq!(expected.len(), 10, "Cline installs exactly ten hook files");
        let mut named: Vec<&str> = expected.iter().map(|(name, _)| *name).collect();
        let mut installed: Vec<&str> = crate::hooks::hook_files::CLINE_HOOK_FILES.to_vec();
        named.sort_unstable();
        installed.sort_unstable();
        assert_eq!(
            named, installed,
            "this list and the installer's have drifted apart"
        );

        for (file_name, wire) in expected {
            let mapped = pascal_to_snake(file_name);
            assert_ne!(
                mapped, "unknown",
                "{file_name} would install as `--event unknown`"
            );
            assert_eq!(mapped, wire, "{file_name} maps to the wrong wire name");
            assert!(
                crate::core::envelope::KNOWN_HOOK_EVENT_TYPES.contains(&mapped),
                "{mapped} is not in the wire vocabulary — the platform would \
                 record it as unknown even though this map is right"
            );
        }

        // `session_shutdown` and `session_end` are different events. Mapping
        // Cline's onto Claude's would merge two lifecycles in the telemetry.
        assert_ne!(
            pascal_to_snake("SessionShutdown"),
            pascal_to_snake("SessionEnd")
        );
    }
}