openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
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
//! `git config user.email` for a session's working directory (I-1 D-06).
//!
//! # Two spawns, and why one is not enough
//!
//! `git config --get user.email` exits **0** outside a repository, and again
//! under dubious-ownership refusal, returning the machine's *global* email
//! either time. A single-spawn resolver therefore reports a confident, wrong
//! answer for every session that runs outside a repo — silently, and with no
//! signal that it happened. The `rev-parse --git-dir` gate in front of it is
//! what makes "absent outside a repository" true, and it collapses three
//! separate failure modes (no repo, dubious ownership, vanished `cwd`) into the
//! one branch below: any non-zero exit means absent.
//!
//! Inside a healthy repository the second spawn is a plain `--get`, with no
//! `--local` / `--global` flags, so *git* resolves its own configuration stack:
//! repo-local wins, global is the fallback, and `includeIf` keeps working. A
//! two-step re-implementation here would diverge from git the first time
//! somebody uses a conditional include.
//!
//! # Hardening
//!
//! Every spawn runs against an absolute path (never the bare name `git`, which
//! a user-writable PATH entry can hijack — real machine PATHs do carry those
//! ahead of `Program Files`), with `stdin` closed so git can never open a pager
//! or prompt, and with `GIT_CONFIG*` / `GIT_DIR` / `GIT_WORK_TREE` removed from
//! the child's environment. That last one is not hypothetical:
//! `GIT_CONFIG_COUNT` + `GIT_CONFIG_KEY_0=user.email` overrides repository
//! configuration outright, so an inherited environment could dictate the
//! attribution this module reports.
//!
//! `GIT_TEST_ASSUME_DIFFERENT_OWNER` is deliberately **not** stripped: it is how
//! the dubious-ownership path is exercised without a second user account, and
//! stripping it would only hide the case the gate exists for.
//!
//! # Nothing here is loud
//!
//! Every failure — no git, no repo, no configured email, a hung child — is the
//! same outcome: no `gitemail` attribute. There is no `Result`, no `OL-` code
//! (D-12) and no log line carrying git's output: a `git config` error message
//! can embed a path, a username or a config value, and this module's prime
//! invariant is that none of that reaches an observable surface.

use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::OnceLock;
use std::time::Duration;

use tokio::io::AsyncReadExt;

/// Test seam (D-11): an absolute path to a git binary, trusted as given.
///
/// Checked before the platform ladder and, unlike it, **not** memoised — see
/// [`git_bin`].
const TEST_SEAM_ENV: &str = "OPENLATCH_TEST_GIT_BIN";

/// Wall-clock ceiling for a single spawn.
///
/// Generous for a local `git config` (tens of milliseconds) and short enough
/// that a wedged git — a network filesystem stall, a credential helper waiting
/// on something — cannot hold a resolution task for longer than a session's
/// first few events. Nothing is waiting on this task, so the ceiling protects
/// the task pool, not the event path.
const SPAWN_TIMEOUT: Duration = Duration::from_secs(2);

/// Ceiling on how much of a child's stdout is read.
///
/// The two commands this module runs answer with a `.git` path and an email
/// address. 4 KiB is orders of magnitude past either, and past it the output is
/// not the answer we asked for.
const MAX_STDOUT_BYTES: u64 = 4096;

/// The platform ladder's answer, resolved on first use and kept for the
/// process. The git installation cannot move under a running daemon, and the
/// PATH walk is the kind of work that should happen once rather than per
/// session.
static GIT_BIN: OnceLock<Option<PathBuf>> = OnceLock::new();

