devboy-core 0.29.0

Core traits, types, and error handling for devboy-tools — Provider, IssueProvider, MergeRequestProvider, configuration model.
Documentation
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
//! Per-session approve-on-use cache for `@secret:<path>`
//! resolution per [ADR-023] §3.7 (P25.4).
//!
//! When a manifest entry's `approve_on_use` is `Session` or
//! `PerCall`, every alias resolve must surface the
//! `secrets_request_use_approval` dialog before the value
//! reaches the consumer. The agent picks one of three
//! decisions:
//!
//! - `Once` — single resolve, no caching.
//! - `AlwaysSession` — cache the approval for the chosen TTL.
//! - `Deny` — refuse the resolve.
//!
//! [`SessionApprovalCache`] holds the `AlwaysSession` decisions
//! for the lifetime of one process. The cache is intentionally
//! *advisory*: it lives in `devboy-core` (the lowest leaf of
//! the dependency graph) so any consumer — config loader,
//! router, MCP server — can reuse the same gate logic without
//! pulling in `devboy-storage` or the dialog crate.
//!
//! The dialog and the storage manifest both stay decoupled
//! from this module: `devboy-storage` exposes the
//! `ApproveOnUse` enum on its `IndexEntry`, and a small
//! [`From`] bridge in that crate turns it into the local
//! [`ApproveOnUsePolicy`] enum so this cache stays
//! dependency-free.
//!
//! [ADR-023]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// Mirror of `devboy_storage::index::ApproveOnUse` exposed
/// here so the cache is reachable from `devboy-core` without a
/// circular dependency. `devboy-storage` provides a `From` impl
/// from its own enum.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ApproveOnUsePolicy {
    /// Default — zero-prompt resolve. Cache is bypassed.
    #[default]
    Never,
    /// One approval covers the rest of the session, capped by
    /// the TTL the dialog returns.
    Session,
    /// Every resolve prompts; cache is bypassed even if a
    /// matching entry exists.
    PerCall,
}

/// What a consumer must do before resolving a `@secret:<path>`
/// alias. Returned by [`SessionApprovalCache::evaluate`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalGate {
    /// Policy is `Never` — proceed straight to resolve, no
    /// dialog, no caching.
    NotRequired,
    /// Policy is `Session` AND a non-expired approval exists
    /// in the cache — the consumer may resolve without
    /// prompting again.
    AlreadyApproved,
    /// Either no cached approval, or policy is `PerCall`.
    /// The consumer must surface the
    /// `secrets_request_use_approval` dialog and observe the
    /// reply before resolving.
    PromptRequired,
}

#[derive(Debug, Clone)]
struct ApprovedAt {
    at: Instant,
    ttl: Duration,
}

impl ApprovedAt {
    fn is_live(&self) -> bool {
        self.at.elapsed() < self.ttl
    }
}

/// Process-lifetime cache of `AlwaysSession` approvals,
/// keyed by ADR-020 path. Mutex-guarded — accesses are
/// infrequent (one per resolve at most) and short.
#[derive(Debug, Default)]
pub struct SessionApprovalCache {
    entries: Mutex<HashMap<String, ApprovedAt>>,
}

impl SessionApprovalCache {
    pub fn new() -> Self {
        Self::default()
    }

    /// Cache a `Session`-scope approval for `path` with the
    /// given TTL. The TTL comes from the dialog's reply so a
    /// short-lived approval drops out of the cache once the
    /// agent's window expires.
    ///
    /// A second call for the same path replaces the previous
    /// entry — there is no contract on "earliest wins" or
    /// "latest wins" beyond that, but in practice the latest
    /// reply is the one the user actually saw.
    pub fn record_session(&self, path: impl Into<String>, ttl: Duration) {
        let mut state = self.entries.lock().expect("approval cache poisoned");
        state.insert(
            path.into(),
            ApprovedAt {
                at: Instant::now(),
                ttl,
            },
        );
    }

    /// `true` iff `path` has a non-expired session approval.
    /// Expired entries are dropped lazily on this call so the
    /// cache stays tidy without a background sweeper.
    pub fn is_approved(&self, path: &str) -> bool {
        let mut state = self.entries.lock().expect("approval cache poisoned");
        if let Some(entry) = state.get(path) {
            if entry.is_live() {
                return true;
            }
            state.remove(path);
        }
        false
    }

