openlatch-client 0.3.3

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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
//! The agent binding contract — one trait every supported agent answers, and
//! the detector that constructs them.
//!
//! `Agent Binding Architecture` §1 states the containment test: *if a change
//! requires editing shared decision code, the line is in the wrong place and we
//! move the line*. Every question shared code used to ask in Claude Code's own
//! shape is a method here.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde_json::Value;

use crate::boundary::wire_format::WireFormat;
use crate::core::hook_state::marker::OpenlatchMarker;

use super::bindings::claude_code::ClaudeCodeBinding;
use super::bindings::codex_cli::CodexCliBinding;
use super::{AgentKind, DetectedAgent};

/// Everything the client needs to know about one agent.
///
/// Object-safe by construction: shared code holds an `Arc<dyn AgentBinding>`
/// and never matches on which agent it has.
pub trait AgentBinding: Send + Sync {
    /// CloudEvents `source` wire value: `"claude-code"`, `"codex-cli"`.
    ///
    /// `'static` rather than a borrowed `&str`: `Check.agent` is
    /// `Option<&'static str>` and is fed from here.
    fn agent_type(&self) -> &'static str;

    /// Human-facing label: `"Claude Code"`.
    ///
    /// Not derivable from [`agent_type`](Self::agent_type) — that is the wire
    /// string, and rendering `Agent: claude-code (…)` would change human
    /// output. On the trait rather than a `match kind` at the call site,
    /// because a match is a second per-agent mapping that can drift from the
    /// binding.
    fn display_name(&self) -> &'static str;

    /// The agent's configuration directory.
    fn config_dir(&self) -> PathBuf;

    /// The file hook registrations are written into.
    fn hook_config_path(&self) -> PathBuf;

    /// Native event names, PascalCase, as written into the agent's config.
    /// THE one list of events install writes.
    fn hook_event_types(&self) -> &'static [&'static str];

    /// Which of *this* agent's events must be present for capture to work.
    ///
    /// An agent that registers a different set is judged on its set, never on
    /// Claude Code's.
    fn load_bearing_events(&self) -> &'static [&'static str];

    /// How a spawned hook — a fresh process, per event — finds its daemon.
    ///
    /// The channel is the agent's, not ours: some agents forward environment
    /// variables we pin in their config, some cannot express that at all.
    fn daemon_channel(&self) -> DaemonChannel;

    /// Is enforcement actually **armed**, or merely installed? Two different
    /// claims, and only the binding knows the difference. The common detector
    /// asks every agent this one question and renders the answer uniformly —
    /// never a second rendering path per agent.
    fn liveness(&self) -> LivenessReport;

    /// Owns every per-agent quirk (Claude Code's `"matcher": ""`) and the
    /// ownership marker.
    fn build_hook_entry(
        &self,
        event: &str,
        binary: &Path,
        port: u16,
        marker: &OpenlatchMarker,
    ) -> Value;

    /// True when [`config_dir`](Self::config_dir) resolves to the
    /// machine-global location.
    fn config_is_machine_global(&self) -> bool;

    /// What this agent's hook protocol can and cannot express.
    fn capabilities(&self) -> BindingCapabilities;

    /// How the agent is pointed at the model boundary, when it has a request
    /// plane at all. `None` means it has none — a question that does not apply,
    /// never a failure.
    fn boundary_wiring(&self) -> Option<BoundaryWiring>;
}

/// How an agent is wired to the model boundary.
#[derive(Debug, Clone)]
pub struct BoundaryWiring {
    /// The provider protocol this agent's request plane speaks. Keyed to the
    /// boundary's own registry, so the map key, the upstream and the
    /// `wireformat` attribute cannot drift apart.
    pub wire_format: WireFormat,
    /// The mechanism that carries the base URL.
    pub endpoint: EndpointConvention,
    /// Always `"x-openlatch-install-id"`.
    ///
    /// **This is where the writer reads the name from** — the merge, the strip
    /// and the `http_headers` entry all take it from here rather than from a
    /// constant of their own, so no shared writer carries one agent's
    /// vocabulary. `boundary::proxy` holds the matching read-side constant.
    pub install_id_header: &'static str,
}