/// The git email for `cwd`, or `None` when there is no repository-scoped answer.
///
/// `cwd` is the working directory the session's first event reported. Without
/// one there is nothing to ask git about, and the answer is absent — the
/// session is not re-armed to wait for a later event that carries a `cwd`,
/// because Claude Code payloads carry one and a cache that re-arms is a state
/// machine built for a case that does not occur.
pub async fn resolve(cwd: Option<&str>) -> Option<String> {
    let cwd = cwd?;
    let git = git_bin()?;

    // The gate. Any non-zero exit — outside a repository, dubious ownership, a
    // `cwd` that no longer exists — ends the resolution here.
    run_git(&git, cwd, &["rev-parse", "--git-dir"]).await?;

    // Exit 1 means the key is unset, which is absent like everything else.
    let email = run_git(&git, cwd, &["config", "--get", "user.email"]).await?;
    let email = email.trim();
    if email.is_empty() {
        None
    } else {
        Some(email.to_owned())
    }
}

/// The git binary to spawn, seam first.
///
/// The seam is read on every resolution rather than folded into [`GIT_BIN`],
/// for one practical reason: a memoised seam freezes whatever the first test in
/// a binary happened to set, which is the same trap `os_user` documents. The
/// cost is one environment lookup per session — not per event — and the
/// platform ladder underneath it stays memoised.
///
/// Pointing this variable at an arbitrary executable is not a privilege
/// boundary: whoever can set the daemon's environment can already run code as
/// the daemon's user.
fn git_bin() -> Option<PathBuf> {
    if let Some(seam) = std::env::var_os(TEST_SEAM_ENV).filter(|v| !v.is_empty()) {
        return Some(PathBuf::from(seam));
    }
    GIT_BIN.get_or_init(discover_platform).clone()
}

/// The uncached platform ladder: Windows registry, then PATH.
///
/// Separate from [`git_bin`] so tests can exercise it without freezing the
/// memo for the rest of the binary.
pub(super) fn discover_platform() -> Option<PathBuf> {
    #[cfg(windows)]
    if let Some(installed) = git_for_windows() {
        return Some(installed);
    }

    let found = path_walk();
    if found.is_none() {
        tracing::debug!(
            target: "identity",
            reason = "no_git_binary",
            "gitemail is unavailable for this daemon run"
        );
    }
    found
}

/// First existing candidate on an **absolute** `PATH` entry — a relative `.`
/// entry can never select a cwd-local shim, which is what the module header's
/// absolute-path guarantee means.
fn path_walk() -> Option<PathBuf> {
    let name = if cfg!(windows) { "git.exe" } else { "git" };
    let path = std::env::var_os("PATH")?;
    std::env::split_paths(&path)
        .filter(|dir| dir.is_absolute())
        .map(|dir| dir.join(name))
        .find(|candidate| candidate.is_file())
}

/// `HKLM\SOFTWARE\GitForWindows\InstallPath` + `\cmd\git.exe`.
///
/// Ahead of the PATH walk because a machine PATH can — and on real hosts does —
/// carry a stale user-writable directory in front of the real installation.
/// `HKLM` is writable only by administrators, which is the entire reason it is
/// consulted first.
///
/// Read through `windows-sys`' `RegGetValueW` rather than the `winreg` crate:
/// `windows-sys` is already in the tree for `GetUserNameW`, and one value read
/// does not justify a dependency.
#[cfg(windows)]
fn git_for_windows() -> Option<PathBuf> {
    use std::ffi::{OsStr, OsString};
    use std::os::windows::ffi::{OsStrExt, OsStringExt};
    use windows_sys::Win32::Foundation::ERROR_SUCCESS;
    use windows_sys::Win32::System::Registry::{RegGetValueW, HKEY_LOCAL_MACHINE, RRF_RT_REG_SZ};

    /// A registry string longer than this is not an install path; refusing to
    /// allocate for it keeps a corrupt value from turning into a large read.
    const MAX_VALUE_BYTES: u32 = 64 * 1024;

    fn wide(s: &str) -> Vec<u16> {
        OsStr::new(s)
            .encode_wide()
            .chain(std::iter::once(0))
            .collect()
    }

    let subkey = wide("SOFTWARE\\GitForWindows");
    let value = wide("InstallPath");

    // First call sizes the value (in bytes, including the terminating NUL).
    let mut bytes: u32 = 0;
    // SAFETY: both name pointers are NUL-terminated UTF-16 buffers alive for
    // the call, and a null data pointer with a live size out-parameter is the
    // documented "tell me how big it is" form.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            &mut bytes,
        )
    };
    if rc != ERROR_SUCCESS || bytes == 0 || bytes > MAX_VALUE_BYTES {
        return None;
    }

    let mut buf = vec![0u16; bytes as usize / 2 + 1];
    let mut written = bytes;
    // SAFETY: `buf` is at least `written` bytes of writable UTF-16 storage and
    // `written` is a live u32 out-parameter, which is the contract for the
    // second call.
    let rc = unsafe {
        RegGetValueW(
            HKEY_LOCAL_MACHINE,
            subkey.as_ptr(),
            value.as_ptr(),
            RRF_RT_REG_SZ,
            std::ptr::null_mut(),
            buf.as_mut_ptr().cast(),
            &mut written,
        )
    };
    if rc != ERROR_SUCCESS {
        return None;
    }

    // `written` counts the terminating NUL, which is not part of the path.
    let len = (written as usize / 2).saturating_sub(1).min(buf.len());
    let install = PathBuf::from(OsString::from_wide(&buf[..len]));
    let candidate = install.join("cmd").join("git.exe");
    candidate.is_file().then_some(candidate)
}