    /// Decide whether the consumer must prompt before
    /// resolving `path`. The single source of truth used by
    /// alias resolvers and the MCP proxy.
    pub fn evaluate(&self, path: &str, policy: ApproveOnUsePolicy) -> ApprovalGate {
        match policy {
            ApproveOnUsePolicy::Never => ApprovalGate::NotRequired,
            ApproveOnUsePolicy::PerCall => ApprovalGate::PromptRequired,
            ApproveOnUsePolicy::Session => {
                if self.is_approved(path) {
                    ApprovalGate::AlreadyApproved
                } else {
                    ApprovalGate::PromptRequired
                }
            }
        }
    }

    /// Drop the cached approval for `path` (if any). Call after
    /// a rotation so a freshly-rotated value re-prompts.
    /// Returns `true` if an entry was removed.
    pub fn forget(&self, path: &str) -> bool {
        let mut state = self.entries.lock().expect("approval cache poisoned");
        state.remove(path).is_some()
    }

    /// Drop every entry. Useful when the user clears the
    /// session manually from the inventory UI.
    pub fn clear(&self) {
        let mut state = self.entries.lock().expect("approval cache poisoned");
        state.clear();
    }

    /// Drop expired entries; returns the number swept.
    /// Optional housekeeping — the cache is correct without
    /// it because [`Self::is_approved`] cleans up on access.
    pub fn sweep_expired(&self) -> usize {
        let mut state = self.entries.lock().expect("approval cache poisoned");
        let before = state.len();
        state.retain(|_, e| e.is_live());
        before - state.len()
    }

    pub fn len(&self) -> usize {
        self.entries.lock().expect("approval cache poisoned").len()
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

// =============================================================================
// ApprovalGatedResolver — enforces the cache before a resolve
// =============================================================================

use std::sync::Arc;

use crate::alias::{AliasResolverError, SecretResolver};
use secrecy::SecretString;

/// Type-safe wrapper that enforces the approve-on-use policy
/// **before** dispatching to an inner [`SecretResolver`]. This is
/// what closes the loop on the P25 protocol — a resolver that
/// is not gated through this wrapper makes the
/// `approve_on_use` field a metadata-only theatrical control.
///
/// Construction takes three values:
///
/// 1. An inner `SecretResolver` (keychain, local-vault, 1Password,
///    …).
/// 2. An [`Arc<SessionApprovalCache>`] — shared across every gated
///    resolver in the process so the user only sees one prompt
///    per session per path.
/// 3. A `policy_for_path` closure — typically reads the path's
///    `approve_on_use` field from the merged manifest. The
///    closure shape avoids a hard dependency on `devboy-storage`
///    in this crate.
///
/// On every `resolve()` call:
///
/// - `ApproveOnUsePolicy::Never` → straight to the inner resolver.
/// - `ApproveOnUsePolicy::Session` with a cache hit → straight to
///   the inner resolver.
/// - `ApproveOnUsePolicy::Session` without a cache hit, or
///   `ApproveOnUsePolicy::PerCall` → return
///   [`AliasResolverError::Backend`] with a message that names the
///   path and the policy, so the caller can surface the approval
///   dialog and retry.
pub struct ApprovalGatedResolver<R, F>
where
    R: SecretResolver,
    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
    inner: R,
    cache: Arc<SessionApprovalCache>,
    policy_for_path: F,
}

impl<R, F> ApprovalGatedResolver<R, F>
where
    R: SecretResolver,
    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
    pub fn new(inner: R, cache: Arc<SessionApprovalCache>, policy_for_path: F) -> Self {
        Self {
            inner,
            cache,
            policy_for_path,
        }
    }

    /// Underlying cache handle — exposed so the orchestration
    /// layer (which drives the approval dialog) can call
    /// `record_session` after the user clicks "Allow always
    /// (this session)".
    pub fn cache(&self) -> &Arc<SessionApprovalCache> {
        &self.cache
    }
}

