openlatch-client 0.6.19

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
//! Credential storage for OpenLatch API keys.
//!
//! Provides a [`CredentialStore`] trait with three implementations:
//! - [`KeyringCredentialStore`] — OS keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service)
//! - [`FileCredentialStore`] — AES-256-GCM encrypted file fallback for headless environments
//! - [`InMemoryCredentialStore`] — In-memory store for testing
//!
//! The fallback chain (per D-06, revised): OPENLATCH_API_KEY env var -> OS keychain -> encrypted file.

pub mod file;
pub mod keyring;
pub mod memory;
/// Choosing the organization a machine joins (PLT-7 C-6). `full-cli` only, like
/// every caller of the browser sign-in.
#[cfg(feature = "full-cli")]
pub mod org_choice;
/// PKCE and `state` for the browser sign-in (PLT-7 C-1). `getrandom` is optional
/// under `full-cli`, so the hook never links it.
#[cfg(feature = "full-cli")]
pub mod pkce;

use secrecy::SecretString;

use crate::error::OlError;

/// Credential storage abstraction (per D-05).
///
/// All implementations must be Send + Sync for use in async contexts.
pub trait CredentialStore: Send + Sync {
    /// Store an API key. Overwrites any existing key.
    fn store(&self, key: SecretString) -> Result<(), OlError>;

    /// Retrieve the stored API key.
    fn retrieve(&self) -> Result<SecretString, OlError>;

    /// Delete the stored API key. No-op if no key exists.
    fn delete(&self) -> Result<(), OlError>;

    /// Drop any process-local cache of the stored credential, so the next
    /// [`retrieve`](Self::retrieve) re-reads the backing store instead of
    /// returning a memoized answer.
    ///
    /// Default no-op: most implementations never cache. [`KeyringCredentialStore`]
    /// overrides this — its `retrieve()` is memoized for the process lifetime
    /// (see `READ_MEMO` in `keyring.rs`) to avoid a macOS keychain authorization
    /// dialog per read, and `store`/`delete` only invalidate that memo when they
    /// run in the SAME process. A daemon's resident memo is invisible to an
    /// `openlatch system auth login` run in a different process, so without this
    /// hook a running daemon would never observe a rotated credential short of a
    /// restart. Callers invoke it when told a credential changed out of band:
    /// the `/admin/auth/refresh` route (a login just happened) and the cloud
    /// worker's 401/403 latch (the current key was just proven wrong).
    fn invalidate(&self) {}

    /// Does this store hold a credential right now? Used by logout to report
    /// which tiers it cleared. The default reads the store;
    /// [`FileCredentialStore`] answers from the file's existence, so a file it
    /// can no longer decrypt still counts as something to clear.
    fn holds(&self) -> bool {
        self.retrieve().is_ok()
    }
}

/// Where a stored credential lives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tier {
    /// The OS keychain.
    Keychain,
    /// The encrypted `credentials.enc` file.
    File,
}

impl Tier {
    /// The `--json` spelling (`credentials_cleared`).
    pub fn as_str(self) -> &'static str {
        match self {
            Tier::Keychain => "keychain",
            Tier::File => "file",
        }
    }
}

/// The STORED credential, read from the keychain and then the file directly.
///
/// Unlike [`retrieve_credential`], `OPENLATCH_API_KEY` is never consulted: a
/// key from the environment is the operator's, and logout must neither revoke
/// it nor report it as cleared (PLT-7 C-9, D-14).
pub fn retrieve_stored(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
) -> Option<(SecretString, Tier)> {
    if let Ok(key) = primary.retrieve() {
        return Some((key, Tier::Keychain));
    }
    fallback.retrieve().ok().map(|key| (key, Tier::File))
}

/// Delete the credential from BOTH tiers and return the tiers that held one
/// and were cleared (PLT-7 C-9). A logout used to clear the keychain alone and
/// left `credentials.enc` behind, so the next lookup found the key again.
///
/// Every tier is attempted whatever the other did; a tier whose delete fails
/// is left out of the result.
pub fn delete_all_credentials(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
) -> Vec<Tier> {
    let mut cleared = Vec::new();
    for (store, tier) in [(primary, Tier::Keychain), (fallback, Tier::File)] {
        let held = store.holds();
        match store.delete() {
            Ok(()) if held => cleared.push(tier),
            Ok(()) => {}
            Err(e) => tracing::warn!(
                code = e.code,
                tier = tier.as_str(),
                error = %e.message,
                "could not delete the stored credential"
            ),
        }
    }
    cleared
}