/// The mechanism an agent offers for naming its model provider.
#[derive(Debug, Clone)]
pub enum EndpointConvention {
    /// Claude Code: `env.ANTHROPIC_BASE_URL` + `env.ANTHROPIC_CUSTOM_HEADERS`.
    EnvVars {
        /// Env var name carrying the base URL.
        base_url: &'static str,
        /// Env var name carrying the extra static headers.
        headers: &'static str,
    },
    /// Codex: a `[model_providers.<name>]` table in `config.toml`.
    TomlProvider {
        /// The provider table's name.
        provider_name: &'static str,
        /// The `wire_api` value that table declares.
        wire_api: &'static str,
    },
}

/// How the hook process is handed its port and bearer token.
#[derive(Debug, Clone, Copy)]
pub enum DaemonChannel {
    /// The agent forwards named environment variables that install pins into
    /// its config.
    EnvVars {
        /// Env var name carrying the daemon bearer token.
        token: &'static str,
        /// Env var name carrying the daemon port.
        port: &'static str,
    },
    /// The agent cannot forward environment variables, so the hook is told
    /// where to look on its own command line and reads both secrets from that
    /// directory. The token *value* never reaches the config — only a path.
    OpenlatchDirArg,
}

/// What a binding can say about whether it is armed.
///
/// `installed` is a file fact; `armed` is an enforcement fact. Rendering the
/// first as the second is the failure *off is never a pass* exists to stop.
#[derive(Debug, Clone)]
pub struct LivenessReport {
    /// `None` — this build cannot tell, because there is nothing to tell:
    /// installed *is* armed. The renderer pushes nothing.
    /// `Some(false)` — installed and provably not enforcing.
    /// `Some(true)` — proven armed.
    pub armed: Option<bool>,
    /// Why, in the agent's own terms. Feeds the `Check`'s detail.
    pub detail: Option<String>,
    /// The actionable remedy when `armed == Some(false)` — **mandatory** in
    /// that state: `Check::validate` returns a contract violation for a
    /// `requires_remedy()` state carrying a code but no remedy.
    pub remedy: Option<String>,
    /// The `OL-XXXX` code stamped on the `Check` when `armed == Some(false)`.
    /// Not decoration: a failed `Check` without one fails `Check::validate`.
    pub code: Option<&'static str>,
}

/// What an agent's hook protocol can express.
#[derive(Debug, Clone, Copy)]
pub struct BindingCapabilities {
    /// Subset of `allow` | `ask` | `deny` this agent can be told.
    pub expressible: &'static [&'static str],
    /// Whether a verdict can rewrite the tool call's arguments.
    pub can_mutate_arguments: bool,
    /// What the agent does when the hook itself fails.
    pub native_failure_mode: FailureMode,
    /// Whether the settings file is administrator-owned.
    pub admin_owned_settings: bool,
    /// Whether the agent names its own session inside the request body.
    pub declares_session_in_request: bool,
}

/// What an agent does when its hook fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureMode {
    /// The tool call proceeds.
    FailOpen,
    /// The tool call is refused.
    FailClosed,
    /// Undocumented, or not observed.
    Unknown,
}

/// Display names of the agents **this build can detect** — the bindings
/// [`detect_all`] actually constructs.
///
/// Not the supported wire vocabulary. That is the eight-name
/// `crate::generated::known_values::SCHEMA_AGENT_TYPES`, generated from
/// `schemas/enums.schema.json`. Two lists, two different questions: *what can I
/// detect* versus *what values are valid on the wire*. Do not merge them.
pub const DETECTABLE_AGENT_NAMES: &[&str] = &["Claude Code", "Codex CLI"];

/// Every agent installed on this host, in detection order.
///
/// Declaration order **is** detection order and is load-bearing: callers that
/// legitimately want one agent take the first.
pub fn detect_all() -> Vec<DetectedAgent> {
    let mut found = Vec::new();
    if let Some(b) = ClaudeCodeBinding::detect() {
        found.push(DetectedAgent {
            kind: AgentKind::ClaudeCode,
            binding: Arc::new(b),
        });
    }
    if let Some(b) = CodexCliBinding::detect() {
        found.push(DetectedAgent {
            kind: AgentKind::CodexCli,
            binding: Arc::new(b),
        });
    }
    found
}

