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
//! Linux discovery rungs — GNOME `gsettings`, and nothing else.
//!
//! The frozen contract row is short and its "never" column is the interesting half: no
//! WPAD, and no KDE `kioslaverc`. WPAD has no OS facility to delegate to on Linux, and we
//! do not implement the protocol ourselves. KDE's settings live in a hand-parsed INI whose
//! shape varies by distribution; a host running it gets a trace entry that names the file
//! and the environment variables that work instead, which is a better answer than a
//! half-right parser.
//!
//! **PAC is refused on Linux (D-20), by name.** No JavaScript engine ships in this binary,
//! and adding one to an egress path is a new attack surface in the exact place a security
//! client can least afford it. `mode = 'auto'` therefore produces `OL-1225` with the
//! executable remedy, and — this is the case that surprises people — so does `mode =
//! 'auto'` with an *empty* `autoconfig-url`, because on GNOME that means WPAD.
//!
//! One `gsettings list-recursively` call answers every key: one process instead of ten,
//! and one place to bound with a deadline.

use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use reqwest::Url;

use crate::core::egress::config::ProxySource;
use crate::core::error::{OlError, ERR_PAC_UNAVAILABLE, ERR_PROXY_CONFIG_INVALID};

use super::{rung, Context, Ladder, RungResult};

/// The GNOME schema that owns every proxy key, children included.
const SCHEMA: &str = "org.gnome.system.proxy";
/// Overrides the binary the rung shells out to. A test seam, following the
/// `OPENLATCH_BOUNDARY_DEFAULT_PORT` precedent — a shipped environment must never set it.
const BIN_ENV: &str = "OPENLATCH_GSETTINGS_BIN";
/// Where a distribution puts `gsettings` when it has one.
///
/// Preferred over a bare `PATH` lookup: `PATH` is attacker-writable in a user session, and
/// this is where GNOME itself installs. It is a *foreign* binary's location rather than a
/// path this client resolves for itself, only the Linux rung ever reads it, and a host
/// without it falls through to `PATH` and then to "no GNOME here".
const BIN_ABSOLUTE: &str = "/usr/bin/gsettings"; // portability-ok: GNOME's own location
/// How long the shell-out may take.
const DEADLINE: Duration = Duration::from_secs(2);

/// What one `gsettings` invocation produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GSettingsOutcome {
    /// It ran and printed this.
    Output(String),
    /// No `gsettings` on this host: not a GNOME desktop, or a headless server.
    NotInstalled,
    /// It ran and failed — no such schema, a non-zero exit, or the deadline.
    Failed(String),
}

/// The Linux ladder's one seam.
pub trait GSettingsSource {
    /// `gsettings list-recursively <schema>`.
    fn list_recursively(&self, schema: &str) -> GSettingsOutcome;
}

/// Walk the Linux ladder. Both contexts read the same settings.
pub fn walk(ladder: &mut Ladder, ctx: Context, src: &dyn GSettingsSource, target: &Url) {
    let _ = (ctx, target);
    ladder.offer(rung::GSETTINGS, gsettings_rung(src));
}

fn gsettings_rung(src: &dyn GSettingsSource) -> RungResult {
    let output = match src.list_recursively(SCHEMA) {
        GSettingsOutcome::Output(o) => o,
        GSettingsOutcome::NotInstalled => {
            return RungResult::empty_with(
                ProxySource::Gnome,
                "no gsettings on this host. If this is KDE, its proxy settings live in \
             kioslaverc and are not read: set https_proxy/http_proxy, or [proxy] url",
            )
        }
        GSettingsOutcome::Failed(why) => {
            return RungResult::empty_with(
                ProxySource::Gnome,
                format!("gsettings did not answer: {why}"),
            )
        }
    };
    let settings = parse_list_recursively(&output);
    match settings.get(SCHEMA, "mode").unwrap_or("none") {
        "manual" => manual_rung(&settings),
        "auto" => auto_refusal(&settings),
        // `none`, absent, or a value GNOME has not shipped yet. Either way there is
        // nothing here, and the rungs above and below are unaffected.
        _ => RungResult::empty(ProxySource::Gnome),
    }
}

