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