/// One hardened `git -C <cwd> <args>`, returning stdout on a zero exit.
///
/// Absence is the answer for every other outcome, and the debug line that
/// records it carries a fixed reason word and nothing else — never git's
/// stdout, stderr, or the directory it ran in.
///
/// The read is capped at [`MAX_STDOUT_BYTES`]: everything this module parses is
/// one line, so a git that decides to print more than that has already stopped
/// being the command we asked for, and there is no reason for this task to hold
/// the allocation. Closing the pipe at the cap rather than after the wait lets a
/// still-writing child fail fast instead of sitting out the whole ceiling.
///
/// `stderr` is discarded at the OS level rather than piped. This module may
/// never log git's error text — that is the invariant the header opens with —
/// so a pipe nobody drains would buy nothing and risk a child blocking on a
/// full buffer until the timeout fires. A null handle keeps the same guarantee
/// piping gave: no inherited TTY, so no pager.
async fn run_git(git: &Path, cwd: &str, args: &[&str]) -> Option<String> {
    let mut cmd = tokio::process::Command::new(git);
    cmd.arg("-C")
        .arg(cwd)
        .args(args)
        // A closed stdin is what guarantees git never becomes interactive.
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true);
    strip_git_env(&mut cmd);

    let Ok(mut child) = cmd.spawn() else {
        debug_absent("spawn_failed");
        return None;
    };

    // `stdout` was piped a few lines up, so this take always yields it.
    let Some(stdout) = child.stdout.take() else {
        debug_absent("io_error");
        return None;
    };

    // The timeout arm cannot kill or reap the child explicitly — `kill_on_drop`
    // is the mechanism. Dropping this future leaves `child` to be dropped at
    // the end of the function, which signals it and lets tokio reap in the
    // background.
    let collect = async {
        let mut buf = Vec::new();
        {
            let mut capped = stdout.take(MAX_STDOUT_BYTES);
            capped.read_to_end(&mut buf).await?;
        }
        let status = child.wait().await?;
        Ok::<_, std::io::Error>((status, buf))
    };

    let Ok(collected) = tokio::time::timeout(SPAWN_TIMEOUT, collect).await else {
        debug_absent("timeout");
        return None;
    };
    let Ok((status, stdout)) = collected else {
        debug_absent("io_error");
        return None;
    };
    if !status.success() {
        debug_absent("nonzero_exit");
        return None;
    }
    String::from_utf8(stdout).ok()
}

