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    /// Check whether a `GrantKind::Tool` grant permits dispatching `tool_name`.
267    ///
268    /// This is the enforcement entry point mirrored after the already-shipped
269    /// `GrantKind::Secret` TTL re-check in the sub-agent loop's `handle_tool_step`
270    /// (`granted_secrets.retain(|_, granted| !granted.is_expired())`): the check is
271    /// evaluated fresh against
272    /// [`Grant::is_expired`] rather than relying on a prior [`sweep_expired`](Self::sweep_expired)
273    /// call having already run, so a grant that lapsed since the last sweep is still caught
274    /// here (no time-of-check-to-time-of-use window).
275    ///
276    /// Distinguishing [`ToolGrantCheck::NoGrant`] from [`ToolGrantCheck::Expired`] lets the
277    /// caller apply fail-closed rejection only where a grant was actually issued and has
278    /// since lapsed, while leaving the — currently universal, since no production caller
279    /// creates `GrantKind::Tool` grants yet — no-grant case unrestricted.
280    ///
281    /// # Semantics: default-permit, time-box only — NOT an allow-list (confirmed, issue #6567)
282    ///
283    /// A `GrantKind::Tool` grant only *time-boxes* a tool the sub-agent is already permitted
284    /// to call under its static `ToolPolicy`/`AutonomyLevel` — it is an additional, narrower
285    /// restriction layered on top of an existing permission, never an independent grant of
286    /// access. Concretely:
287    /// - Absence of any grant for `tool_name` always permits the call (matches the current,
288    ///   universal zero-grant production state — no observable behavior change).
289    /// - A grant existing for *one* tool name never restricts any *other* tool name, even for
290    ///   the same sub-agent. There is no implicit "some grants exist, so everything else is
291    ///   denied" allow-list mode, and this method must never be extended to add one without a
292    ///   deliberate, separately-specified design change.
293    /// - Matching is exact tool-name string equality only — no prefix/glob support for tool
294    ///   families (e.g. MCP server-scoped names). A grant for `"mcp:server-a"` does not cover
295    ///   `"mcp:server-a:tool-x"` or any other name.
296    ///
297    /// # Examples
298    ///
299    /// ```rust
300    /// use std::time::Duration;
301    /// use zeph_subagent::grants::{GrantKind, PermissionGrants, ToolGrantCheck};
302    ///
303    /// let mut grants = PermissionGrants::default();
304    /// assert_eq!(grants.check_tool_grant("shell"), ToolGrantCheck::NoGrant);
305    ///
306    /// grants.add(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
307    /// assert_eq!(grants.check_tool_grant("shell"), ToolGrantCheck::Active);
308    /// ```
309    #[must_use]
310    pub fn check_tool_grant(&mut self, tool_name: &str) -> ToolGrantCheck {
311        let kind = GrantKind::Tool(tool_name.to_owned());
312        let had_record = self.grants.iter().any(|g| g.kind == kind);
313        if !had_record {
314            return ToolGrantCheck::NoGrant;
315        }
316        if self.is_active(&kind) {
317            ToolGrantCheck::Active
318        } else {
319            ToolGrantCheck::Expired
320        }
321    }
322}
323
324/// Result of checking whether a `GrantKind::Tool` grant permits a tool dispatch.
325///
326/// Returned by [`PermissionGrants::check_tool_grant`]. See that method's doc for the full
327/// default-permit / time-box-only semantics (confirmed, issue #6567) — in short: this is a
328/// narrowing restriction on top of an already-permitted tool, never an allow-list, and
329/// [`NoGrant`](Self::NoGrant) always means "unrestricted by this mechanism," not "denied."
330///
331/// # Examples
332///
333/// ```rust
334/// use std::time::Duration;
335/// use zeph_subagent::grants::{GrantKind, PermissionGrants, ToolGrantCheck};
336///
337/// let mut grants = PermissionGrants::default();
338/// grants.add(GrantKind::Tool("web".to_owned()), Duration::from_mins(5));
339/// assert_eq!(grants.check_tool_grant("web"), ToolGrantCheck::Active);
340/// assert_eq!(grants.check_tool_grant("shell"), ToolGrantCheck::NoGrant);
341/// ```
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum ToolGrantCheck {
344    /// No `GrantKind::Tool` grant record exists for this tool name — dispatch is
345    /// unrestricted by this mechanism (today's universal production state, since no
346    /// caller creates `GrantKind::Tool` grants yet).
347    NoGrant,
348    /// A `GrantKind::Tool` grant exists for this tool name and has not expired.
349    Active,
350    /// A `GrantKind::Tool` grant existed for this tool name but its TTL has elapsed; the
351    /// stale entry is evicted as a side effect of this check.
352    Expired,
353}
354
355#[cfg(test)]
356impl PermissionGrants {
357    /// Insert a grant with an explicit `granted_at`, bypassing `Instant::now()`, so
358    /// crate-internal tests outside this module (e.g. `agent_loop`'s enforcement tests) can
359    /// deterministically construct an already-expired grant without a real sleep — the
360    /// `grants` field itself is module-private, so this is the supported way in from outside
361    /// `grants.rs`.
362    pub(crate) fn add_test_grant(&mut self, kind: GrantKind, granted_at: Instant, ttl: Duration) {
363        self.grants.push(Grant {
364            kind,
365            granted_at,
366            ttl,
367        });
368    }
369}
370
371/// A resolved secret value delivered to a sub-agent loop, paired with the absolute
372/// instant its originating grant expires.
373///
374/// Sent over the `secret_tx`/`secret_rx` channel
375/// (see [`SubAgentHandle::secret_tx`][crate::manager::SubAgentHandle::secret_tx]) instead of a
376/// bare [`Secret`] so the spawned agent loop task — which has no further access to the
377/// manager-side [`PermissionGrants`] once the value is delivered — can still re-validate the
378/// TTL locally before every tool call and evict the value once it expires.
379///
380/// # Examples
381///
382/// ```rust
383/// use std::time::{Duration, Instant};
384/// use zeph_common::secret::Secret;
385/// use zeph_subagent::grants::GrantedSecret;
386///
387/// let granted = GrantedSecret {
388///     value: Secret::new("sekrit"),
389///     expires_at: Instant::now() + Duration::from_mins(5),
390/// };
391/// assert!(!granted.is_expired());
392/// ```
393#[derive(Debug)]
394pub struct GrantedSecret {
395    /// The resolved vault secret value.
396    pub value: Secret,
397    /// The absolute instant after which this value must no longer be used.
398    pub expires_at: Instant,
399}
400
401impl GrantedSecret {
402    /// Returns `true` if `expires_at` has already passed.
403    ///
404    /// # Examples
405    ///
406    /// ```rust
407    /// use std::time::{Duration, Instant};
408    /// use zeph_common::secret::Secret;
409    /// use zeph_subagent::grants::GrantedSecret;
410    ///
411    /// let expired = GrantedSecret {
412    ///     value: Secret::new("sekrit"),
413    ///     expires_at: Instant::now().checked_sub(Duration::from_secs(1)).unwrap(),
414    /// };
415    /// assert!(expired.is_expired());
416    /// ```
417    #[must_use]
418    pub fn is_expired(&self) -> bool {
419        Instant::now() >= self.expires_at
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn grant_is_active_before_expiry() {
429        let mut pg = PermissionGrants::default();
430        pg.add(GrantKind::Secret("api-key".into()), Duration::from_mins(5));
431        assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
432    }
433
434    #[test]
435    fn sweep_expired_removes_instant_ttl() {
436        let mut pg = PermissionGrants::default();
437        pg.grants.push(Grant {
438            kind: GrantKind::Tool("shell".into()),
439            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
440            ttl: Duration::from_secs(1), // already expired
441        });
442        // is_active internally sweeps
443        assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
444        assert!(pg.grants.is_empty());
445    }
446
447    #[test]
448    fn revoke_all_clears_all_grants() {
449        let mut pg = PermissionGrants::default();
450        pg.add(GrantKind::Secret("token".into()), Duration::from_mins(1));
451        pg.add(GrantKind::Tool("web".into()), Duration::from_mins(1));
452        pg.revoke_all();
453        assert!(pg.grants.is_empty());
454    }
455
456    #[test]
457    fn grant_secret_is_active() {
458        let mut pg = PermissionGrants::default();
459        pg.grant_secret("db-password", Duration::from_mins(2));
460        assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
461    }
462
463    #[test]
464    fn whitespace_description_invalid() {
465        // Verify grant kind display redacts secrets
466        let k = GrantKind::Secret("my-secret-key".into());
467        let display = k.to_string();
468        assert!(
469            !display.contains("my-secret-key"),
470            "secret key must be redacted in Display"
471        );
472        assert!(display.contains("redacted"));
473    }
474
475    #[test]
476    fn tool_grant_display_shows_name() {
477        let k = GrantKind::Tool("shell".into());
478        assert_eq!(k.to_string(), "Tool(shell)");
479    }
480
481    #[test]
482    fn partial_sweep_keeps_non_expired_grants() {
483        let mut pg = PermissionGrants::default();
484
485        // Add one already-expired grant.
486        pg.grants.push(Grant {
487            kind: GrantKind::Tool("expired-tool".into()),
488            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
489            ttl: Duration::from_secs(1),
490        });
491
492        // Add one live grant with long TTL.
493        pg.add(GrantKind::Secret("live-key".into()), Duration::from_mins(5));
494
495        pg.sweep_expired();
496
497        assert_eq!(pg.grants.len(), 1, "only live grant should remain");
498        assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
499    }
500
501    #[test]
502    fn check_tool_grant_no_record_returns_no_grant() {
503        let mut pg = PermissionGrants::default();
504        assert_eq!(pg.check_tool_grant("shell"), ToolGrantCheck::NoGrant);
505    }
506
507    #[test]
508    fn check_tool_grant_active_returns_active() {
509        let mut pg = PermissionGrants::default();
510        pg.add(GrantKind::Tool("shell".into()), Duration::from_mins(5));
511        assert_eq!(pg.check_tool_grant("shell"), ToolGrantCheck::Active);
512    }
513
514    #[test]
515    fn check_tool_grant_unrelated_name_returns_no_grant() {
516        let mut pg = PermissionGrants::default();
517        pg.add(GrantKind::Tool("shell".into()), Duration::from_mins(5));
518        assert_eq!(pg.check_tool_grant("web"), ToolGrantCheck::NoGrant);
519    }
520
521    #[test]
522    fn check_tool_grant_expired_returns_expired_and_evicts() {
523        let mut pg = PermissionGrants::default();
524        pg.grants.push(Grant {
525            kind: GrantKind::Tool("shell".into()),
526            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
527            ttl: Duration::from_secs(1),
528        });
529        assert_eq!(pg.check_tool_grant("shell"), ToolGrantCheck::Expired);
530        assert!(pg.grants.is_empty(), "expired grant must be evicted");
531    }
532
533    #[test]
534    fn duplicate_grant_for_same_key_both_tracked() {
535        let mut pg = PermissionGrants::default();
536        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
537        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
538
539        // Both grants are stored; is_active just checks any match.
540        assert_eq!(pg.grants.len(), 2);
541        assert!(pg.is_active(&GrantKind::Secret("my-key".into())));
542
543        // After revoking all, none remain.
544        pg.revoke_all();
545        assert!(pg.grants.is_empty());
546    }
547}