/// `mode = 'manual'`.
///
/// Activation is a **non-empty host**, not the per-child `enabled` key: that key is
/// documented as unused, and GNOME's own `GProxyResolverGnome` ignores it. Keying on it
/// would silently discard a proxy the desktop is really using.
fn manual_rung(settings: &Settings) -> RungResult {
    // https first — every destination in the egress inventory is https — then http, then
    // SOCKS as `socks5h` so the proxy resolves the name.
    for (child, scheme) in [
        ("org.gnome.system.proxy.https", "http"),
        ("org.gnome.system.proxy.http", "http"),
        ("org.gnome.system.proxy.socks", "socks5h"),
    ] {
        let Some(host) = settings.get(child, "host").filter(|h| !h.is_empty()) else {
            continue;
        };
        let port = settings
            .get(child, "port")
            .and_then(|p| p.parse::<u16>().ok())
            .unwrap_or(0);
        if port == 0 {
            // A host with no port is a half-finished setting. Inventing the protocol
            // default here would send traffic to a port the desktop never named; the trace
            // is the warning carrier, and the rung moves on.
            return RungResult::skipped(
                ProxySource::Gnome,
                ERR_PROXY_CONFIG_INVALID,
                format!("{child} sets host = '{host}' with no port"),
            );
        }
        return match Url::parse(&format!("{scheme}://{host}:{port}")) {
            Ok(url) => RungResult::static_route(ProxySource::Gnome, url),
            Err(e) => RungResult::skipped(
                ProxySource::Gnome,
                ERR_PROXY_CONFIG_INVALID,
                format!("{child} names an unusable proxy '{host}:{port}': {e}"),
            ),
        };
    }
    RungResult::empty_with(
        ProxySource::Gnome,
        "mode = 'manual' but no child schema sets a host",
    )
}

/// `mode = 'auto'` — the Linux-PAC refusal (D-20), in its two flavours.
fn auto_refusal(settings: &Settings) -> RungResult {
    let url = settings
        .get(SCHEMA, "autoconfig-url")
        .unwrap_or("")
        .trim()
        .to_string();
    if url.is_empty() {
        // GNOME's `auto` with an empty URL means WPAD. Saying "PAC is unsupported" here
        // would send the operator hunting for a PAC file that does not exist.
        return RungResult::skipped(
            ProxySource::Wpad,
            ERR_PAC_UNAVAILABLE,
            "WPAD requested by GNOME settings; not supported on Linux — set [proxy] url, \
             or run `openlatch proxy set <url>`",
        );
    }
    RungResult::skipped(
        ProxySource::Pac,
        ERR_PAC_UNAVAILABLE,
        format!(
            "GNOME names a PAC script ({url}) and no PAC evaluator exists on Linux — set \
             [proxy] url, or run `openlatch proxy set <url>`"
        ),
    )
}

/// The refusal, as an error, for a caller holding an explicit `pac_url` on Linux.
///
/// The discovery rung above refuses in the trace; a configuration that names a PAC
/// outright deserves a hard error at the point it is read, which is plan 02's job. This is
/// the one wording both share.
pub fn pac_refusal(pac_url: &str) -> OlError {
    OlError::new(
        ERR_PAC_UNAVAILABLE,
        format!("[proxy] pac_url = \"{pac_url}\" cannot be used: Linux has no PAC evaluator"),
    )
    .with_suggestion(
        "This client ships no JavaScript engine, deliberately. Set [proxy] url to the proxy \
         the PAC would have returned, or run `openlatch proxy set <url>`.",
    )
}

// ---------------------------------------------------------------------------
// list-recursively parsing
// ---------------------------------------------------------------------------