#[cfg(test)]
pub mod test_support {
    //! A second `AgentBinding` that exists ONLY so multi-agent tests can be
    //! written against a build that detects one agent. Never constructed by
    //! [`detect_all`](super::detect_all).

    use super::*;

    /// The two-agent fixture every multi-agent test in this repo is written
    /// against: the real Claude binding, plus a `cursor` [`FakeBinding`], both
    /// rooted under `root` with their config directories created.
    ///
    /// Seeding the two `settings.json` files is deliberately left to the
    /// caller, because that is the one axis the suites genuinely differ on:
    /// `doctor_rescue` needs them only to EXIST (its `found` predicate is
    /// `settings_path().exists()`), while `doctor_fix` needs their CONTENTS to
    /// be dead or healthy per test. Everything above that line is the same
    /// fixture, and it was written out twice before this helper existed —
    /// which is what plan 02 §5 meant by "the fixture §6 reuses".
    pub fn two_detected_agents(root: &std::path::Path) -> Vec<crate::hooks::DetectedAgent> {
        let claude_dir = root.join("claude");
        let cursor_dir = root.join("cursor");
        for dir in [&claude_dir, &cursor_dir] {
            std::fs::create_dir_all(dir).expect("fixture dirs");
        }
        vec![
            crate::hooks::DetectedAgent {
                kind: crate::hooks::AgentKind::ClaudeCode,
                binding: std::sync::Arc::new(
                    crate::hooks::bindings::claude_code::ClaudeCodeBinding {
                        settings_path: claude_dir.join("settings.json"),
                        claude_dir,
                    },
                ),
            },
            crate::hooks::DetectedAgent {
                // Deliberately reused: this unit adds no `AgentKind` variant.
                kind: crate::hooks::AgentKind::ClaudeCode,
                binding: std::sync::Arc::new(FakeBinding {
                    agent_type: "cursor",
                    display_name: "Cursor",
                    config_dir: cursor_dir,
                    ..Default::default()
                }),
            },
        ]
    }

    /// A settable stand-in for an agent that is not Claude Code.
    ///
    /// Six fields back eleven accessors. The five that are not fields answer
    /// Claude-shaped, because every test that drives this fake traverses shared
    /// code that now asks those questions of the binding — a blanket
    /// `unimplemented!()` would turn each assertion into a panic.
    pub struct FakeBinding {
        /// The wire value this fake keys to.
        pub agent_type: &'static str,
        /// The human label doctor and init render.
        pub display_name: &'static str,
        /// The config directory; `hook_config_path()` is `settings.json` in it.
        pub config_dir: PathBuf,
        /// Drives the liveness renderer through all three `armed` states.
        pub liveness: LivenessReport,
        /// `None` — the default — is an agent with no request plane at all.
        pub boundary_wiring: Option<BoundaryWiring>,
        /// Drives the daemon ownership guard through both answers.
        pub config_is_machine_global: bool,
    }

    impl Default for FakeBinding {
        fn default() -> Self {
            Self {
                agent_type: "fake",
                display_name: "Fake",
                // A deterministic path under the system temp directory rather
                // than a live `tempfile::TempDir`: the default holds no guard
                // to drop, and the path does not exist, so a fixture that does
                // not care about the config file reads as "not installed".
                config_dir: std::env::temp_dir().join("openlatch-fake-binding"),
                liveness: LivenessReport {
                    armed: None,
                    detail: None,
                    remedy: None,
                    code: None,
                },
                boundary_wiring: None,
                config_is_machine_global: false,
            }
        }
    }

    impl AgentBinding for FakeBinding {
        fn agent_type(&self) -> &'static str {
            self.agent_type
        }

