openlatch-client 0.6.5

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
//! Cursor path resolution and detection.
//!
//! The sibling of [`crate::hooks::codex_cli`], and deliberately the same shape:
//! this module owns the Cursor root for **every** caller — the binding, the
//! config-monitor manifest and the isolation fixtures. Nothing else in the crate
//! computes `~/.cursor`.
//!
//! Cursor has no upstream variable that relocates its config directory, so the
//! seam is ours: [`CONFIG_DIR_ENV`]. It exists before detection is armed, or
//! the first test that installs "every detected agent" writes the real
//! `~/.cursor` of the machine running it.

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

/// Relocates Cursor's configuration root (`~/.cursor`).
///
/// OpenLatch's own seam, not Cursor's: `olbox` exports it so a sandboxed
/// instance never reads or writes the developer's real `~/.cursor`, and a
/// Cursor launched in that sandbox is pointed at the same root through its
/// sandboxed `HOME`.
///
/// **Its lock is `cline::SEAM_ENV_LOCK`, not one of its own.** The shared absent-agent
/// fixture ([`crate::hooks::cline::absent_seams`]) writes this variable beside the three
/// Cline seams under that lock, so a second lock guarding the same variable would let a
/// resolver test and an absent-seam fixture write it at once.
pub const CONFIG_DIR_ENV: &str = "OPENLATCH_CURSOR_DIR";

/// Relocates the Cursor IDE's user-data directory — the one a Cursor launched
/// with `--user-data-dir <dir>` reads `User/settings.json` from (Cursor I-2 D-09).
///
/// OpenLatch's own seam, like [`CONFIG_DIR_ENV`]: `olbox` derives it from the
/// sandbox so an isolated instance never names the developer's real editor
/// settings. Its lock is the same one, `cline::SEAM_ENV_LOCK`. An empty value
/// reads as unset.
pub(crate) const IDE_DIR_ENV: &str = "OPENLATCH_CURSOR_IDE_DIR";

/// The root's name under the home directory, when no seam relocates it.
const DEFAULT_DIR_NAME: &str = ".cursor";