/// Every key `gsettings list-recursively` printed, keyed by `(schema, key)`.
#[derive(Debug, Default)]
struct Settings(std::collections::HashMap<(String, String), String>);

impl Settings {
    fn get(&self, schema: &str, key: &str) -> Option<&str> {
        self.0
            .get(&(schema.to_string(), key.to_string()))
            .map(String::as_str)
    }
}

/// Parse `<schema> <key> <value>` lines.
///
/// Values are GVariant literals: `'manual'`, `8080`, `true`, `['localhost', '::1']`. Only
/// the first two forms matter here, so the parser unquotes a single-quoted scalar and
/// leaves everything else verbatim rather than growing a GVariant reader.
fn parse_list_recursively(output: &str) -> Settings {
    let mut out = Settings::default();
    for line in output.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let mut parts = line.splitn(3, ' ');
        let (Some(schema), Some(key), value) = (parts.next(), parts.next(), parts.next()) else {
            continue;
        };
        let value = value.unwrap_or("").trim();
        let value = value
            .strip_prefix('\'')
            .and_then(|v| v.strip_suffix('\''))
            .unwrap_or(value);
        out.0
            .insert((schema.to_string(), key.to_string()), value.to_string());
    }
    out
}

// ---------------------------------------------------------------------------
// The shell-out backend
// ---------------------------------------------------------------------------

/// The real backend, with the binary resolved from the environment.
pub fn native() -> CommandGSettings {
    CommandGSettings::from_env()
}

/// Runs the real `gsettings` binary.
///
/// Absolute path first, `PATH` only as the fallback. `PATH` is attacker-writable in a user
/// session, so this is defence in depth rather than a guarantee: what actually makes a
/// hostile answer harmless is that every candidate is probe-validated and TLS trust still
/// applies. Discovery is a hint, never an authority.
#[derive(Debug, Clone)]
pub struct CommandGSettings {
    binary: String,
}

impl CommandGSettings {
    /// Resolve the binary once: the [`BIN_ENV`] seam, then the absolute path, then `PATH`.
    pub fn from_env() -> Self {
        Self {
            binary: resolve_binary(),
        }
    }

    /// A backend pinned to one binary. The seam tests use it so they never mutate the
    /// process environment, which would race every other test in the binary.
    pub fn with_binary(binary: impl Into<String>) -> Self {
        Self {
            binary: binary.into(),
        }
    }
}

impl GSettingsSource for CommandGSettings {
    fn list_recursively(&self, schema: &str) -> GSettingsOutcome {
        let bin = &self.binary;
        let mut child = match Command::new(bin)
            .arg("list-recursively")
            .arg(schema)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
        {
            Ok(c) => c,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return GSettingsOutcome::NotInstalled
            }
            Err(e) => return GSettingsOutcome::Failed(format!("{bin} could not start: {e}")),
        };

        let deadline = Instant::now() + DEADLINE;
        let status = loop {
            match child.try_wait() {
                Ok(Some(status)) => break status,
                Ok(None) if Instant::now() >= deadline => {
                    let _ = child.kill();
                    let _ = child.wait();
                    return GSettingsOutcome::Failed(format!(
                        "no answer within {} s",
                        DEADLINE.as_secs()
                    ));
                }
                Ok(None) => std::thread::sleep(Duration::from_millis(25)),
                Err(e) => return GSettingsOutcome::Failed(format!("{e}")),
            }
        };

        // Read after exit rather than concurrently: this schema prints well under a
        // kilobyte, far below the pipe buffer that would make that a deadlock.
        let mut buf = String::new();
        if let Some(mut stdout) = child.stdout.take() {
            let _ = stdout.read_to_string(&mut buf);
        }
        if !status.success() {
            return GSettingsOutcome::Failed(format!("exit {status}"));
        }
        GSettingsOutcome::Output(buf)
    }
}

