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