// Re-export error constants from crate::error for use within this leaf module.
pub(crate) use crate::error::{
    ERR_FILE_FALLBACK_ERROR, ERR_KEYCHAIN_PERMISSION, ERR_KEYCHAIN_UNAVAILABLE, ERR_NO_CREDENTIALS,
};

/// Retrieve a credential using the fallback chain (per D-06, revised):
/// 1. Check OPENLATCH_API_KEY env var
/// 2. Try the primary store (OS keychain)
/// 3. Try the fallback store (encrypted file)
///
/// Returns the first successful result, or an error if all fail.
///
/// # Why the env var is checked first
///
/// D-06 originally probed the keychain before the env var, which made
/// `OPENLATCH_API_KEY` an "override" that could not actually override
/// anything: whenever the keychain held a key, the env var was unreachable,
/// so a stale keychain entry could not be worked around without deleting it.
///
/// The ordering also cost a keychain hit on every lookup even when the caller
/// had already supplied a credential explicitly. On macOS a keychain read from
/// a binary that did not create the item raises a blocking authorization
/// dialog, so headless callers that set the env var precisely to avoid the
/// keychain (launchd/systemd units, containers, CI) were prompted anyway.
///
/// An explicitly-provided credential is a deliberate act by the caller and now
/// wins over both stores.
/// Store `key`, falling back to the encrypted file when the OS keychain cannot
/// take it.
///
/// **The mirror of [`retrieve_credential`], and it was missing.** Lookup has
/// consulted the file store since it existed; storing went to the keychain
/// alone and failed the whole flow when that was unavailable — so on any host
/// without a usable login keychain, `openlatch init` could not complete at all.
/// A sandbox with a redirected `HOME` is exactly such a host: the macOS login
/// keychain lives under `$HOME/Library/Keychains`, and moving `HOME` is what
/// puts an agent's own config inside the sandbox.
///
/// The fallback is the SAME store lookup already trusts, so a credential
/// written here is found again by step 3 of `retrieve_credential`. Both failing
/// is still an error: silently storing nothing would leave a host that believes
/// it is authenticated and is not.
pub fn store_credential(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
    key: SecretString,
) -> Result<(), OlError> {
    let Err(primary_err) = primary.store(key.clone()) else {
        return Ok(());
    };
    fallback.store(key).map_err(|fallback_err| {
        // BOTH messages. "keychain unavailable" alone sends the reader to the
        // keychain, which is not where this host is going to keep its
        // credential; the file error is the one they can act on.
        OlError::new(
            fallback_err.code,
            format!(
                "could not store the credential in the OS keychain ({}) and the \
                 encrypted file fallback also failed: {}",
                primary_err.message, fallback_err.message
            ),
        )
    })
}

pub fn retrieve_credential(
    primary: &dyn CredentialStore,
    fallback: &dyn CredentialStore,
) -> Result<SecretString, OlError> {
    // Step 1: env var override (per CRED-02)
    if let Ok(val) = std::env::var("OPENLATCH_API_KEY") {
        if !val.is_empty() {
            return Ok(SecretString::from(val));
        }
    }

    // Step 2: primary store (keyring)
    if let Ok(key) = primary.retrieve() {
        return Ok(key);
    }

    // Step 3: fallback store (encrypted file)
    if let Ok(key) = fallback.retrieve() {
        return Ok(key);
    }

    Err(OlError::new(
        ERR_NO_CREDENTIALS,
        "No API key found in keychain, OPENLATCH_API_KEY env var, or encrypted file",
    )
    .with_suggestion(
        "Run 'openlatch system auth login' to authenticate, or set OPENLATCH_API_KEY.",
    ))
}

pub use self::file::FileCredentialStore;
pub use self::keyring::KeyringCredentialStore;
pub use self::memory::InMemoryCredentialStore;

/// `CredentialStore` that composes the full fallback chain (keyring -> env -> file)
/// behind a single `retrieve()` call, so the daemon can hand one store to the
/// cloud worker without caring which source holds the key.
///
/// `store()` and `delete()` target the primary (keyring) store only — mutations
/// are the `auth` command's job, not the daemon's.
pub struct FallbackCredentialStore {
    primary: Box<dyn CredentialStore>,
    fallback: Box<dyn CredentialStore>,
}

