zeph-subagent 0.22.1

Subagent management: spawning, grants, transcripts, and lifecycle hooks for Zeph
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Zero-trust TTL-bounded permission grants for sub-agents.
//!
//! [`PermissionGrants`] tracks active grants (vault secrets or runtime tool access)
//! for a running sub-agent. All grants are time-limited; expired grants are swept
//! lazily by [`PermissionGrants::is_active`] and eagerly by
//! [`PermissionGrants::sweep_expired`].
//!
//! Grants are revoked on drop and on agent completion/cancellation. Secret key names
//! are never logged above DEBUG level; the `Display` impl for [`GrantKind::Secret`]
//! always prints `"Secret(<redacted>)"`.

use std::time::{Duration, Instant};

use serde::{Deserialize, Serialize};
use zeph_common::secret::Secret;

/// Metadata sent by a sub-agent when it needs a secret from the vault.
///
/// Carried in an `InputRequired` A2A status update as structured metadata.
/// The parent agent surfaces this to the user as an approval prompt; the user can
/// then call [`SubAgentManager::approve_secret`][crate::SubAgentManager] or
/// [`SubAgentManager::deny_secret`][crate::SubAgentManager].
///
/// # Examples
///
/// ```rust
/// use zeph_subagent::grants::SecretRequest;
///
/// let req = SecretRequest {
///     secret_key: "OPENAI_API_KEY".to_owned(),
///     reason: Some("needed for embeddings".to_owned()),
/// };
/// assert_eq!(req.secret_key, "OPENAI_API_KEY");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretRequest {
    /// The vault key name the sub-agent is requesting.
    pub secret_key: String,
    /// Human-readable reason (shown to the user in the approval prompt).
    pub reason: Option<String>,
}

/// Identifies the kind of permission that was granted to a sub-agent.
///
/// `GrantKind` is intentionally NOT serializable — grant metadata should never
/// leave the in-memory security boundary. Key names are logged only at DEBUG
/// level to avoid leaking grant enumeration to centralized log systems.
///
/// The [`Display`][std::fmt::Display] implementation always redacts `Secret` payloads,
/// printing `Secret(<redacted>)` instead of the actual key name.
///
/// # Examples
///
/// ```rust
/// use zeph_subagent::grants::GrantKind;
///
/// let secret = GrantKind::Secret("my-key".to_owned());
/// assert!(!secret.to_string().contains("my-key"), "key must be redacted");
///
/// let tool = GrantKind::Tool("shell".to_owned());
/// assert_eq!(tool.to_string(), "Tool(shell)");
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantKind {
    /// A vault secret key granted for in-memory access.
    Secret(String),
    /// A tool name granted at runtime beyond the definition's static policy.
    Tool(String),
}

impl std::fmt::Display for GrantKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Secret(_) => write!(f, "Secret(<redacted>)"),
            Self::Tool(name) => write!(f, "Tool({name})"),
        }
    }
}

/// A single permission grant with a TTL.
///
/// Created via [`PermissionGrants::add`] and swept automatically by
/// [`PermissionGrants::sweep_expired`].
#[derive(Debug)]
pub struct Grant {
    pub(crate) kind: GrantKind,
    pub(crate) granted_at: Instant,
    pub(crate) ttl: Duration,
}

impl Grant {
    /// Create a new grant for `kind` that expires after `ttl`.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use zeph_subagent::grants::{Grant, GrantKind};
    ///
    /// let grant = Grant::new(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
    /// assert!(!grant.is_expired());
    /// ```
    #[must_use]
    pub fn new(kind: GrantKind, ttl: Duration) -> Self {
        Self {
            kind,
            granted_at: Instant::now(),
            ttl,
        }
    }

    /// Returns `true` if the grant's TTL has elapsed.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use zeph_subagent::grants::{Grant, GrantKind};
    ///
    /// let grant = Grant::new(GrantKind::Tool("web".to_owned()), Duration::from_mins(5));
    /// // A brand-new grant is not yet expired.
    /// assert!(!grant.is_expired());
    /// ```
    #[must_use]
    pub fn is_expired(&self) -> bool {
        self.granted_at.elapsed() >= self.ttl
    }
}

/// Tracks active zero-trust permission grants for a sub-agent.
///
/// All grants are TTL-bounded. [`is_active`](Self::is_active) automatically
/// sweeps expired grants before checking, so callers do not need to call
/// [`sweep_expired`](Self::sweep_expired) manually.
#[derive(Debug, Default)]
pub struct PermissionGrants {
    grants: Vec<Grant>,
}

