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}