/// Remove every environment variable that can redirect git's configuration.
///
/// `GIT_CONFIG*` is matched by prefix rather than by name because the injection
/// vector is a family — `GIT_CONFIG_COUNT` plus `GIT_CONFIG_KEY_n` /
/// `GIT_CONFIG_VALUE_n` pairs — alongside `GIT_CONFIG_GLOBAL`, `_SYSTEM` and
/// `_NOSYSTEM`. The comparison is case-insensitive because Windows environment
/// names are.
fn strip_git_env(cmd: &mut tokio::process::Command) {
    cmd.env_remove("GIT_DIR").env_remove("GIT_WORK_TREE");
    for (key, _) in std::env::vars_os() {
        if key
            .to_string_lossy()
            .to_ascii_uppercase()
            .starts_with("GIT_CONFIG")
        {
            cmd.env_remove(&key);
        }
    }
}

fn debug_absent(reason: &'static str) {
    tracing::debug!(target: "identity", reason, "gitemail not resolved");
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::identity::test_support::{git_ok, write_shim, EnvGuard};

    /// A repository with `user.email` set locally, unless `email` is `None`.
    fn repo(git: &Path, email: Option<&str>) -> tempfile::TempDir {
        let dir = tempfile::tempdir().expect("tempdir");
        assert!(git_ok(git, dir.path(), &["init", "-q"]), "git init");
        if let Some(email) = email {
            assert!(
                git_ok(git, dir.path(), &["config", "user.email", email]),
                "git config user.email"
            );
        }
        dir
    }

    /// The host's global `user.email`, if it has one.
    fn global_email(git: &Path) -> Option<String> {
        let out = std::process::Command::new(git)
            .args(["config", "--global", "--get", "user.email"])
            .stdin(Stdio::null())
            .output()
            .ok()?;
        if !out.status.success() {
            return None;
        }
        let value = String::from_utf8(out.stdout).ok()?.trim().to_owned();
        (!value.is_empty()).then_some(value)
    }

    /// The whole gate matrix in one function.
    ///
    /// Serialized against the other identity tests through
    /// [`super::super::ENV_LOCK`] because the cases below mutate process-global
    /// environment variables that the resolvers read.
    #[tokio::test]
    async fn the_gate_decides_whether_there_is_an_email_at_all() {
        let _lock = crate::daemon::identity::ENV_LOCK.lock().await;
        let env = EnvGuard::clear();
        // Every case below needs a real git. A host without one is a legitimate
        // configuration — it is the `None` this module is designed to return —
        // so the test skips rather than fails.
        let Some(git) = discover_platform() else {
            return;
        };

        // 1. A healthy repository with a repo-local address.
        let local = repo(&git, Some("local@fixture.test"));
        assert_eq!(
            resolve(local.path().to_str()).await.as_deref(),
            Some("local@fixture.test"),
            "the repo-local address is the whole point of resolving per-cwd"
        );

        // 2. Local unset inside a healthy repository: git's own stack answers,
        //    which on a configured host means the global address. This is what
        //    rules out the rejected `--local`-only shape — that one would
        //    return `None` here.
        let no_local = repo(&git, None);
        if let Some(global) = global_email(&git) {
            assert_eq!(
                resolve(no_local.path().to_str()).await.as_deref(),
                Some(global.as_str()),
                "with no repo-local value git falls back to the global one"
            );
        } else {
            assert_eq!(
                resolve(no_local.path().to_str()).await,
                None,
                "no local and no global address is an absent attribute"
            );
        }

        // 3. Outside a repository. The gate is the only reason this is `None`:
        //    the `config` spawn on its own would have exited 0 with the global
        //    address.
        let bare = tempfile::tempdir().expect("tempdir");
        assert_eq!(resolve(bare.path().to_str()).await, None);

        // 4. No cwd on the event at all.
        assert_eq!(resolve(None).await, None);

        // 5. A cwd that does not exist.
        let vanished = bare.path().join("gone");
        assert_eq!(resolve(vanished.to_str()).await, None);

        // 6. Dubious ownership, simulated without a second account. Git only
        //    honors this variable when it was built with the test switches
        //    compiled in, so the assertion is made against what the gate
        //    actually does on this host rather than against an assumption
        //    about it — when git ignores the variable there is nothing to
        //    prove, and a hard assertion would be a flake on someone's runner.
        env.set("GIT_TEST_ASSUME_DIFFERENT_OWNER", "1");
        let gate_refuses = !git_ok(&git, local.path(), &["rev-parse", "--git-dir"]);
        if gate_refuses {
            assert_eq!(
                resolve(local.path().to_str()).await,
                None,
                "a refused gate must suppress the email, silently"
            );
        }
        env.unset("GIT_TEST_ASSUME_DIFFERENT_OWNER");

        // 7. Environment injection. `GIT_CONFIG_COUNT` + a `KEY_0`/`VALUE_0`
        //    pair overrides repository configuration in an inheriting child, so
        //    an unsanitized resolver would report `evil@injected.test` for a
        //    repository that says otherwise.
        env.set("GIT_CONFIG_COUNT", "1");
        env.set("GIT_CONFIG_KEY_0", "user.email");
        env.set("GIT_CONFIG_VALUE_0", "evil@injected.test");
        assert_eq!(
            resolve(local.path().to_str()).await.as_deref(),
            Some("local@fixture.test"),
            "the child environment must not be able to dictate attribution"
        );
    }

    /// A git that never returns must not hold the resolution open.
    ///
    /// Bounded on **both** sides. The upper bound is the ceiling doing its job;
    /// the lower bound is what keeps the test honest — a shim the platform
    /// refused to launch would return `None` in microseconds and satisfy an
    /// upper-bound-only assertion without ever exercising the timeout.
    #[tokio::test]
    async fn a_hung_git_times_out_instead_of_hanging() {
        let _lock = crate::daemon::identity::ENV_LOCK.lock().await;
        let dir = tempfile::tempdir().expect("tempdir");
        let shim = sleep_shim(dir.path());

        // Straight at `run_git`, the layer the ceiling lives in: reaching it
        // through `resolve` would mean going via `git_bin` and the seam, which
        // is a different test's subject.
        let started = std::time::Instant::now();
        let gated = run_git(
            &shim,
            dir.path().to_str().expect("utf-8 path"),
            &["rev-parse", "--git-dir"],
        )
        .await;
        let elapsed = started.elapsed();

        assert_eq!(gated, None, "a timed-out spawn is an absent attribute");
        assert!(
            elapsed >= SPAWN_TIMEOUT,
            "returning before the ceiling means the shim never ran — the timeout was not tested"
        );
        assert!(
            elapsed < Duration::from_secs(5),
            "the 2s ceiling must fire long before the child's own 5s sleep"
        );
    }

    /// A script that sleeps well past [`SPAWN_TIMEOUT`].
    fn sleep_shim(dir: &Path) -> PathBuf {
        #[cfg(windows)]
        let (name, body) = ("slow-git.cmd", "@echo off\r\nping -n 6 127.0.0.1 >nul\r\n");
        #[cfg(unix)]
        let (name, body) = ("slow-git.sh", "#!/bin/sh\nsleep 5\n");

        write_shim(dir, name, body)
    }

    /// Discovery order, exercised against the uncached ladder.
    #[test]
    fn the_seam_outranks_the_platform_ladder() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = EnvGuard::clear();

        let seam = if cfg!(windows) {
            "C:\\fixture\\seam-git.exe"
        } else {
            "/fixture/seam-git"
        };
        env.set(TEST_SEAM_ENV, seam);
        assert_eq!(
            git_bin(),
            Some(PathBuf::from(seam)),
            "the seam is trusted as given — it is not required to exist"
        );

        // An empty value is not a path; the ladder continues past it.
        env.set(TEST_SEAM_ENV, "");
        assert_ne!(git_bin(), Some(PathBuf::new()));
        env.unset(TEST_SEAM_ENV);

        // Whatever the platform ladder finds is a real file — a candidate that
        // does not exist would fail at spawn time, one session at a time.
        if let Some(found) = discover_platform() {
            assert!(found.is_file(), "the ladder only returns existing binaries");
        }
    }
}