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};
18
19/// Metadata sent by a sub-agent when it needs a secret from the vault.
20///
21/// Carried in an `InputRequired` A2A status update as structured metadata.
22/// The parent agent surfaces this to the user as an approval prompt; the user can
23/// then call [`SubAgentManager::approve_secret`][crate::SubAgentManager] or
24/// [`SubAgentManager::deny_secret`][crate::SubAgentManager].
25///
26/// # Examples
27///
28/// ```rust
29/// use zeph_subagent::grants::SecretRequest;
30///
31/// let req = SecretRequest {
32///     secret_key: "OPENAI_API_KEY".to_owned(),
33///     reason: Some("needed for embeddings".to_owned()),
34/// };
35/// assert_eq!(req.secret_key, "OPENAI_API_KEY");
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SecretRequest {
39    /// The vault key name the sub-agent is requesting.
40    pub secret_key: String,
41    /// Human-readable reason (shown to the user in the approval prompt).
42    pub reason: Option<String>,
43}
44
45/// Identifies the kind of permission that was granted to a sub-agent.
46///
47/// `GrantKind` is intentionally NOT serializable — grant metadata should never
48/// leave the in-memory security boundary. Key names are logged only at DEBUG
49/// level to avoid leaking grant enumeration to centralized log systems.
50///
51/// The [`Display`][std::fmt::Display] implementation always redacts `Secret` payloads,
52/// printing `Secret(<redacted>)` instead of the actual key name.
53///
54/// # Examples
55///
56/// ```rust
57/// use zeph_subagent::grants::GrantKind;
58///
59/// let secret = GrantKind::Secret("my-key".to_owned());
60/// assert!(!secret.to_string().contains("my-key"), "key must be redacted");
61///
62/// let tool = GrantKind::Tool("shell".to_owned());
63/// assert_eq!(tool.to_string(), "Tool(shell)");
64/// ```
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum GrantKind {
68    /// A vault secret key granted for in-memory access.
69    Secret(String),
70    /// A tool name granted at runtime beyond the definition's static policy.
71    Tool(String),
72}
73
74impl std::fmt::Display for GrantKind {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Secret(_) => write!(f, "Secret(<redacted>)"),
78            Self::Tool(name) => write!(f, "Tool({name})"),
79        }
80    }
81}
82
83/// A single permission grant with a TTL.
84///
85/// Created via [`PermissionGrants::add`] and swept automatically by
86/// [`PermissionGrants::sweep_expired`].
87#[derive(Debug)]
88pub struct Grant {
89    pub(crate) kind: GrantKind,
90    pub(crate) granted_at: Instant,
91    pub(crate) ttl: Duration,
92}
93
94impl Grant {
95    /// Create a new grant for `kind` that expires after `ttl`.
96    ///
97    /// # Examples
98    ///
99    /// ```rust
100    /// use std::time::Duration;
101    /// use zeph_subagent::grants::{Grant, GrantKind};
102    ///
103    /// let grant = Grant::new(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
104    /// assert!(!grant.is_expired());
105    /// ```
106    #[must_use]
107    pub fn new(kind: GrantKind, ttl: Duration) -> Self {
108        Self {
109            kind,
110            granted_at: Instant::now(),
111            ttl,
112        }
113    }
114
115    /// Returns `true` if the grant's TTL has elapsed.
116    ///
117    /// # Examples
118    ///
119    /// ```rust
120    /// use std::time::Duration;
121    /// use zeph_subagent::grants::{Grant, GrantKind};
122    ///
123    /// let grant = Grant::new(GrantKind::Tool("web".to_owned()), Duration::from_mins(5));
124    /// // A brand-new grant is not yet expired.
125    /// assert!(!grant.is_expired());
126    /// ```
127    #[must_use]
128    pub fn is_expired(&self) -> bool {
129        self.granted_at.elapsed() >= self.ttl
130    }
131}
132
133/// Tracks active zero-trust permission grants for a sub-agent.
134///
135/// All grants are TTL-bounded. [`is_active`](Self::is_active) automatically
136/// sweeps expired grants before checking, so callers do not need to call
137/// [`sweep_expired`](Self::sweep_expired) manually.
138#[derive(Debug, Default)]
139pub struct PermissionGrants {
140    grants: Vec<Grant>,
141}
142
143impl Drop for PermissionGrants {
144    fn drop(&mut self) {
145        // Defense-in-depth: revoke all grants on drop even if revoke_all()
146        // was not explicitly called (e.g., on panic or early return).
147        if !self.grants.is_empty() {
148            tracing::warn!(
149                count = self.grants.len(),
150                "PermissionGrants dropped with active grants — revoking"
151            );
152            self.grants.clear();
153        }
154    }
155}
156
157impl PermissionGrants {
158    /// Add a new grant with the given `kind` and `ttl`.
159    ///
160    /// The grant is immediately tracked. Expired grants are not swept here;
161    /// call [`sweep_expired`][Self::sweep_expired] or [`is_active`][Self::is_active]
162    /// to remove stale entries.
163    ///
164    /// # Examples
165    ///
166    /// ```rust
167    /// use std::time::Duration;
168    /// use zeph_subagent::grants::{GrantKind, PermissionGrants};
169    ///
170    /// let mut grants = PermissionGrants::default();
171    /// grants.add(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
172    /// assert!(grants.is_active(&GrantKind::Tool("shell".to_owned())));
173    /// ```
174    pub fn add(&mut self, kind: GrantKind, ttl: Duration) {
175        // Log tool grants at DEBUG; for secrets log only the redacted display form.
176        tracing::debug!(kind = %kind, ?ttl, "permission grant added");
177        self.grants.push(Grant::new(kind, ttl));
178    }
179
180    /// Remove all expired grants.
181    pub fn sweep_expired(&mut self) {
182        let expired: Vec<_> = self.grants.extract_if(.., |g| g.is_expired()).collect();
183        for g in &expired {
184            tracing::debug!(kind = %g.kind, "permission grant expired and revoked");
185        }
186        if !expired.is_empty() {
187            tracing::debug!(removed = expired.len(), "swept expired grants");
188        }
189    }
190
191    /// Check if a specific grant is still active (not expired).
192    ///
193    /// Automatically sweeps expired grants before checking.
194    #[must_use]
195    pub fn is_active(&mut self, kind: &GrantKind) -> bool {
196        self.sweep_expired();
197        self.grants.iter().any(|g| &g.kind == kind)
198    }
199
200    /// Grant access to a vault secret with the given TTL.
201    ///
202    /// Sweeps expired grants first. Logs an audit event at DEBUG (key is redacted
203    /// in the log output to avoid leaking grant enumeration to log aggregators).
204    pub fn grant_secret(&mut self, key: impl Into<String>, ttl: Duration) {
205        self.sweep_expired();
206        let key = key.into();
207        tracing::debug!("vault secret granted to sub-agent (key redacted), ttl={ttl:?}");
208        self.add(GrantKind::Secret(key), ttl);
209    }
210
211    /// Returns `true` if there are any grants currently tracked (expired or not).
212    ///
213    /// Used by [`Drop`] to emit a warning when handles are dropped without cleanup.
214    #[must_use]
215    pub fn is_empty_grants(&self) -> bool {
216        self.grants.is_empty()
217    }
218
219    /// Revoke all grants immediately (called on sub-agent completion or cancellation).
220    pub fn revoke_all(&mut self) {
221        let count = self.grants.len();
222        self.grants.clear();
223        if count > 0 {
224            tracing::debug!(count, "all permission grants revoked");
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn grant_is_active_before_expiry() {
235        let mut pg = PermissionGrants::default();
236        pg.add(GrantKind::Secret("api-key".into()), Duration::from_mins(5));
237        assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
238    }
239
240    #[test]
241    fn sweep_expired_removes_instant_ttl() {
242        let mut pg = PermissionGrants::default();
243        pg.grants.push(Grant {
244            kind: GrantKind::Tool("shell".into()),
245            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
246            ttl: Duration::from_secs(1), // already expired
247        });
248        // is_active internally sweeps
249        assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
250        assert!(pg.grants.is_empty());
251    }
252
253    #[test]
254    fn revoke_all_clears_all_grants() {
255        let mut pg = PermissionGrants::default();
256        pg.add(GrantKind::Secret("token".into()), Duration::from_mins(1));
257        pg.add(GrantKind::Tool("web".into()), Duration::from_mins(1));
258        pg.revoke_all();
259        assert!(pg.grants.is_empty());
260    }
261
262    #[test]
263    fn grant_secret_is_active() {
264        let mut pg = PermissionGrants::default();
265        pg.grant_secret("db-password", Duration::from_mins(2));
266        assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
267    }
268
269    #[test]
270    fn whitespace_description_invalid() {
271        // Verify grant kind display redacts secrets
272        let k = GrantKind::Secret("my-secret-key".into());
273        let display = k.to_string();
274        assert!(
275            !display.contains("my-secret-key"),
276            "secret key must be redacted in Display"
277        );
278        assert!(display.contains("redacted"));
279    }
280
281    #[test]
282    fn tool_grant_display_shows_name() {
283        let k = GrantKind::Tool("shell".into());
284        assert_eq!(k.to_string(), "Tool(shell)");
285    }
286
287    #[test]
288    fn partial_sweep_keeps_non_expired_grants() {
289        let mut pg = PermissionGrants::default();
290
291        // Add one already-expired grant.
292        pg.grants.push(Grant {
293            kind: GrantKind::Tool("expired-tool".into()),
294            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
295            ttl: Duration::from_secs(1),
296        });
297
298        // Add one live grant with long TTL.
299        pg.add(GrantKind::Secret("live-key".into()), Duration::from_mins(5));
300
301        pg.sweep_expired();
302
303        assert_eq!(pg.grants.len(), 1, "only live grant should remain");
304        assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
305    }
306
307    #[test]
308    fn duplicate_grant_for_same_key_both_tracked() {
309        let mut pg = PermissionGrants::default();
310        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
311        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
312
313        // Both grants are stored; is_active just checks any match.
314        assert_eq!(pg.grants.len(), 2);
315        assert!(pg.is_active(&GrantKind::Secret("my-key".into())));
316
317        // After revoking all, none remain.
318        pg.revoke_all();
319        assert!(pg.grants.is_empty());
320    }
321}