Skip to main content

zeph_subagent/
grants.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Zero-trust TTL-bounded permission grants for sub-agents.
5//!
6//! [`PermissionGrants`] tracks active grants (vault secrets or runtime tool access)
7//! for a running sub-agent. All grants are time-limited; expired grants are swept
8//! lazily by [`PermissionGrants::is_active`] and eagerly by
9//! [`PermissionGrants::sweep_expired`].
10//!
11//! Grants are revoked on drop and on agent completion/cancellation. Secret key names
12//! are never logged above DEBUG level; the `Display` impl for [`GrantKind::Secret`]
13//! always prints `"Secret(<redacted>)"`.
14
15use std::time::{Duration, Instant};
16
17use serde::{Deserialize, Serialize};
18use zeph_common::secret::Secret;
19
20/// Metadata sent by a sub-agent when it needs a secret from the vault.
21///
22/// Carried in an `InputRequired` A2A status update as structured metadata.
23/// The parent agent surfaces this to the user as an approval prompt; the user can
24/// then call [`SubAgentManager::approve_secret`][crate::SubAgentManager] or
25/// [`SubAgentManager::deny_secret`][crate::SubAgentManager].
26///
27/// # Examples
28///
29/// ```rust
30/// use zeph_subagent::grants::SecretRequest;
31///
32/// let req = SecretRequest {
33///     secret_key: "OPENAI_API_KEY".to_owned(),
34///     reason: Some("needed for embeddings".to_owned()),
35/// };
36/// assert_eq!(req.secret_key, "OPENAI_API_KEY");
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SecretRequest {
40    /// The vault key name the sub-agent is requesting.
41    pub secret_key: String,
42    /// Human-readable reason (shown to the user in the approval prompt).
43    pub reason: Option<String>,
44}
45
46/// Identifies the kind of permission that was granted to a sub-agent.
47///
48/// `GrantKind` is intentionally NOT serializable — grant metadata should never
49/// leave the in-memory security boundary. Key names are logged only at DEBUG
50/// level to avoid leaking grant enumeration to centralized log systems.
51///
52/// The [`Display`][std::fmt::Display] implementation always redacts `Secret` payloads,
53/// printing `Secret(<redacted>)` instead of the actual key name.
54///
55/// # Examples
56///
57/// ```rust
58/// use zeph_subagent::grants::GrantKind;
59///
60/// let secret = GrantKind::Secret("my-key".to_owned());
61/// assert!(!secret.to_string().contains("my-key"), "key must be redacted");
62///
63/// let tool = GrantKind::Tool("shell".to_owned());
64/// assert_eq!(tool.to_string(), "Tool(shell)");
65/// ```
66#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum GrantKind {
69    /// A vault secret key granted for in-memory access.
70    Secret(String),
71    /// A tool name granted at runtime beyond the definition's static policy.
72    Tool(String),
73}
74
75impl std::fmt::Display for GrantKind {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::Secret(_) => write!(f, "Secret(<redacted>)"),
79            Self::Tool(name) => write!(f, "Tool({name})"),
80        }
81    }
82}
83
84/// A single permission grant with a TTL.
85///
86/// Created via [`PermissionGrants::add`] and swept automatically by
87/// [`PermissionGrants::sweep_expired`].
88#[derive(Debug)]
89pub struct Grant {
90    pub(crate) kind: GrantKind,
91    pub(crate) granted_at: Instant,
92    pub(crate) ttl: Duration,
93}
94
95impl Grant {
96    /// Create a new grant for `kind` that expires after `ttl`.
97    ///
98    /// # Examples
99    ///
100    /// ```rust
101    /// use std::time::Duration;
102    /// use zeph_subagent::grants::{Grant, GrantKind};
103    ///
104    /// let grant = Grant::new(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
105    /// assert!(!grant.is_expired());
106    /// ```
107    #[must_use]
108    pub fn new(kind: GrantKind, ttl: Duration) -> Self {
109        Self {
110            kind,
111            granted_at: Instant::now(),
112            ttl,
113        }
114    }
115
116    /// Returns `true` if the grant's TTL has elapsed.
117    ///
118    /// # Examples
119    ///
120    /// ```rust
121    /// use std::time::Duration;
122    /// use zeph_subagent::grants::{Grant, GrantKind};
123    ///
124    /// let grant = Grant::new(GrantKind::Tool("web".to_owned()), Duration::from_mins(5));
125    /// // A brand-new grant is not yet expired.
126    /// assert!(!grant.is_expired());
127    /// ```
128    #[must_use]
129    pub fn is_expired(&self) -> bool {
130        self.granted_at.elapsed() >= self.ttl
131    }
132}
133
134/// Tracks active zero-trust permission grants for a sub-agent.
135///
136/// All grants are TTL-bounded. [`is_active`](Self::is_active) automatically
137/// sweeps expired grants before checking, so callers do not need to call
138/// [`sweep_expired`](Self::sweep_expired) manually.
139#[derive(Debug, Default)]
140pub struct PermissionGrants {
141    grants: Vec<Grant>,
142}
143
144impl Drop for PermissionGrants {
145    fn drop(&mut self) {
146        // Defense-in-depth: revoke all grants on drop even if revoke_all()
147        // was not explicitly called (e.g., on panic or early return).
148        if !self.grants.is_empty() {
149            tracing::warn!(
150                count = self.grants.len(),
151                "PermissionGrants dropped with active grants — revoking"
152            );
153            self.grants.clear();
154        }
155    }
156}
157
158impl PermissionGrants {
159    /// Add a new grant with the given `kind` and `ttl`.
160    ///
161    /// The grant is immediately tracked. Expired grants are not swept here;
162    /// call [`sweep_expired`][Self::sweep_expired] or [`is_active`][Self::is_active]
163    /// to remove stale entries.
164    ///
165    /// # Examples
166    ///
167    /// ```rust
168    /// use std::time::Duration;
169    /// use zeph_subagent::grants::{GrantKind, PermissionGrants};
170    ///
171    /// let mut grants = PermissionGrants::default();
172    /// grants.add(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
173    /// assert!(grants.is_active(&GrantKind::Tool("shell".to_owned())));
174    /// ```
175    pub fn add(&mut self, kind: GrantKind, ttl: Duration) {
176        // Log tool grants at DEBUG; for secrets log only the redacted display form.
177        tracing::debug!(kind = %kind, ?ttl, "permission grant added");
178        self.grants.push(Grant::new(kind, ttl));
179    }
180
181    /// Remove all expired grants.
182    pub fn sweep_expired(&mut self) {
183        let expired: Vec<_> = self.grants.extract_if(.., |g| g.is_expired()).collect();
184        for g in &expired {
185            tracing::debug!(kind = %g.kind, "permission grant expired and revoked");
186        }
187        if !expired.is_empty() {
188            tracing::debug!(removed = expired.len(), "swept expired grants");
189        }
190    }
191
192    /// Check if a specific grant is still active (not expired).
193    ///
194    /// Automatically sweeps expired grants before checking.
195    #[must_use]
196    pub fn is_active(&mut self, kind: &GrantKind) -> bool {
197        self.sweep_expired();
198        self.grants.iter().any(|g| &g.kind == kind)
199    }
200
201    /// Returns the absolute instant at which the active grant for `kind` expires.
202    ///
203    /// Automatically sweeps expired grants before checking, so a `None` result means
204    /// there is no active grant for `kind` (never granted, already expired, or revoked).
205    /// Used by [`SubAgentManager::deliver_secret`][crate::manager::SubAgentManager::deliver_secret]
206    /// to stamp the delivered value with its expiry so the sub-agent loop can re-validate the
207    /// TTL locally on every subsequent tool call, without needing further access to this
208    /// `PermissionGrants` instance (which stays on the manager side, not the spawned loop task).
209    ///
210    /// If duplicate grants exist for the same `kind`, this returns the *first* match's
211    /// expiry rather than the latest (max) one. This is intentionally fail-safe: it can
212    /// only cause an earlier-than-necessary secret eviction in the sub-agent loop, never
213    /// a later one, so it is not a security concern — just a minor inefficiency in the
214    /// rare duplicate-grant case.
215    ///
216    /// # Examples
217    ///
218    /// ```rust
219    /// use std::time::Duration;
220    /// use zeph_subagent::grants::{GrantKind, PermissionGrants};
221    ///
222    /// let mut grants = PermissionGrants::default();
223    /// let kind = GrantKind::Secret("api-key".to_owned());
224    /// assert!(grants.expires_at(&kind).is_none());
225    ///
226    /// grants.add(kind.clone(), Duration::from_mins(5));
227    /// assert!(grants.expires_at(&kind).is_some());
228    /// ```
229    #[must_use]
230    pub fn expires_at(&mut self, kind: &GrantKind) -> Option<Instant> {
231        self.sweep_expired();
232        self.grants
233            .iter()
234            .find(|g| &g.kind == kind)
235            .map(|g| g.granted_at + g.ttl)
236    }
237
238    /// Grant access to a vault secret with the given TTL.
239    ///
240    /// Sweeps expired grants first. Logs an audit event at DEBUG (key is redacted
241    /// in the log output to avoid leaking grant enumeration to log aggregators).
242    pub fn grant_secret(&mut self, key: impl Into<String>, ttl: Duration) {
243        self.sweep_expired();
244        let key = key.into();
245        tracing::debug!("vault secret granted to sub-agent (key redacted), ttl={ttl:?}");
246        self.add(GrantKind::Secret(key), ttl);
247    }
248
249    /// Returns `true` if there are any grants currently tracked (expired or not).
250    ///
251    /// Used by [`Drop`] to emit a warning when handles are dropped without cleanup.
252    #[must_use]
253    pub fn is_empty_grants(&self) -> bool {
254        self.grants.is_empty()
255    }
256
257    /// Revoke all grants immediately (called on sub-agent completion or cancellation).
258    pub fn revoke_all(&mut self) {
259        let count = self.grants.len();
260        self.grants.clear();
261        if count > 0 {
262            tracing::debug!(count, "all permission grants revoked");
263        }
264    }
265}
266
267/// A resolved secret value delivered to a sub-agent loop, paired with the absolute
268/// instant its originating grant expires.
269///
270/// Sent over the `secret_tx`/`secret_rx` channel
271/// (see [`SubAgentHandle::secret_tx`][crate::manager::SubAgentHandle::secret_tx]) instead of a
272/// bare [`Secret`] so the spawned agent loop task — which has no further access to the
273/// manager-side [`PermissionGrants`] once the value is delivered — can still re-validate the
274/// TTL locally before every tool call and evict the value once it expires.
275///
276/// # Examples
277///
278/// ```rust
279/// use std::time::{Duration, Instant};
280/// use zeph_common::secret::Secret;
281/// use zeph_subagent::grants::GrantedSecret;
282///
283/// let granted = GrantedSecret {
284///     value: Secret::new("sekrit"),
285///     expires_at: Instant::now() + Duration::from_mins(5),
286/// };
287/// assert!(!granted.is_expired());
288/// ```
289#[derive(Debug)]
290pub struct GrantedSecret {
291    /// The resolved vault secret value.
292    pub value: Secret,
293    /// The absolute instant after which this value must no longer be used.
294    pub expires_at: Instant,
295}
296
297impl GrantedSecret {
298    /// Returns `true` if `expires_at` has already passed.
299    ///
300    /// # Examples
301    ///
302    /// ```rust
303    /// use std::time::{Duration, Instant};
304    /// use zeph_common::secret::Secret;
305    /// use zeph_subagent::grants::GrantedSecret;
306    ///
307    /// let expired = GrantedSecret {
308    ///     value: Secret::new("sekrit"),
309    ///     expires_at: Instant::now().checked_sub(Duration::from_secs(1)).unwrap(),
310    /// };
311    /// assert!(expired.is_expired());
312    /// ```
313    #[must_use]
314    pub fn is_expired(&self) -> bool {
315        Instant::now() >= self.expires_at
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn grant_is_active_before_expiry() {
325        let mut pg = PermissionGrants::default();
326        pg.add(GrantKind::Secret("api-key".into()), Duration::from_mins(5));
327        assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
328    }
329
330    #[test]
331    fn sweep_expired_removes_instant_ttl() {
332        let mut pg = PermissionGrants::default();
333        pg.grants.push(Grant {
334            kind: GrantKind::Tool("shell".into()),
335            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
336            ttl: Duration::from_secs(1), // already expired
337        });
338        // is_active internally sweeps
339        assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
340        assert!(pg.grants.is_empty());
341    }
342
343    #[test]
344    fn revoke_all_clears_all_grants() {
345        let mut pg = PermissionGrants::default();
346        pg.add(GrantKind::Secret("token".into()), Duration::from_mins(1));
347        pg.add(GrantKind::Tool("web".into()), Duration::from_mins(1));
348        pg.revoke_all();
349        assert!(pg.grants.is_empty());
350    }
351
352    #[test]
353    fn grant_secret_is_active() {
354        let mut pg = PermissionGrants::default();
355        pg.grant_secret("db-password", Duration::from_mins(2));
356        assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
357    }
358
359    #[test]
360    fn whitespace_description_invalid() {
361        // Verify grant kind display redacts secrets
362        let k = GrantKind::Secret("my-secret-key".into());
363        let display = k.to_string();
364        assert!(
365            !display.contains("my-secret-key"),
366            "secret key must be redacted in Display"
367        );
368        assert!(display.contains("redacted"));
369    }
370
371    #[test]
372    fn tool_grant_display_shows_name() {
373        let k = GrantKind::Tool("shell".into());
374        assert_eq!(k.to_string(), "Tool(shell)");
375    }
376
377    #[test]
378    fn partial_sweep_keeps_non_expired_grants() {
379        let mut pg = PermissionGrants::default();
380
381        // Add one already-expired grant.
382        pg.grants.push(Grant {
383            kind: GrantKind::Tool("expired-tool".into()),
384            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
385            ttl: Duration::from_secs(1),
386        });
387
388        // Add one live grant with long TTL.
389        pg.add(GrantKind::Secret("live-key".into()), Duration::from_mins(5));
390
391        pg.sweep_expired();
392
393        assert_eq!(pg.grants.len(), 1, "only live grant should remain");
394        assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
395    }
396
397    #[test]
398    fn duplicate_grant_for_same_key_both_tracked() {
399        let mut pg = PermissionGrants::default();
400        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
401        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
402
403        // Both grants are stored; is_active just checks any match.
404        assert_eq!(pg.grants.len(), 2);
405        assert!(pg.is_active(&GrantKind::Secret("my-key".into())));
406
407        // After revoking all, none remain.
408        pg.revoke_all();
409        assert!(pg.grants.is_empty());
410    }
411}