        fn display_name(&self) -> &'static str {
            self.display_name
        }

        fn config_dir(&self) -> PathBuf {
            self.config_dir.clone()
        }

        fn hook_config_path(&self) -> PathBuf {
            self.config_dir.join("settings.json")
        }

        fn hook_event_types(&self) -> &'static [&'static str] {
            &super::super::bindings::claude_code::EVENT_TYPES
        }

        fn load_bearing_events(&self) -> &'static [&'static str] {
            &super::super::bindings::claude_code::LOAD_BEARING_EVENTS
        }

        fn daemon_channel(&self) -> DaemonChannel {
            DaemonChannel::EnvVars {
                token: crate::hooks::OPENLATCH_TOKEN_ENV,
                port: crate::hooks::OPENLATCH_PORT_ENV,
            }
        }

        fn liveness(&self) -> LivenessReport {
            self.liveness.clone()
        }

        fn build_hook_entry(
            &self,
            event: &str,
            binary: &Path,
            port: u16,
            marker: &OpenlatchMarker,
        ) -> Value {
            crate::hooks::claude_code::build_hook_entry(
                event,
                port,
                crate::hooks::OPENLATCH_TOKEN_ENV,
                binary,
                marker,
            )
        }

        fn config_is_machine_global(&self) -> bool {
            self.config_is_machine_global
        }

        fn capabilities(&self) -> BindingCapabilities {
            unimplemented!(
                "FakeBinding models no capability declaration — a test that reaches \
                 capabilities() is testing something this seam was not built for"
            )
        }

        fn boundary_wiring(&self) -> Option<BoundaryWiring> {
            self.boundary_wiring.clone()
        }
    }
}

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

    /// Restores `$HOME`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME` on unwind as
    /// well as on success, so a failing assertion cannot leak a redirected home
    /// into the next test in this binary.
    ///
    /// `CODEX_HOME` joined the trio when [`detect_all`] gained its Codex arm:
    /// a test asserting that a bare `$HOME` detects nothing now has two
    /// directories to keep out of the way, not one. Every taker holds
    /// `codex_cli::CONFIG_DIR_ENV_LOCK` as well, because that variable has its
    /// own lock and clearing it under only Claude's would race the suites that
    /// set it.
    struct HomeGuard {
        home: Option<std::ffi::OsString>,
        config_dir: Option<std::ffi::OsString>,
        codex_home: Option<std::ffi::OsString>,
    }

    impl HomeGuard {
        fn take() -> Self {
            let guard = Self {
                home: std::env::var_os("HOME"),
                config_dir: std::env::var_os(crate::hooks::claude_code::CONFIG_DIR_ENV),
                codex_home: std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV),
            };
            std::env::remove_var(crate::hooks::claude_code::CONFIG_DIR_ENV);
            std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV);
            guard
        }
    }

    impl Drop for HomeGuard {
        fn drop(&mut self) {
            for (key, value) in [
                ("HOME", &self.home),
                (crate::hooks::claude_code::CONFIG_DIR_ENV, &self.config_dir),
                (crate::hooks::codex_cli::CONFIG_DIR_ENV, &self.codex_home),
            ] {
                match value {
                    Some(v) => std::env::set_var(key, v),
                    None => std::env::remove_var(key),
                }
            }
        }
    }

    /// A host with no agent is not an error condition — it is an empty list.
    /// `detect_agent()` is the shim that turns emptiness into `OL-1400`; the
    /// primitive says nothing at all.
    #[test]
    #[cfg(unix)]
    fn detect_agents_returns_empty_without_an_agent() {
        // `CONFIG_DIR_ENV_LOCK` is the lock for `$CLAUDE_CONFIG_DIR` *and*
        // `$HOME`; its own doc names this exact pair of suites. `$CODEX_HOME`
        // has its own, taken last — the ordering rule for every test in this
        // binary that needs more than one env lock.
        let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _guard = HomeGuard::take();
        let empty = tempfile::tempdir().expect("temp dir");
        std::env::set_var("HOME", empty.path());

        assert!(
            detect_all().is_empty(),
            "no agent on this host means an empty Vec, never an error"
        );
    }

    /// Declaration order is detection order (D-04), and the shim takes the
    /// first: Claude Code, then whatever I-2 appends.
    #[test]
    fn detect_agent_shim_takes_the_first() {
        let _lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _guard = HomeGuard::take();
        let claude_dir = tempfile::tempdir().expect("temp dir");
        std::env::set_var(crate::hooks::claude_code::CONFIG_DIR_ENV, claude_dir.path());

        let all = detect_all();
        assert!(
            !all.is_empty(),
            "a relocated Claude dir exists, so it detects"
        );
        assert_eq!(all[0].kind, AgentKind::ClaudeCode, "Claude Code is first");

        let first = crate::hooks::detect_agent().expect("an agent was detected");
        assert_eq!(first.kind, all[0].kind);
        assert_eq!(first.agent_type(), all[0].agent_type());
        assert_eq!(first.agent_type(), "claude-code");
    }

    /// Declaration order **is** detection order, and it is load-bearing: the
    /// four singular callers of `detect_agent()` take the first, so Claude Code
    /// comes before Codex CLI and nothing else may be inserted ahead of it.
    ///
    /// Env-driven rather than `FakeBinding`-driven: `detect_all()` has no
    /// injection seam, and the fake is never constructed by it. Both temp
    /// directories must EXIST, because both resolvers stat before answering.
    ///
    /// Three locks, always in this order — Claude's `CONFIG_DIR_ENV_LOCK`,
    /// `identity::ENV_LOCK` (which is what guards `CLAUDE_CONFIG_DIR`'s
    /// `EnvGuard`, since that variable is in `identity::MANAGED`), then
    /// `codex_cli::CONFIG_DIR_ENV_LOCK` last. `CODEX_HOME` is not managed by
    /// `EnvGuard`, so this test saves and restores it itself, before asserting,
    /// so a failed assertion cannot leak it.
    #[test]
    fn detect_all_orders_claude_before_codex() {
        let _claude_lock = crate::hooks::claude_code::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());
        let _identity_lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let _codex_lock = crate::hooks::codex_cli::CONFIG_DIR_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner());

        let claude_dir = tempfile::tempdir().expect("temp dir");
        let codex_dir = tempfile::tempdir().expect("temp dir");
        env.set("CLAUDE_CONFIG_DIR", claude_dir.path());

        let previous_codex = std::env::var_os(crate::hooks::codex_cli::CONFIG_DIR_ENV);
        std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, codex_dir.path());

        let kinds: Vec<AgentKind> = detect_all().iter().map(|a| a.kind).collect();

        match previous_codex {
            Some(v) => std::env::set_var(crate::hooks::codex_cli::CONFIG_DIR_ENV, v),
            None => std::env::remove_var(crate::hooks::codex_cli::CONFIG_DIR_ENV),
        }

        assert_eq!(
            kinds,
            vec![AgentKind::ClaudeCode, AgentKind::CodexCli],
            "declaration order is detection order, and the singular callers take the first"
        );
    }

    /// `OL-1400` used to name Claude Code and only Claude Code, with a
    /// `claude.ai/download` link — the regression a host that could have run a
    /// different agent would hit. The remedy is built from the detectable list,
    /// so I-2 appending a binding appends its name here in the same edit.
    #[test]
    fn agent_not_found_remedy_names_every_detectable_agent() {
        let err = super::super::agent_not_found_err();
        assert_eq!(err.code, crate::error::ERR_HOOK_AGENT_NOT_FOUND);
        let suggestion = err.suggestion.expect("OL-1400 carries a suggestion");
        for name in DETECTABLE_AGENT_NAMES {
            assert!(
                suggestion.contains(name),
                "the remedy must name every detectable agent; {name} is missing from {suggestion:?}"
            );
        }
        assert!(
            !suggestion.contains("claude.ai/download"),
            "one download URL cannot serve a list of agents: {suggestion:?}"
        );
    }

    #[test]
    fn agent_binding_is_object_safe() {
        fn _assert_object_safe(_: &dyn AgentBinding) {}
    }

    #[test]
    fn agent_binding_is_send_sync() {
        fn _assert_send_sync<T: Send + Sync>() {}
        _assert_send_sync::<Arc<dyn AgentBinding>>();
    }
}