impl<R, F> SecretResolver for ApprovalGatedResolver<R, F>
where
    R: SecretResolver,
    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
    fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
        let policy = (self.policy_for_path)(path);
        match self.cache.evaluate(path, policy) {
            ApprovalGate::NotRequired | ApprovalGate::AlreadyApproved => self.inner.resolve(path),
            ApprovalGate::PromptRequired => {
                let label = match policy {
                    ApproveOnUsePolicy::Never => "never",
                    ApproveOnUsePolicy::Session => "session",
                    ApproveOnUsePolicy::PerCall => "per-call",
                };
                Err(AliasResolverError::Backend {
                    path: path.to_owned(),
                    message: format!(
                        "approve-on-use policy `{label}` requires user approval; \
                         surface secrets_request_use_approval and retry"
                    ),
                })
            }
        }
    }
}

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

    fn ttl_long() -> Duration {
        Duration::from_secs(300)
    }

    // -- evaluate ---------------------------------------------------

    #[test]
    fn evaluate_never_policy_returns_not_required() {
        let cache = SessionApprovalCache::new();
        assert_eq!(
            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Never),
            ApprovalGate::NotRequired
        );
    }

    #[test]
    fn evaluate_per_call_always_prompts_even_with_cache_hit() {
        let cache = SessionApprovalCache::new();
        cache.record_session("team/jira/api-key", ttl_long());
        assert_eq!(
            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::PerCall),
            ApprovalGate::PromptRequired
        );
    }

    #[test]
    fn evaluate_session_returns_already_approved_when_cached() {
        let cache = SessionApprovalCache::new();
        cache.record_session("team/jira/api-key", ttl_long());
        assert_eq!(
            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
            ApprovalGate::AlreadyApproved
        );
    }

    #[test]
    fn evaluate_session_prompts_when_cache_miss() {
        let cache = SessionApprovalCache::new();
        assert_eq!(
            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
            ApprovalGate::PromptRequired
        );
    }

    // -- TTL --------------------------------------------------------

    #[test]
    fn cached_approval_expires_after_ttl() {
        let cache = SessionApprovalCache::new();
        cache.record_session("team/jira/api-key", Duration::from_millis(20));
        sleep(Duration::from_millis(40));
        assert_eq!(
            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
            ApprovalGate::PromptRequired
        );
    }

    #[test]
    fn is_approved_drops_expired_entry_lazily() {
        let cache = SessionApprovalCache::new();
        cache.record_session("a/b/c", Duration::from_millis(10));
        sleep(Duration::from_millis(20));
        assert!(!cache.is_approved("a/b/c"));
        assert_eq!(cache.len(), 0, "expired entry should be evicted on access");
    }

    // -- forget / clear --------------------------------------------

    #[test]
    fn forget_evicts_existing_entry() {
        let cache = SessionApprovalCache::new();
        cache.record_session("a/b/c", ttl_long());
        assert!(cache.forget("a/b/c"));
        assert!(!cache.is_approved("a/b/c"));
    }

    #[test]
    fn forget_returns_false_for_missing_entry() {
        let cache = SessionApprovalCache::new();
        assert!(!cache.forget("a/b/c"));
    }

    #[test]
    fn clear_drops_all_entries() {
        let cache = SessionApprovalCache::new();
        cache.record_session("a/b/c", ttl_long());
        cache.record_session("d/e/f", ttl_long());
        cache.clear();
        assert!(cache.is_empty());
    }

    // -- replace ----------------------------------------------------

    #[test]
    fn record_session_replaces_existing_entry() {
        let cache = SessionApprovalCache::new();
        cache.record_session("a/b/c", Duration::from_millis(10));
        sleep(Duration::from_millis(20));
        // First entry is now stale — record a fresh long-lived
        // approval. The next is_approved call must report true.
        cache.record_session("a/b/c", ttl_long());
        assert!(cache.is_approved("a/b/c"));
    }

    // -- sweep ------------------------------------------------------

    #[test]
    fn sweep_expired_drops_only_stale_entries() {
        let cache = SessionApprovalCache::new();
        cache.record_session("stale", Duration::from_millis(10));
        cache.record_session("fresh", ttl_long());
        sleep(Duration::from_millis(20));
        assert_eq!(cache.sweep_expired(), 1);
        assert!(cache.is_approved("fresh"));
        assert!(!cache.is_approved("stale"));
    }

    // -- ApprovalGatedResolver --------------------------------------

    use crate::alias::{AliasResolverError, SecretResolver};
    use secrecy::{ExposeSecret, SecretString};
    use std::sync::Mutex;

    /// Minimal in-memory resolver for gating tests. Counts
    /// calls so we can assert the gate short-circuits.
    struct CountingResolver {
        secrets: std::collections::HashMap<String, String>,
        calls: Mutex<u32>,
    }

    impl CountingResolver {
        fn new(entries: &[(&str, &str)]) -> Self {
            Self {
                secrets: entries
                    .iter()
                    .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
                    .collect(),
                calls: Mutex::new(0),
            }
        }
    }

    impl SecretResolver for CountingResolver {
        fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
            *self.calls.lock().unwrap() += 1;
            self.secrets
                .get(path)
                .map(|v| SecretString::from(v.clone()))
                .ok_or_else(|| AliasResolverError::NotFound {
                    path: path.to_owned(),
                })
        }
    }

    #[test]
    fn gated_resolver_passes_through_never_policy() {
        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
        let cache = Arc::new(SessionApprovalCache::new());
        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Never);
        let v = gated.resolve("team/x/y").unwrap();
        assert_eq!(v.expose_secret(), "value-1");
    }

    #[test]
    fn gated_resolver_refuses_session_policy_without_cache_hit() {
        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
        let cache = Arc::new(SessionApprovalCache::new());
        let gated =
            ApprovalGatedResolver::new(inner, cache.clone(), |_| ApproveOnUsePolicy::Session);
        let err = gated.resolve("team/x/y").unwrap_err();
        match err {
            AliasResolverError::Backend { path, message } => {
                assert_eq!(path, "team/x/y");
                assert!(
                    message.contains("session") && message.contains("user approval"),
                    "unexpected message: {message}"
                );
            }
            other => panic!("expected Backend gate-required error, got {other:?}"),
        }
        // Inner resolver must NOT have been touched.
        // (We can't borrow the inner directly through the gate;
        // a fresh assertion below validates the same thing with
        // an explicit count.)
    }

    #[test]
    fn gated_resolver_passes_session_policy_after_cache_record() {
        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
        let cache = Arc::new(SessionApprovalCache::new());
        cache.record_session("team/x/y", ttl_long());
        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Session);
        let v = gated.resolve("team/x/y").unwrap();
        assert_eq!(v.expose_secret(), "value-1");
    }

    #[test]
    fn gated_resolver_always_refuses_per_call_even_with_cache() {
        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
        let cache = Arc::new(SessionApprovalCache::new());
        cache.record_session("team/x/y", ttl_long());
        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::PerCall);
        let err = gated.resolve("team/x/y").unwrap_err();
        assert!(matches!(err, AliasResolverError::Backend { .. }));
    }

    #[test]
    fn gated_resolver_does_not_touch_inner_on_refusal() {
        // Build the inner outside the gate so we can re-read its
        // call count after the refusal.
        let cache = Arc::new(SessionApprovalCache::new());
        let inner_box: Box<dyn SecretResolver> =
            Box::new(CountingResolver::new(&[("team/x/y", "value-1")]));
        // Use a sneak: build the gate on an Arc-shared resolver
        // via &dyn. A small adapter that owns nothing and just
        // proxies the call count check is simpler.
        let counter = Arc::new(Mutex::new(0u32));
        let counter_clone = Arc::clone(&counter);
        struct ProxyResolver {
            inner: Box<dyn SecretResolver>,
            counter: Arc<Mutex<u32>>,
        }
        impl SecretResolver for ProxyResolver {
            fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
                *self.counter.lock().unwrap() += 1;
                self.inner.resolve(path)
            }
        }
        let proxy = ProxyResolver {
            inner: inner_box,
            counter: counter_clone,
        };
        let gated = ApprovalGatedResolver::new(proxy, cache, |_| ApproveOnUsePolicy::Session);
        let _ = gated.resolve("team/x/y").unwrap_err();
        assert_eq!(
            *counter.lock().unwrap(),
            0,
            "inner resolver must not be touched on gate refusal"
        );
    }

    #[test]
    fn gated_resolver_call_count_zero_after_refusal() {
        let cache = Arc::new(SessionApprovalCache::new());
        let inner = CountingResolver::new(&[("team/prod-db/password", "v")]);
        let gated = ApprovalGatedResolver::new(inner, cache, |path| {
            if path == "team/prod-db/password" {
                ApproveOnUsePolicy::PerCall
            } else {
                ApproveOnUsePolicy::Never
            }
        });
        let _ = gated.resolve("team/prod-db/password").unwrap_err();
        // can't observe inner.call_count() here because the
        // gate owns inner; the wrapper invariant is enforced
        // by the previous test using ProxyResolver. This test
        // just exercises the per-path policy closure shape.
    }

    #[test]
    fn gated_resolver_cache_accessor_exposes_handle_for_orchestrator() {
        let inner = CountingResolver::new(&[]);
        let cache = Arc::new(SessionApprovalCache::new());
        let gated =
            ApprovalGatedResolver::new(inner, Arc::clone(&cache), |_| ApproveOnUsePolicy::Session);
        // The orchestration layer needs to record the approval
        // after the user clicks "Allow always (this session)";
        // it does so through the cached handle.
        gated.cache().record_session("a/b/c", ttl_long());
        assert!(cache.is_approved("a/b/c"));
    }
}