impl Drop for PermissionGrants {
    fn drop(&mut self) {
        // Defense-in-depth: revoke all grants on drop even if revoke_all()
        // was not explicitly called (e.g., on panic or early return).
        if !self.grants.is_empty() {
            tracing::warn!(
                count = self.grants.len(),
                "PermissionGrants dropped with active grants — revoking"
            );
            self.grants.clear();
        }
    }
}

impl PermissionGrants {
    /// Add a new grant with the given `kind` and `ttl`.
    ///
    /// The grant is immediately tracked. Expired grants are not swept here;
    /// call [`sweep_expired`][Self::sweep_expired] or [`is_active`][Self::is_active]
    /// to remove stale entries.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use zeph_subagent::grants::{GrantKind, PermissionGrants};
    ///
    /// let mut grants = PermissionGrants::default();
    /// grants.add(GrantKind::Tool("shell".to_owned()), Duration::from_mins(1));
    /// assert!(grants.is_active(&GrantKind::Tool("shell".to_owned())));
    /// ```
    pub fn add(&mut self, kind: GrantKind, ttl: Duration) {
        // Log tool grants at DEBUG; for secrets log only the redacted display form.
        tracing::debug!(kind = %kind, ?ttl, "permission grant added");
        self.grants.push(Grant::new(kind, ttl));
    }

    /// Remove all expired grants.
    pub fn sweep_expired(&mut self) {
        let expired: Vec<_> = self.grants.extract_if(.., |g| g.is_expired()).collect();
        for g in &expired {
            tracing::debug!(kind = %g.kind, "permission grant expired and revoked");
        }
        if !expired.is_empty() {
            tracing::debug!(removed = expired.len(), "swept expired grants");
        }
    }

    /// Check if a specific grant is still active (not expired).
    ///
    /// Automatically sweeps expired grants before checking.
    #[must_use]
    pub fn is_active(&mut self, kind: &GrantKind) -> bool {
        self.sweep_expired();
        self.grants.iter().any(|g| &g.kind == kind)
    }

    /// Returns the absolute instant at which the active grant for `kind` expires.
    ///
    /// Automatically sweeps expired grants before checking, so a `None` result means
    /// there is no active grant for `kind` (never granted, already expired, or revoked).
    /// Used by [`SubAgentManager::deliver_secret`][crate::manager::SubAgentManager::deliver_secret]
    /// to stamp the delivered value with its expiry so the sub-agent loop can re-validate the
    /// TTL locally on every subsequent tool call, without needing further access to this
    /// `PermissionGrants` instance (which stays on the manager side, not the spawned loop task).
    ///
    /// If duplicate grants exist for the same `kind`, this returns the *first* match's
    /// expiry rather than the latest (max) one. This is intentionally fail-safe: it can
    /// only cause an earlier-than-necessary secret eviction in the sub-agent loop, never
    /// a later one, so it is not a security concern — just a minor inefficiency in the
    /// rare duplicate-grant case.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::Duration;
    /// use zeph_subagent::grants::{GrantKind, PermissionGrants};
    ///
    /// let mut grants = PermissionGrants::default();
    /// let kind = GrantKind::Secret("api-key".to_owned());
    /// assert!(grants.expires_at(&kind).is_none());
    ///
    /// grants.add(kind.clone(), Duration::from_mins(5));
    /// assert!(grants.expires_at(&kind).is_some());
    /// ```
    #[must_use]
    pub fn expires_at(&mut self, kind: &GrantKind) -> Option<Instant> {
        self.sweep_expired();
        self.grants
            .iter()
            .find(|g| &g.kind == kind)
            .map(|g| g.granted_at + g.ttl)
    }

    /// Grant access to a vault secret with the given TTL.
    ///
    /// Sweeps expired grants first. Logs an audit event at DEBUG (key is redacted
    /// in the log output to avoid leaking grant enumeration to log aggregators).
    pub fn grant_secret(&mut self, key: impl Into<String>, ttl: Duration) {
        self.sweep_expired();
        let key = key.into();
        tracing::debug!("vault secret granted to sub-agent (key redacted), ttl={ttl:?}");
        self.add(GrantKind::Secret(key), ttl);
    }

    /// Returns `true` if there are any grants currently tracked (expired or not).
    ///
    /// Used by [`Drop`] to emit a warning when handles are dropped without cleanup.
    #[must_use]
    pub fn is_empty_grants(&self) -> bool {
        self.grants.is_empty()
    }