impl FallbackCredentialStore {
    pub fn new(primary: Box<dyn CredentialStore>, fallback: Box<dyn CredentialStore>) -> Self {
        Self { primary, fallback }
    }
}

impl CredentialStore for FallbackCredentialStore {
    /// Through [`store_credential`], so this type's `store` and `retrieve` use
    /// the SAME two tiers. Delegating to the primary alone made a host whose
    /// keychain was unavailable fail to store while still being able to read —
    /// an asymmetry that reads as "the fallback is for lookup only", which it
    /// never was.
    fn store(&self, key: SecretString) -> Result<(), OlError> {
        store_credential(self.primary.as_ref(), self.fallback.as_ref(), key)
    }

    fn retrieve(&self) -> Result<SecretString, OlError> {
        retrieve_credential(self.primary.as_ref(), self.fallback.as_ref())
    }

    fn delete(&self) -> Result<(), OlError> {
        self.primary.delete()
    }

    /// Both tiers: only the keyring memoizes today, but a future fallback
    /// cache should not have to remember to wire this in separately.
    fn invalidate(&self) {
        self.primary.invalidate();
        self.fallback.invalidate();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::auth::memory::InMemoryCredentialStore;
    use secrecy::ExposeSecret;

    /// A store that cannot take anything — a host with no usable OS keychain.
    struct UnavailableStore;

    impl CredentialStore for UnavailableStore {
        fn store(&self, _key: SecretString) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
        fn retrieve(&self) -> Result<SecretString, OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
        fn delete(&self) -> Result<(), OlError> {
            Err(OlError::new("OL-TEST", "OS keychain is not available"))
        }
    }

    /// **A host with no keychain can still authenticate.**
    ///
    /// Storing went to the keychain alone, so `init` failed outright wherever
    /// one was unavailable — including any sandbox whose `HOME` is redirected
    /// away from `~/Library/Keychains`, which is what puts an agent's config
    /// inside the sandbox in the first place.
    #[test]
    fn a_credential_falls_back_to_the_file_when_the_keychain_cannot_take_it() {
        let fallback = InMemoryCredentialStore::new();
        store_credential(
            &UnavailableStore,
            &fallback,
            SecretString::from("k-123".to_string()),
        )
        .expect("an unavailable keychain must not fail the whole flow");

        // And the tier lookup already consults finds it again.
        assert_eq!(
            retrieve_credential(&UnavailableStore, &fallback)
                .expect("stored")
                .expose_secret(),
            "k-123"
        );
    }

    /// Both failing is still an error — a host that believes it is
    /// authenticated and is not is worse than one that says so.
    #[test]
    fn both_stores_failing_is_reported() {
        let err = store_credential(
            &UnavailableStore,
            &UnavailableStore,
            SecretString::from("k".to_string()),
        )
        .expect_err("both tiers failed");
        assert!(
            err.message.contains("keychain") && err.message.contains("fallback also failed"),
            "the message must name both tiers: {}",
            err.message
        );
    }

    #[test]
    fn test_retrieve_credential_uses_primary_first() {
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "primary-key");
    }

    #[test]
    #[ignore] // Mutates the process-wide OPENLATCH_API_KEY env var — not
              // parallel-safe. Run with:
              // cargo test retrieve_credential_env -- --ignored --test-threads=1
    fn test_env_var_beats_both_stores() {
        // The env var is an override: it must win even when both stores hold a
        // key. Before the D-06 revision the keychain was probed first, so a
        // stale entry could not be overridden and headless callers that set
        // this var to avoid the keychain were prompted anyway.
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        std::env::set_var("OPENLATCH_API_KEY", "env-key");
        let result = retrieve_credential(&primary, &fallback).unwrap();
        std::env::remove_var("OPENLATCH_API_KEY");

        assert_eq!(result.expose_secret(), "env-key");
    }

    #[test]
    #[ignore] // Mutates the process-wide OPENLATCH_API_KEY env var — see above.
    fn test_empty_env_var_falls_through_to_stores() {
        let primary = InMemoryCredentialStore::new();
        primary
            .store(SecretString::from("primary-key".to_string()))
            .unwrap();
        let fallback = InMemoryCredentialStore::new();

        std::env::set_var("OPENLATCH_API_KEY", "");
        let result = retrieve_credential(&primary, &fallback).unwrap();
        std::env::remove_var("OPENLATCH_API_KEY");

        assert_eq!(result.expose_secret(), "primary-key");
    }

