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}