fn resolve_binary() -> String {
    if let Some(seam) = std::env::var(BIN_ENV).ok().filter(|v| !v.is_empty()) {
        return seam;
    }
    if std::path::Path::new(BIN_ABSOLUTE).is_file() {
        return BIN_ABSOLUTE.to_string();
    }
    "gsettings".to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::egress::discovery::tests::ScriptedProbe;
    use crate::core::egress::discovery::{CandidateAttempt, CandidateOutcome, Route};

    struct Fixture(GSettingsOutcome);

    impl GSettingsSource for Fixture {
        fn list_recursively(&self, _schema: &str) -> GSettingsOutcome {
            self.0.clone()
        }
    }

    fn out(lines: &str) -> Fixture {
        Fixture(GSettingsOutcome::Output(lines.to_string()))
    }

    fn target() -> Url {
        Url::parse("https://app.openlatch.ai/api/v1/health").expect("target")
    }

    fn walk_with(src: &dyn GSettingsSource, probe: &ScriptedProbe) -> Vec<CandidateAttempt> {
        let mut ladder = Ladder::new(probe);
        walk(&mut ladder, Context::UserSession, src, &target());
        ladder.finish().1
    }

    #[test]
    fn manual_mode_yields_a_static_candidate() {
        let src = out("org.gnome.system.proxy mode 'manual'\n\
             org.gnome.system.proxy.https host 'secure.corp'\n\
             org.gnome.system.proxy.https port 8443\n");
        let probe = ScriptedProbe::new(vec![Ok(5)]);
        let mut ladder = Ladder::new(&probe);
        walk(&mut ladder, Context::UserSession, &src, &target());
        let won = ladder.finish().0.expect("gnome win");
        assert_eq!(won.source, ProxySource::Gnome);
        assert_eq!(
            won.route,
            Route::Static(Url::parse("http://secure.corp:8443").expect("url"))
        );
    }

    #[test]
    fn the_enabled_key_is_ignored() {
        // `org.gnome.system.proxy.http enabled` is documented-unused, and GNOME's own
        // resolver ignores it. Honouring it would discard a proxy the desktop is using.
        let src = out("org.gnome.system.proxy mode 'manual'\n\
             org.gnome.system.proxy.http enabled false\n\
             org.gnome.system.proxy.http host 'proxy.corp'\n\
             org.gnome.system.proxy.http port 3128\n");
        let probe = ScriptedProbe::new(vec![Ok(2)]);
        let mut ladder = Ladder::new(&probe);
        walk(&mut ladder, Context::UserSession, &src, &target());
        let won = ladder.finish().0.expect("gnome win despite enabled=false");
        assert_eq!(
            won.route,
            Route::Static(Url::parse("http://proxy.corp:3128").expect("url"))
        );
    }

    #[test]
    fn a_host_with_port_zero_is_incomplete_and_carries_its_own_warning() {
        let src = out("org.gnome.system.proxy mode 'manual'\n\
             org.gnome.system.proxy.https host 'secure.corp'\n\
             org.gnome.system.proxy.https port 0\n");
        let probe = ScriptedProbe::always_fails();
        let trace = walk_with(&src, &probe);
        assert_eq!(trace.len(), 1);
        assert_eq!(
            trace[0].probe,
            CandidateOutcome::Skipped(ERR_PROXY_CONFIG_INVALID)
        );
        assert!(trace[0]
            .detail
            .as_deref()
            .is_some_and(|d| d.contains("no port")));
    }

    #[test]
    fn auto_mode_is_the_named_pac_refusal() {
        let src = out("org.gnome.system.proxy mode 'auto'\n\
             org.gnome.system.proxy autoconfig-url 'http://corp/proxy.pac'\n");
        let probe = ScriptedProbe::always_fails();
        let trace = walk_with(&src, &probe);
        assert_eq!(
            trace[0].probe,
            CandidateOutcome::Skipped(ERR_PAC_UNAVAILABLE)
        );
        assert_eq!(trace[0].source, ProxySource::Pac);
        let detail = trace[0].detail.as_deref().unwrap_or_default();
        assert!(detail.contains("http://corp/proxy.pac"));
        assert!(
            detail.contains("proxy set") || detail.contains("[proxy] url"),
            "a refusal owes an executable remedy: {detail}"
        );
    }

    #[test]
    fn auto_mode_with_no_url_is_the_wpad_refusal_and_says_wpad() {
        // The distinct wording matters: "PAC unsupported" would send an operator looking
        // for a PAC file that GNOME never named.
        let src = out("org.gnome.system.proxy mode 'auto'\n\
             org.gnome.system.proxy autoconfig-url ''\n");
        let probe = ScriptedProbe::always_fails();
        let trace = walk_with(&src, &probe);
        assert_eq!(trace[0].source, ProxySource::Wpad);
        let detail = trace[0].detail.as_deref().unwrap_or_default();
        assert!(detail.contains("WPAD"), "{detail}");
    }

    #[test]
    fn no_gsettings_names_kde_and_the_environment_remedy() {
        let src = Fixture(GSettingsOutcome::NotInstalled);
        let probe = ScriptedProbe::always_fails();
        let trace = walk_with(&src, &probe);
        assert_eq!(trace.len(), 1, "the rung must still leave its one entry");
        assert_eq!(trace[0].probe, CandidateOutcome::NotConfigured);
        let detail = trace[0].detail.as_deref().unwrap_or_default();
        assert!(detail.contains("kioslaverc"), "{detail}");
        assert!(detail.contains("https_proxy"), "{detail}");
    }

    #[test]
    fn mode_none_leaves_a_clean_empty_rung() {
        let src = out("org.gnome.system.proxy mode 'none'\n");
        let probe = ScriptedProbe::always_fails();
        let trace = walk_with(&src, &probe);
        assert_eq!(trace[0].probe, CandidateOutcome::NotConfigured);
        assert!(trace[0].detail.is_none());
    }

    #[test]
    fn the_value_parser_unquotes_scalars_and_leaves_lists_alone() {
        let s = parse_list_recursively(
            "org.gnome.system.proxy mode 'manual'\n\
             org.gnome.system.proxy.http port 8080\n\
             org.gnome.system.proxy ignore-hosts ['localhost', '::1']\n\
             malformed\n",
        );
        assert_eq!(s.get("org.gnome.system.proxy", "mode"), Some("manual"));
        assert_eq!(s.get("org.gnome.system.proxy.http", "port"), Some("8080"));
        assert_eq!(
            s.get("org.gnome.system.proxy", "ignore-hosts"),
            Some("['localhost', '::1']")
        );
        assert_eq!(s.get("org.gnome.system.proxy", "missing"), None);
    }

    #[test]
    fn the_explicit_pac_refusal_names_the_url_and_a_remedy() {
        let err = pac_refusal("http://corp/proxy.pac");
        assert_eq!(err.code, ERR_PAC_UNAVAILABLE);
        assert!(err.message.contains("http://corp/proxy.pac"));
        assert!(err
            .suggestion
            .as_deref()
            .is_some_and(|s| s.contains("proxy set")));
    }

    #[test]
    fn a_missing_binary_is_not_installed_rather_than_a_failure() {
        // The real backend, exercised on every OS without touching the process
        // environment: a binary that cannot exist must produce the "no GNOME here"
        // answer, not a crash and not a `Failed` an operator would go debugging.
        let src = CommandGSettings::with_binary("openlatch-no-such-gsettings-binary");
        assert_eq!(src.list_recursively(SCHEMA), GSettingsOutcome::NotInstalled);
        let trace = walk_with(&src, &ScriptedProbe::always_fails());
        assert!(trace[0]
            .detail
            .as_deref()
            .is_some_and(|d| d.contains("kioslaverc")));
    }
}