Skip to main content

devboy_core/
secret_approval.rs

1//! Per-session approve-on-use cache for `@secret:<path>`
2//! resolution per [ADR-023] §3.7 (P25.4).
3//!
4//! When a manifest entry's `approve_on_use` is `Session` or
5//! `PerCall`, every alias resolve must surface the
6//! `secrets_request_use_approval` dialog before the value
7//! reaches the consumer. The agent picks one of three
8//! decisions:
9//!
10//! - `Once` — single resolve, no caching.
11//! - `AlwaysSession` — cache the approval for the chosen TTL.
12//! - `Deny` — refuse the resolve.
13//!
14//! [`SessionApprovalCache`] holds the `AlwaysSession` decisions
15//! for the lifetime of one process. The cache is intentionally
16//! *advisory*: it lives in `devboy-core` (the lowest leaf of
17//! the dependency graph) so any consumer — config loader,
18//! router, MCP server — can reuse the same gate logic without
19//! pulling in `devboy-storage` or the dialog crate.
20//!
21//! The dialog and the storage manifest both stay decoupled
22//! from this module: `devboy-storage` exposes the
23//! `ApproveOnUse` enum on its `IndexEntry`, and a small
24//! [`From`] bridge in that crate turns it into the local
25//! [`ApproveOnUsePolicy`] enum so this cache stays
26//! dependency-free.
27//!
28//! [ADR-023]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md
29
30use std::collections::HashMap;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34/// Mirror of `devboy_storage::index::ApproveOnUse` exposed
35/// here so the cache is reachable from `devboy-core` without a
36/// circular dependency. `devboy-storage` provides a `From` impl
37/// from its own enum.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub enum ApproveOnUsePolicy {
40    /// Default — zero-prompt resolve. Cache is bypassed.
41    #[default]
42    Never,
43    /// One approval covers the rest of the session, capped by
44    /// the TTL the dialog returns.
45    Session,
46    /// Every resolve prompts; cache is bypassed even if a
47    /// matching entry exists.
48    PerCall,
49}
50
51/// What a consumer must do before resolving a `@secret:<path>`
52/// alias. Returned by [`SessionApprovalCache::evaluate`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ApprovalGate {
55    /// Policy is `Never` — proceed straight to resolve, no
56    /// dialog, no caching.
57    NotRequired,
58    /// Policy is `Session` AND a non-expired approval exists
59    /// in the cache — the consumer may resolve without
60    /// prompting again.
61    AlreadyApproved,
62    /// Either no cached approval, or policy is `PerCall`.
63    /// The consumer must surface the
64    /// `secrets_request_use_approval` dialog and observe the
65    /// reply before resolving.
66    PromptRequired,
67}
68
69#[derive(Debug, Clone)]
70struct ApprovedAt {
71    at: Instant,
72    ttl: Duration,
73}
74
75impl ApprovedAt {
76    fn is_live(&self) -> bool {
77        self.at.elapsed() < self.ttl
78    }
79}
80
81/// Process-lifetime cache of `AlwaysSession` approvals,
82/// keyed by ADR-020 path. Mutex-guarded — accesses are
83/// infrequent (one per resolve at most) and short.
84#[derive(Debug, Default)]
85pub struct SessionApprovalCache {
86    entries: Mutex<HashMap<String, ApprovedAt>>,
87}
88
89impl SessionApprovalCache {
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Cache a `Session`-scope approval for `path` with the
95    /// given TTL. The TTL comes from the dialog's reply so a
96    /// short-lived approval drops out of the cache once the
97    /// agent's window expires.
98    ///
99    /// A second call for the same path replaces the previous
100    /// entry — there is no contract on "earliest wins" or
101    /// "latest wins" beyond that, but in practice the latest
102    /// reply is the one the user actually saw.
103    pub fn record_session(&self, path: impl Into<String>, ttl: Duration) {
104        let mut state = self.entries.lock().expect("approval cache poisoned");
105        state.insert(
106            path.into(),
107            ApprovedAt {
108                at: Instant::now(),
109                ttl,
110            },
111        );
112    }
113
114    /// `true` iff `path` has a non-expired session approval.
115    /// Expired entries are dropped lazily on this call so the
116    /// cache stays tidy without a background sweeper.
117    pub fn is_approved(&self, path: &str) -> bool {
118        let mut state = self.entries.lock().expect("approval cache poisoned");
119        if let Some(entry) = state.get(path) {
120            if entry.is_live() {
121                return true;
122            }
123            state.remove(path);
124        }
125        false
126    }
127
128    /// Decide whether the consumer must prompt before
129    /// resolving `path`. The single source of truth used by
130    /// alias resolvers and the MCP proxy.
131    pub fn evaluate(&self, path: &str, policy: ApproveOnUsePolicy) -> ApprovalGate {
132        match policy {
133            ApproveOnUsePolicy::Never => ApprovalGate::NotRequired,
134            ApproveOnUsePolicy::PerCall => ApprovalGate::PromptRequired,
135            ApproveOnUsePolicy::Session => {
136                if self.is_approved(path) {
137                    ApprovalGate::AlreadyApproved
138                } else {
139                    ApprovalGate::PromptRequired
140                }
141            }
142        }
143    }
144
145    /// Drop the cached approval for `path` (if any). Call after
146    /// a rotation so a freshly-rotated value re-prompts.
147    /// Returns `true` if an entry was removed.
148    pub fn forget(&self, path: &str) -> bool {
149        let mut state = self.entries.lock().expect("approval cache poisoned");
150        state.remove(path).is_some()
151    }
152
153    /// Drop every entry. Useful when the user clears the
154    /// session manually from the inventory UI.
155    pub fn clear(&self) {
156        let mut state = self.entries.lock().expect("approval cache poisoned");
157        state.clear();
158    }
159
160    /// Drop expired entries; returns the number swept.
161    /// Optional housekeeping — the cache is correct without
162    /// it because [`Self::is_approved`] cleans up on access.
163    pub fn sweep_expired(&self) -> usize {
164        let mut state = self.entries.lock().expect("approval cache poisoned");
165        let before = state.len();
166        state.retain(|_, e| e.is_live());
167        before - state.len()
168    }
169
170    pub fn len(&self) -> usize {
171        self.entries.lock().expect("approval cache poisoned").len()
172    }
173
174    pub fn is_empty(&self) -> bool {
175        self.len() == 0
176    }
177}
178
179// =============================================================================
180// ApprovalGatedResolver — enforces the cache before a resolve
181// =============================================================================
182
183use std::sync::Arc;
184
185use crate::alias::{AliasResolverError, SecretResolver};
186use secrecy::SecretString;
187
188/// Type-safe wrapper that enforces the approve-on-use policy
189/// **before** dispatching to an inner [`SecretResolver`]. This is
190/// what closes the loop on the P25 protocol — a resolver that
191/// is not gated through this wrapper makes the
192/// `approve_on_use` field a metadata-only theatrical control.
193///
194/// Construction takes three values:
195///
196/// 1. An inner `SecretResolver` (keychain, local-vault, 1Password,
197///    …).
198/// 2. An [`Arc<SessionApprovalCache>`] — shared across every gated
199///    resolver in the process so the user only sees one prompt
200///    per session per path.
201/// 3. A `policy_for_path` closure — typically reads the path's
202///    `approve_on_use` field from the merged manifest. The
203///    closure shape avoids a hard dependency on `devboy-storage`
204///    in this crate.
205///
206/// On every `resolve()` call:
207///
208/// - `ApproveOnUsePolicy::Never` → straight to the inner resolver.
209/// - `ApproveOnUsePolicy::Session` with a cache hit → straight to
210///   the inner resolver.
211/// - `ApproveOnUsePolicy::Session` without a cache hit, or
212///   `ApproveOnUsePolicy::PerCall` → return
213///   [`AliasResolverError::Backend`] with a message that names the
214///   path and the policy, so the caller can surface the approval
215///   dialog and retry.
216pub struct ApprovalGatedResolver<R, F>
217where
218    R: SecretResolver,
219    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
220{
221    inner: R,
222    cache: Arc<SessionApprovalCache>,
223    policy_for_path: F,
224}
225
226impl<R, F> ApprovalGatedResolver<R, F>
227where
228    R: SecretResolver,
229    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
230{
231    pub fn new(inner: R, cache: Arc<SessionApprovalCache>, policy_for_path: F) -> Self {
232        Self {
233            inner,
234            cache,
235            policy_for_path,
236        }
237    }
238
239    /// Underlying cache handle — exposed so the orchestration
240    /// layer (which drives the approval dialog) can call
241    /// `record_session` after the user clicks "Allow always
242    /// (this session)".
243    pub fn cache(&self) -> &Arc<SessionApprovalCache> {
244        &self.cache
245    }
246}
247
248impl<R, F> SecretResolver for ApprovalGatedResolver<R, F>
249where
250    R: SecretResolver,
251    F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
252{
253    fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
254        let policy = (self.policy_for_path)(path);
255        match self.cache.evaluate(path, policy) {
256            ApprovalGate::NotRequired | ApprovalGate::AlreadyApproved => self.inner.resolve(path),
257            ApprovalGate::PromptRequired => {
258                let label = match policy {
259                    ApproveOnUsePolicy::Never => "never",
260                    ApproveOnUsePolicy::Session => "session",
261                    ApproveOnUsePolicy::PerCall => "per-call",
262                };
263                Err(AliasResolverError::Backend {
264                    path: path.to_owned(),
265                    message: format!(
266                        "approve-on-use policy `{label}` requires user approval; \
267                         surface secrets_request_use_approval and retry"
268                    ),
269                })
270            }
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use std::thread::sleep;
279
280    fn ttl_long() -> Duration {
281        Duration::from_secs(300)
282    }
283
284    // -- evaluate ---------------------------------------------------
285
286    #[test]
287    fn evaluate_never_policy_returns_not_required() {
288        let cache = SessionApprovalCache::new();
289        assert_eq!(
290            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Never),
291            ApprovalGate::NotRequired
292        );
293    }
294
295    #[test]
296    fn evaluate_per_call_always_prompts_even_with_cache_hit() {
297        let cache = SessionApprovalCache::new();
298        cache.record_session("team/jira/api-key", ttl_long());
299        assert_eq!(
300            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::PerCall),
301            ApprovalGate::PromptRequired
302        );
303    }
304
305    #[test]
306    fn evaluate_session_returns_already_approved_when_cached() {
307        let cache = SessionApprovalCache::new();
308        cache.record_session("team/jira/api-key", ttl_long());
309        assert_eq!(
310            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
311            ApprovalGate::AlreadyApproved
312        );
313    }
314
315    #[test]
316    fn evaluate_session_prompts_when_cache_miss() {
317        let cache = SessionApprovalCache::new();
318        assert_eq!(
319            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
320            ApprovalGate::PromptRequired
321        );
322    }
323
324    // -- TTL --------------------------------------------------------
325
326    #[test]
327    fn cached_approval_expires_after_ttl() {
328        let cache = SessionApprovalCache::new();
329        cache.record_session("team/jira/api-key", Duration::from_millis(20));
330        sleep(Duration::from_millis(40));
331        assert_eq!(
332            cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
333            ApprovalGate::PromptRequired
334        );
335    }
336
337    #[test]
338    fn is_approved_drops_expired_entry_lazily() {
339        let cache = SessionApprovalCache::new();
340        cache.record_session("a/b/c", Duration::from_millis(10));
341        sleep(Duration::from_millis(20));
342        assert!(!cache.is_approved("a/b/c"));
343        assert_eq!(cache.len(), 0, "expired entry should be evicted on access");
344    }
345
346    // -- forget / clear --------------------------------------------
347
348    #[test]
349    fn forget_evicts_existing_entry() {
350        let cache = SessionApprovalCache::new();
351        cache.record_session("a/b/c", ttl_long());
352        assert!(cache.forget("a/b/c"));
353        assert!(!cache.is_approved("a/b/c"));
354    }
355
356    #[test]
357    fn forget_returns_false_for_missing_entry() {
358        let cache = SessionApprovalCache::new();
359        assert!(!cache.forget("a/b/c"));
360    }
361
362    #[test]
363    fn clear_drops_all_entries() {
364        let cache = SessionApprovalCache::new();
365        cache.record_session("a/b/c", ttl_long());
366        cache.record_session("d/e/f", ttl_long());
367        cache.clear();
368        assert!(cache.is_empty());
369    }
370
371    // -- replace ----------------------------------------------------
372
373    #[test]
374    fn record_session_replaces_existing_entry() {
375        let cache = SessionApprovalCache::new();
376        cache.record_session("a/b/c", Duration::from_millis(10));
377        sleep(Duration::from_millis(20));
378        // First entry is now stale — record a fresh long-lived
379        // approval. The next is_approved call must report true.
380        cache.record_session("a/b/c", ttl_long());
381        assert!(cache.is_approved("a/b/c"));
382    }
383
384    // -- sweep ------------------------------------------------------
385
386    #[test]
387    fn sweep_expired_drops_only_stale_entries() {
388        let cache = SessionApprovalCache::new();
389        cache.record_session("stale", Duration::from_millis(10));
390        cache.record_session("fresh", ttl_long());
391        sleep(Duration::from_millis(20));
392        assert_eq!(cache.sweep_expired(), 1);
393        assert!(cache.is_approved("fresh"));
394        assert!(!cache.is_approved("stale"));
395    }
396
397    // -- ApprovalGatedResolver --------------------------------------
398
399    use crate::alias::{AliasResolverError, SecretResolver};
400    use secrecy::{ExposeSecret, SecretString};
401    use std::sync::Mutex;
402
403    /// Minimal in-memory resolver for gating tests. Counts
404    /// calls so we can assert the gate short-circuits.
405    struct CountingResolver {
406        secrets: std::collections::HashMap<String, String>,
407        calls: Mutex<u32>,
408    }
409
410    impl CountingResolver {
411        fn new(entries: &[(&str, &str)]) -> Self {
412            Self {
413                secrets: entries
414                    .iter()
415                    .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
416                    .collect(),
417                calls: Mutex::new(0),
418            }
419        }
420    }
421
422    impl SecretResolver for CountingResolver {
423        fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
424            *self.calls.lock().unwrap() += 1;
425            self.secrets
426                .get(path)
427                .map(|v| SecretString::from(v.clone()))
428                .ok_or_else(|| AliasResolverError::NotFound {
429                    path: path.to_owned(),
430                })
431        }
432    }
433
434    #[test]
435    fn gated_resolver_passes_through_never_policy() {
436        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
437        let cache = Arc::new(SessionApprovalCache::new());
438        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Never);
439        let v = gated.resolve("team/x/y").unwrap();
440        assert_eq!(v.expose_secret(), "value-1");
441    }
442
443    #[test]
444    fn gated_resolver_refuses_session_policy_without_cache_hit() {
445        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
446        let cache = Arc::new(SessionApprovalCache::new());
447        let gated =
448            ApprovalGatedResolver::new(inner, cache.clone(), |_| ApproveOnUsePolicy::Session);
449        let err = gated.resolve("team/x/y").unwrap_err();
450        match err {
451            AliasResolverError::Backend { path, message } => {
452                assert_eq!(path, "team/x/y");
453                assert!(
454                    message.contains("session") && message.contains("user approval"),
455                    "unexpected message: {message}"
456                );
457            }
458            other => panic!("expected Backend gate-required error, got {other:?}"),
459        }
460        // Inner resolver must NOT have been touched.
461        // (We can't borrow the inner directly through the gate;
462        // a fresh assertion below validates the same thing with
463        // an explicit count.)
464    }
465
466    #[test]
467    fn gated_resolver_passes_session_policy_after_cache_record() {
468        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
469        let cache = Arc::new(SessionApprovalCache::new());
470        cache.record_session("team/x/y", ttl_long());
471        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Session);
472        let v = gated.resolve("team/x/y").unwrap();
473        assert_eq!(v.expose_secret(), "value-1");
474    }
475
476    #[test]
477    fn gated_resolver_always_refuses_per_call_even_with_cache() {
478        let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
479        let cache = Arc::new(SessionApprovalCache::new());
480        cache.record_session("team/x/y", ttl_long());
481        let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::PerCall);
482        let err = gated.resolve("team/x/y").unwrap_err();
483        assert!(matches!(err, AliasResolverError::Backend { .. }));
484    }
485
486    #[test]
487    fn gated_resolver_does_not_touch_inner_on_refusal() {
488        // Build the inner outside the gate so we can re-read its
489        // call count after the refusal.
490        let cache = Arc::new(SessionApprovalCache::new());
491        let inner_box: Box<dyn SecretResolver> =
492            Box::new(CountingResolver::new(&[("team/x/y", "value-1")]));
493        // Use a sneak: build the gate on an Arc-shared resolver
494        // via &dyn. A small adapter that owns nothing and just
495        // proxies the call count check is simpler.
496        let counter = Arc::new(Mutex::new(0u32));
497        let counter_clone = Arc::clone(&counter);
498        struct ProxyResolver {
499            inner: Box<dyn SecretResolver>,
500            counter: Arc<Mutex<u32>>,
501        }
502        impl SecretResolver for ProxyResolver {
503            fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
504                *self.counter.lock().unwrap() += 1;
505                self.inner.resolve(path)
506            }
507        }
508        let proxy = ProxyResolver {
509            inner: inner_box,
510            counter: counter_clone,
511        };
512        let gated = ApprovalGatedResolver::new(proxy, cache, |_| ApproveOnUsePolicy::Session);
513        let _ = gated.resolve("team/x/y").unwrap_err();
514        assert_eq!(
515            *counter.lock().unwrap(),
516            0,
517            "inner resolver must not be touched on gate refusal"
518        );
519    }
520
521    #[test]
522    fn gated_resolver_call_count_zero_after_refusal() {
523        let cache = Arc::new(SessionApprovalCache::new());
524        let inner = CountingResolver::new(&[("team/prod-db/password", "v")]);
525        let gated = ApprovalGatedResolver::new(inner, cache, |path| {
526            if path == "team/prod-db/password" {
527                ApproveOnUsePolicy::PerCall
528            } else {
529                ApproveOnUsePolicy::Never
530            }
531        });
532        let _ = gated.resolve("team/prod-db/password").unwrap_err();
533        // can't observe inner.call_count() here because the
534        // gate owns inner; the wrapper invariant is enforced
535        // by the previous test using ProxyResolver. This test
536        // just exercises the per-path policy closure shape.
537    }
538
539    #[test]
540    fn gated_resolver_cache_accessor_exposes_handle_for_orchestrator() {
541        let inner = CountingResolver::new(&[]);
542        let cache = Arc::new(SessionApprovalCache::new());
543        let gated =
544            ApprovalGatedResolver::new(inner, Arc::clone(&cache), |_| ApproveOnUsePolicy::Session);
545        // The orchestration layer needs to record the approval
546        // after the user clicks "Allow always (this session)";
547        // it does so through the cached handle.
548        gated.cache().record_session("a/b/c", ttl_long());
549        assert!(cache.is_approved("a/b/c"));
550    }
551}