    /// Revoke all grants immediately (called on sub-agent completion or cancellation).
    pub fn revoke_all(&mut self) {
        let count = self.grants.len();
        self.grants.clear();
        if count > 0 {
            tracing::debug!(count, "all permission grants revoked");
        }
    }
}

/// A resolved secret value delivered to a sub-agent loop, paired with the absolute
/// instant its originating grant expires.
///
/// Sent over the `secret_tx`/`secret_rx` channel
/// (see [`SubAgentHandle::secret_tx`][crate::manager::SubAgentHandle::secret_tx]) instead of a
/// bare [`Secret`] so the spawned agent loop task — which has no further access to the
/// manager-side [`PermissionGrants`] once the value is delivered — can still re-validate the
/// TTL locally before every tool call and evict the value once it expires.
///
/// # Examples
///
/// ```rust
/// use std::time::{Duration, Instant};
/// use zeph_common::secret::Secret;
/// use zeph_subagent::grants::GrantedSecret;
///
/// let granted = GrantedSecret {
///     value: Secret::new("sekrit"),
///     expires_at: Instant::now() + Duration::from_mins(5),
/// };
/// assert!(!granted.is_expired());
/// ```
#[derive(Debug)]
pub struct GrantedSecret {
    /// The resolved vault secret value.
    pub value: Secret,
    /// The absolute instant after which this value must no longer be used.
    pub expires_at: Instant,
}

impl GrantedSecret {
    /// Returns `true` if `expires_at` has already passed.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use std::time::{Duration, Instant};
    /// use zeph_common::secret::Secret;
    /// use zeph_subagent::grants::GrantedSecret;
    ///
    /// let expired = GrantedSecret {
    ///     value: Secret::new("sekrit"),
    ///     expires_at: Instant::now().checked_sub(Duration::from_secs(1)).unwrap(),
    /// };
    /// assert!(expired.is_expired());
    /// ```
    #[must_use]
    pub fn is_expired(&self) -> bool {
        Instant::now() >= self.expires_at
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grant_is_active_before_expiry() {
        let mut pg = PermissionGrants::default();
        pg.add(GrantKind::Secret("api-key".into()), Duration::from_mins(5));
        assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
    }

    #[test]
    fn sweep_expired_removes_instant_ttl() {
        let mut pg = PermissionGrants::default();
        pg.grants.push(Grant {
            kind: GrantKind::Tool("shell".into()),
            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
            ttl: Duration::from_secs(1), // already expired
        });
        // is_active internally sweeps
        assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
        assert!(pg.grants.is_empty());
    }

    #[test]
    fn revoke_all_clears_all_grants() {
        let mut pg = PermissionGrants::default();
        pg.add(GrantKind::Secret("token".into()), Duration::from_mins(1));
        pg.add(GrantKind::Tool("web".into()), Duration::from_mins(1));
        pg.revoke_all();
        assert!(pg.grants.is_empty());
    }

    #[test]
    fn grant_secret_is_active() {
        let mut pg = PermissionGrants::default();
        pg.grant_secret("db-password", Duration::from_mins(2));
        assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
    }

    #[test]
    fn whitespace_description_invalid() {
        // Verify grant kind display redacts secrets
        let k = GrantKind::Secret("my-secret-key".into());
        let display = k.to_string();
        assert!(
            !display.contains("my-secret-key"),
            "secret key must be redacted in Display"
        );
        assert!(display.contains("redacted"));
    }

    #[test]
    fn tool_grant_display_shows_name() {
        let k = GrantKind::Tool("shell".into());
        assert_eq!(k.to_string(), "Tool(shell)");
    }

    #[test]
    fn partial_sweep_keeps_non_expired_grants() {
        let mut pg = PermissionGrants::default();

        // Add one already-expired grant.
        pg.grants.push(Grant {
            kind: GrantKind::Tool("expired-tool".into()),
            granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
            ttl: Duration::from_secs(1),
        });

        // Add one live grant with long TTL.
        pg.add(GrantKind::Secret("live-key".into()), Duration::from_mins(5));

        pg.sweep_expired();

        assert_eq!(pg.grants.len(), 1, "only live grant should remain");
        assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
    }

    #[test]
    fn duplicate_grant_for_same_key_both_tracked() {
        let mut pg = PermissionGrants::default();
        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));
        pg.add(GrantKind::Secret("my-key".into()), Duration::from_mins(1));

        // Both grants are stored; is_active just checks any match.
        assert_eq!(pg.grants.len(), 2);
        assert!(pg.is_active(&GrantKind::Secret("my-key".into())));

        // After revoking all, none remain.
        pg.revoke_all();
        assert!(pg.grants.is_empty());
    }
}