/// `$OPENLATCH_CURSOR_DIR` when set to something non-empty.
///
/// An empty value reads as unset, as `CODEX_HOME` does: an exported-but-blank
/// variable would otherwise resolve every path below it against the process
/// cwd.
fn relocated_dir() -> Option<PathBuf> {
    std::env::var_os(CONFIG_DIR_ENV)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// The machine's own Cursor root, `~/.cursor`, whatever the seam says.
fn default_root() -> Option<PathBuf> {
    dirs::home_dir().map(|home| home.join(DEFAULT_DIR_NAME))
}

/// THE Cursor root, whether or not it exists: `$OPENLATCH_CURSOR_DIR` when set
/// and non-empty, else `~/.cursor` (`%USERPROFILE%\.cursor` on Windows).
///
/// Split from [`detect`] for the reason its Codex counterpart is: `detect`
/// answers "is Cursor installed", while the config-monitor manifest needs the
/// path regardless.
pub fn root() -> Option<PathBuf> {
    relocated_dir().or_else(default_root)
}

/// The file Cursor reads user-level hook registrations from.
pub fn hooks_json_path(root: &Path) -> PathBuf {
    root.join("hooks.json")
}

/// Is the Cursor root this process would write the **machine-global** one?
///
/// The same contract as [`crate::hooks::codex_cli::config_is_machine_global`]:
/// paths are canonicalized, so a seam pointed at the real `~/.cursor` through a
/// symlink or a trailing slash still reads as machine-global, and anything we
/// cannot resolve answers `true` — declining to write is the safe direction.
pub fn config_is_machine_global() -> bool {
    let (Some(resolved), Some(default)) = (root(), default_root()) else {
        return true;
    };
    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    canonical(&resolved) == canonical(&default)
}

/// Cursor IDE's user `settings.json` — the one place this path is computed.
///
/// macOS `~/Library/Application Support/Cursor/User/settings.json`, Windows
/// `%APPDATA%\Cursor\User\settings.json`, Linux `~/.config/Cursor/User/settings.json`;
/// [`IDE_DIR_ENV`], when set, replaces the per-OS `…/Cursor` base.
///
/// `None` unless this process owns the machine install, as
/// `bindings::cline::vscode_user_settings` is: `http.proxy` there routes every
/// request the editor makes, and on Windows `dirs::config_dir()` ignores every
/// environment seam — an isolated instance never writes the real editor's
/// settings.
pub fn ide_user_settings() -> Option<PathBuf> {
    ide_user_settings_in(
        relocated_ide_dir(),
        dirs::config_dir(),
        crate::supervision::owns_machine_supervision(),
    )
}

/// `$OPENLATCH_CURSOR_IDE_DIR` when set to something non-empty.
fn relocated_ide_dir() -> Option<PathBuf> {
    std::env::var_os(IDE_DIR_ENV)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// [`ide_user_settings`] with its inputs as parameters, so both branches are
/// tested on every OS.
pub(crate) fn ide_user_settings_in(
    relocated: Option<PathBuf>,
    config_dir: Option<PathBuf>,
    owns_machine: bool,
) -> Option<PathBuf> {
    if !owns_machine {
        return None;
    }
    let base = relocated.or_else(|| Some(config_dir?.join("Cursor")))?;
    Some(base.join("User").join("settings.json"))
}

/// A Cursor seam pointed at a path under `root` that is **never created** —
/// Cursor absent, for a fixture that must not see the developer's own.
///
/// Folded into [`crate::hooks::cline::absent_seams`], which is the one
/// definition every fixture applies, so a fixture isolating Cline isolates
/// Cursor by the same line. Plain `pub` for the reason that helper is: an
/// integration target under `tests/` cannot see a `#[cfg(test)]` item, and
/// `pub mod hooks` is behind `full-cli`, so the lean hook never links it.
pub fn absent_seam(root: &Path) -> (&'static str, PathBuf) {
    (CONFIG_DIR_ENV, root.join("absent-cursor"))
}

/// Detect whether Cursor is installed.
///
/// Returns the root when the IDE **or** the CLI is installed. A leftover
/// `~/.cursor` alone is not a detection: it outlives an uninstalled Cursor, and
/// wiring hooks for an agent that is gone is a file nobody asked for.
///
/// Under the isolation seam the root existing counts instead: a sandbox has no
/// application of its own, and the seam is the operator saying where Cursor
/// lives.
pub fn detect() -> Option<PathBuf> {
    detect_in(
        relocated_dir(),
        dirs::home_dir().as_deref(),
        std::env::var_os("PATH").as_deref(),
    )
}

/// [`detect`] with its inputs as parameters, so a test can hand it a temporary
/// home and `PATH` without redirecting either for the whole test binary.
fn detect_in(
    relocated: Option<PathBuf>,
    home: Option<&Path>,
    path_var: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
    if let Some(root) = relocated {
        return root.is_dir().then_some(root);
    }
    let home = home?;
    (ide_installed(home, path_var) || cli_installed(home, path_var))
        .then(|| home.join(DEFAULT_DIR_NAME))
}

/// One place a Cursor IDE install may sit.
pub(crate) struct IdeLocation {
    /// What exists when the IDE is installed there.
    pub path: PathBuf,
    /// The `package.json` naming the product and its version, when `path` is
    /// an application bundle or directory — `None` for a launcher entry (a
    /// Linux `.desktop` file), which proves an install but names nothing.
    pub package_json: Option<PathBuf>,
}

/// Every place this platform's Cursor IDE installer puts the application.
///
/// Every probe of these is a `Path::exists` or a file read. **Never run a
/// Cursor binary**: on macOS the application binary launches the full GUI,
/// with the caller's `HOME`, even for `--version`.
pub(crate) fn ide_locations(home: &Path) -> Vec<IdeLocation> {
    let app = |dir: PathBuf| dir.join("resources").join("app").join("package.json");
    let mut locations: Vec<IdeLocation> = Vec::new();
    if cfg!(target_os = "macos") {
        for bundle in [
            PathBuf::from("/Applications").join("Cursor.app"), // portability-ok: macOS bundle root, reached only when cfg!(target_os = "macos")
            home.join("Applications").join("Cursor.app"),
        ] {
            let package_json = bundle
                .join("Contents")
                .join("Resources")
                .join("app")
                .join("package.json");
            locations.push(IdeLocation {
                path: bundle,
                package_json: Some(package_json),
            });
        }
    }
    if cfg!(windows) {
        let mut dirs_: Vec<PathBuf> = Vec::new();
        if let Some(local) = dirs::data_local_dir() {
            dirs_.push(local.join("Programs").join("cursor"));
        }
        if let Some(program_files) = std::env::var_os("ProgramFiles") {
            dirs_.push(PathBuf::from(program_files).join("cursor"));
        }
        for dir in dirs_ {
            locations.push(IdeLocation {
                path: dir.join("Cursor.exe"),
                package_json: Some(app(dir)),
            });
        }
    }
    if cfg!(target_os = "linux") {
        for dir in [
            PathBuf::from("/usr/share/cursor"), // portability-ok: Linux package root, reached only when cfg!(target_os = "linux")
            PathBuf::from("/opt/Cursor"), // portability-ok: Linux AppImage root, reached only when cfg!(target_os = "linux")
        ] {
            locations.push(IdeLocation {
                package_json: Some(app(dir.clone())),
                path: dir,
            });
        }
        for entry in [
            PathBuf::from("/usr/share/applications").join("cursor.desktop"), // portability-ok: Linux desktop entry, reached only when cfg!(target_os = "linux")
            home.join(".local")
                .join("share")
                .join("applications")
                .join("cursor.desktop"),
        ] {
            locations.push(IdeLocation {
                path: entry,
                package_json: None,
            });
        }
    }
    locations
}

/// Is the IDE's launcher on `PATH`? Only Linux installs one this probe trusts.
pub(crate) fn ide_on_path(path_var: Option<&std::ffi::OsStr>) -> bool {
    cfg!(target_os = "linux") && on_path(path_var, "cursor")
}

/// Is the Cursor IDE installed?
fn ide_installed(home: &Path, path_var: Option<&std::ffi::OsStr>) -> bool {
    ide_locations(home).iter().any(|l| l.path.exists()) || ide_on_path(path_var)
}

/// The directories the Cursor CLI installer puts `cursor-agent` in; each keeps
/// one directory per installed version under `versions/`.
pub(crate) fn cli_install_dirs(home: &Path) -> Vec<PathBuf> {
    let mut dirs_ = vec![home.join(".local").join("share").join("cursor-agent")];
    if cfg!(windows) {
        if let Some(local) = dirs::data_local_dir() {
            dirs_.push(local.join("cursor-agent"));
        }
    }
    dirs_
}

/// Is `cursor-agent` on `PATH`?
pub(crate) fn cli_on_path(path_var: Option<&std::ffi::OsStr>) -> bool {
    on_path(path_var, "cursor-agent")
}

/// Is the Cursor CLI (`cursor-agent`) installed?
///
/// Found by its own name or its install directory — **never by the bare name
/// `agent`**, which the CLI also installs and which is generic enough to
/// belong to any other tool on the host.
fn cli_installed(home: &Path, path_var: Option<&std::ffi::OsStr>) -> bool {
    cli_on_path(path_var) || cli_install_dirs(home).iter().any(|path| path.exists())
}

/// Where an administrator puts the machine-wide (enterprise) Cursor hook file:
/// `/Library/Application Support/Cursor` on macOS, `/etc/cursor` on Linux,
/// `%ProgramData%\Cursor` on Windows. `None` on another OS, or on Windows with
/// no `ProgramData`.
///
/// The one definition: the config-monitor resolver `${CURSOR_ENTERPRISE_DIR}`
/// and `doctor`'s `other_hook_sources` both read it.
pub fn enterprise_dir() -> Option<PathBuf> {
    if cfg!(target_os = "macos") {
        return Some(
            PathBuf::from("/Library") // portability-ok: macOS system root, reached only when cfg!(target_os = "macos")
                .join("Application Support")
                .join("Cursor"),
        );
    }
    if cfg!(target_os = "linux") {
        return Some(PathBuf::from("/etc").join("cursor")); // portability-ok: Linux system config root, reached only when cfg!(target_os = "linux")
    }
    if cfg!(windows) {
        return std::env::var_os("ProgramData")
            .filter(|v| !v.is_empty())
            .map(|v| PathBuf::from(v).join("Cursor"));
    }
    None
}

/// An executable named `name` on an **absolute** entry of `path_var`.
///
/// A relative entry is skipped for the reason `identity::git_email` skips it:
/// it would resolve against whatever cwd the process has. On Windows the
/// launcher shims carry an extension, so each of the usual ones is tried.
fn on_path(path_var: Option<&std::ffi::OsStr>, name: &str) -> bool {
    let Some(path_var) = path_var else {
        return false;
    };
    let names: Vec<String> = if cfg!(windows) {
        ["exe", "cmd", "ps1"]
            .iter()
            .map(|ext| format!("{name}.{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    };
    std::env::split_paths(path_var)
        .filter(|dir| dir.is_absolute())
        .any(|dir| names.iter().any(|n| dir.join(n).is_file()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::cline::EnvOverride;

    /// Every test here writes [`CONFIG_DIR_ENV`], and that variable's lock is
    /// the Cline seam lock (see its doc).
    fn seam_lock() -> std::sync::MutexGuard<'static, ()> {
        crate::hooks::cline::SEAM_ENV_LOCK
            .lock()
            .unwrap_or_else(|e| e.into_inner())
    }

    /// D-09: the seam replaces the per-OS base, and neither answers anything
    /// for a process that does not own the machine install.
    #[test]
    fn ide_user_settings_honours_the_seam_and_the_machine_gate() {
        let seam = PathBuf::from("/sandbox/cursor-user-data");
        let config = PathBuf::from("/home/dev/.config");
        let settings = |base: &Path| base.join("User").join("settings.json");

        assert_eq!(
            ide_user_settings_in(None, Some(config.clone()), true),
            Some(settings(&config.join("Cursor")))
        );
        assert_eq!(
            ide_user_settings_in(Some(seam.clone()), Some(config.clone()), true),
            Some(settings(&seam)),
            "the seam replaces the per-OS base"
        );
        assert_eq!(
            ide_user_settings_in(Some(seam), Some(config.clone()), false),
            None
        );
        assert_eq!(ide_user_settings_in(None, Some(config), false), None);
        assert_eq!(ide_user_settings_in(None, None, true), None);

        // The resolver reads the seam, and an empty value reads as unset. (The
        // machine gate is not re-read here: it follows `OPENLATCH_DIR` and
        // `CLAUDE_CONFIG_DIR`, which sibling tests move under other locks.)
        let _lock = seam_lock();
        let dir = tempfile::tempdir().expect("temp dir");
        {
            let _env = EnvOverride::apply([(IDE_DIR_ENV, Some(dir.path().as_os_str().to_owned()))]);
            assert_eq!(relocated_ide_dir().as_deref(), Some(dir.path()));
        }
        let _env = EnvOverride::apply([(IDE_DIR_ENV, Some(std::ffi::OsString::new()))]);
        assert_eq!(relocated_ide_dir(), None);
    }

    #[test]
    fn cursor_root_honours_the_seam() {
        let _lock = seam_lock();
        let dir = tempfile::tempdir().expect("temp dir");
        let _env = EnvOverride::apply([(CONFIG_DIR_ENV, Some(dir.path().as_os_str().to_owned()))]);

        assert_eq!(root().as_deref(), Some(dir.path()));
        assert_eq!(
            hooks_json_path(&root().expect("root")),
            dir.path().join("hooks.json")
        );
        assert!(
            !config_is_machine_global(),
            "a relocated root is not the machine's own"
        );
        assert_eq!(
            detect().as_deref(),
            Some(dir.path()),
            "under the seam, the root existing counts"
        );
    }

    #[test]
    fn cursor_root_empty_seam_is_unset() {
        let _lock = seam_lock();
        let _env = EnvOverride::apply([(CONFIG_DIR_ENV, Some(std::ffi::OsString::new()))]);

        assert_eq!(relocated_dir(), None, "an empty seam reads as unset");
        assert_eq!(root(), default_root());
        assert!(
            config_is_machine_global(),
            "an empty seam leaves the machine's own root"
        );
    }

    #[test]
    fn an_absent_seam_is_not_a_detection() {
        let _lock = seam_lock();
        let dir = tempfile::tempdir().expect("temp dir");
        let (key, path) = absent_seam(dir.path());
        let _env = EnvOverride::apply([(key, Some(path.clone().into_os_string()))]);

        assert!(
            !path.exists(),
            "the premise: the absent seam names a path it never creates"
        );
        assert_eq!(detect(), None);
    }

    /// D-02, on a temporary home with no application and no CLI in reach: a
    /// `~/.cursor` left behind by an uninstalled Cursor does not arm detection.
    ///
    /// The IDE probe also reads `/Applications` on macOS, which a test cannot
    /// redirect; on a host that really has Cursor installed the premise does not
    /// hold, and the case asserts what detection must then answer instead of
    /// skipping silently.
    #[test]
    fn leftover_dot_cursor_alone_is_not_a_detection() {
        let home = tempfile::tempdir().expect("temp home");
        std::fs::create_dir_all(home.path().join(".cursor")).expect("leftover root");
        let empty = tempfile::tempdir().expect("empty PATH dir");
        let path_var = empty.path().as_os_str();
        let expected = home.path().join(".cursor");

        if ide_installed(home.path(), Some(path_var)) {
            assert_eq!(
                detect_in(None, Some(home.path()), Some(path_var)),
                Some(expected)
            );
            return;
        }
        assert!(!cli_installed(home.path(), Some(path_var)));
        assert_eq!(
            detect_in(None, Some(home.path()), Some(path_var)),
            None,
            "a leftover ~/.cursor alone is not an install"
        );

        // The CLI's install directory alone IS one.
        std::fs::create_dir_all(
            home.path()
                .join(".local")
                .join("share")
                .join("cursor-agent"),
        )
        .expect("cli dir");
        assert_eq!(
            detect_in(None, Some(home.path()), Some(path_var)),
            Some(expected)
        );
    }

    #[test]
    fn the_generic_agent_name_is_not_the_cli() {
        let home = tempfile::tempdir().expect("temp home");
        let bin = tempfile::tempdir().expect("PATH dir");
        let path_var = bin.path().as_os_str();
        std::fs::write(bin.path().join("agent"), "").expect("a generic `agent`");
        assert!(
            !cli_installed(home.path(), Some(path_var)),
            "`agent` alone must never read as Cursor's CLI"
        );

        let name = if cfg!(windows) {
            "cursor-agent.exe"
        } else {
            "cursor-agent"
        };
        std::fs::write(bin.path().join(name), "").expect("cursor-agent");
        assert!(cli_installed(home.path(), Some(path_var)));
    }
}