    #[test]
    fn test_retrieve_credential_falls_through_to_fallback_when_primary_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        fallback
            .store(SecretString::from("fallback-key".to_string()))
            .unwrap();

        let result = retrieve_credential(&primary, &fallback).unwrap();
        assert_eq!(result.expose_secret(), "fallback-key");
    }

    #[test]
    fn test_retrieve_credential_returns_err_when_all_empty() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();

        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert_eq!(err.code, ERR_NO_CREDENTIALS);
    }

    #[test]
    fn test_secret_string_debug_does_not_leak_value() {
        let secret = SecretString::from("my-api-key".to_string());
        let debug_output = format!("{:?}", secret);
        assert!(
            !debug_output.contains("my-api-key"),
            "SecretString Debug output must not contain the actual secret: {debug_output}"
        );
    }

    #[test]
    fn test_retrieve_credential_env_var_absent_falls_through() {
        // With no env var set, all three sources are empty → OL-1600.
        // The positive env-var cases are covered by the two `#[ignore]`d tests
        // above, which mutate the process-wide var and must run single-threaded.
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();

        let result = retrieve_credential(&primary, &fallback);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err().code, ERR_NO_CREDENTIALS);
    }

    // --- logout: both tiers (PLT-7 C-9, D-14) ---

    #[test]
    fn logout_clears_both_tiers_and_reports_them() {
        let primary = InMemoryCredentialStore::new();
        primary.store(SecretString::from("k".to_string())).unwrap();
        let fallback = InMemoryCredentialStore::new();
        fallback.store(SecretString::from("k".to_string())).unwrap();

        assert_eq!(
            delete_all_credentials(&primary, &fallback),
            vec![Tier::Keychain, Tier::File]
        );
        assert!(primary.retrieve().is_err());
        assert!(fallback.retrieve().is_err());
    }

    #[test]
    fn logout_reports_only_the_tier_that_held_a_key() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        fallback.store(SecretString::from("k".to_string())).unwrap();
        assert_eq!(
            delete_all_credentials(&primary, &fallback),
            vec![Tier::File]
        );
        assert_eq!(
            delete_all_credentials(&primary, &fallback),
            Vec::<Tier>::new()
        );
    }

    #[test]
    fn logout_still_clears_the_file_when_the_keychain_fails() {
        let fallback = InMemoryCredentialStore::new();
        fallback.store(SecretString::from("k".to_string())).unwrap();
        assert_eq!(
            delete_all_credentials(&UnavailableStore, &fallback),
            vec![Tier::File]
        );
        assert!(fallback.retrieve().is_err());
    }

    #[test]
    fn logout_clears_a_credentials_file_it_cannot_decrypt() {
        // `holds` is the file's existence: an `agent_id` change leaves a file
        // nothing can read, and it is still a credential left on disk.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("credentials.enc");
        FileCredentialStore::new(path.clone(), "agt_old".into())
            .store(SecretString::from("k".to_string()))
            .unwrap();
        let file = FileCredentialStore::new(path.clone(), "agt_new".into());
        assert!(file.retrieve().is_err());
        assert_eq!(
            delete_all_credentials(&InMemoryCredentialStore::new(), &file),
            vec![Tier::File]
        );
        assert!(!path.exists());
    }

    #[test]
    fn logout_revokes_the_stored_key_by_tier_order() {
        let primary = InMemoryCredentialStore::new();
        let fallback = InMemoryCredentialStore::new();
        assert!(retrieve_stored(&primary, &fallback).is_none());

        fallback
            .store(SecretString::from("file-key".to_string()))
            .unwrap();
        let (key, tier) = retrieve_stored(&primary, &fallback).unwrap();
        assert_eq!((key.expose_secret(), tier), ("file-key", Tier::File));

        primary
            .store(SecretString::from("keychain-key".to_string()))
            .unwrap();
        let (key, tier) = retrieve_stored(&primary, &fallback).unwrap();
        assert_eq!(
            (key.expose_secret(), tier),
            ("keychain-key", Tier::Keychain)
        );
    }
}