Skip to main content

zeph_subagent/manager/
secrets.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::time::Duration;
6
7use zeph_common::secret::Secret;
8
9use super::SubAgentManager;
10use crate::error::SubAgentError;
11use crate::grants::{GrantKind, GrantedSecret, SecretRequest};
12
13/// Build the standard hook environment for a sub-agent lifecycle event.
14pub(crate) fn make_hook_env(
15    task_id: &str,
16    agent_name: &str,
17    tool_name: &str,
18) -> HashMap<String, String> {
19    let mut env = HashMap::new();
20    env.insert("ZEPH_AGENT_ID".to_owned(), task_id.to_owned());
21    env.insert("ZEPH_AGENT_NAME".to_owned(), agent_name.to_owned());
22    env.insert("ZEPH_AGENT_TYPE".to_owned(), "subagent".to_owned());
23    env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
24    env
25}
26
27impl SubAgentManager {
28    /// Approve a secret request for a running sub-agent.
29    ///
30    /// Called after the user approves a vault secret access prompt. The secret
31    /// key must appear in the sub-agent definition's allowed `secrets` list;
32    /// otherwise the request is auto-denied.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
37    /// [`SubAgentError::Invalid`] if the key is not in the definition's allowed list.
38    pub fn approve_secret(
39        &mut self,
40        task_id: &str,
41        secret_key: &str,
42        ttl: Duration,
43    ) -> Result<(), SubAgentError> {
44        let handle = self
45            .agents
46            .get_mut(task_id)
47            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
48
49        handle.grants.sweep_expired();
50
51        if !handle
52            .def
53            .permissions
54            .secrets
55            .iter()
56            .any(|k| k == secret_key)
57        {
58            tracing::warn!(task_id, "secret request denied: key not in allowed list");
59            return Err(SubAgentError::Invalid(format!(
60                "secret is not in the allowed secrets list for '{}'",
61                handle.def.name
62            )));
63        }
64
65        handle.grants.grant_secret(secret_key, ttl);
66        Ok(())
67    }
68
69    /// Deliver a resolved secret value to a waiting sub-agent loop.
70    ///
71    /// Should be called after the user approves the request and the caller has resolved
72    /// `key` to its actual vault value (see [`approve_secret`](Self::approve_secret)).
73    /// Requires an active grant for `key` — delivery is refused if
74    /// [`approve_secret`](Self::approve_secret) was never called or the grant's TTL has
75    /// already elapsed, making
76    /// [`PermissionGrants::is_active`][crate::grants::PermissionGrants::is_active]
77    /// load-bearing rather than unused bookkeeping.
78    ///
79    /// The delivered value is stamped with the grant's expiry (see [`GrantedSecret`]) so the
80    /// sub-agent loop can keep re-validating the TTL locally on every subsequent tool call,
81    /// rather than trusting this one-time gate for the remainder of a long-running turn loop.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown, or
86    /// [`SubAgentError::Invalid`] if there is no active grant for `key`.
87    pub fn deliver_secret(
88        &mut self,
89        task_id: &str,
90        key: &str,
91        value: Secret,
92    ) -> Result<(), SubAgentError> {
93        let handle = self
94            .agents
95            .get_mut(task_id)
96            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
97
98        let Some(expires_at) = handle.grants.expires_at(&GrantKind::Secret(key.to_owned())) else {
99            tracing::warn!(
100                task_id,
101                "secret delivery denied: no active grant (missing approval or TTL expired)"
102            );
103            return Err(SubAgentError::Invalid(
104                "no active grant for this secret".to_owned(),
105            ));
106        };
107
108        handle
109            .secret_tx
110            .try_send(Some(GrantedSecret { value, expires_at }))
111            .map_err(|e| SubAgentError::Channel(e.to_string()))
112    }
113
114    /// Deny a pending secret request — sends `None` to unblock the waiting sub-agent loop.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
119    /// [`SubAgentError::Channel`] if the channel is full or closed.
120    pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
121        let handle = self
122            .agents
123            .get_mut(task_id)
124            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
125        handle
126            .secret_tx
127            .try_send(None)
128            .map_err(|e| SubAgentError::Channel(e.to_string()))
129    }
130
131    /// Try to receive a pending secret request from any sub-agent (non-blocking).
132    ///
133    /// Polls each active agent's request channel once. Returns `Some((task_id, request))`
134    /// if any agent has a pending request, or `None` if all channels are empty.
135    /// Call this from the main agent loop to surface approval prompts to the user.
136    pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
137        for handle in self.agents.values_mut() {
138            if let Ok(req) = handle.pending_secret_rx.try_recv() {
139                return Some((handle.task_id.clone(), req));
140            }
141        }
142        None
143    }
144
145    /// Try to receive a pending secret request from one specific sub-agent (non-blocking).
146    ///
147    /// Unlike [`try_recv_secret_request`](Self::try_recv_secret_request), this only polls
148    /// `task_id`'s own request channel, so it never pops and discards an unrelated sibling
149    /// sub-agent's pending request. Use this when the caller already knows which sub-agent
150    /// it wants to act on (e.g. an explicit `/agent approve <id>` command), instead of the
151    /// pop-then-filter pattern of polling [`try_recv_secret_request`](Self::try_recv_secret_request)
152    /// and discarding non-matching results — a discarded result is popped off the channel
153    /// and lost forever, silently starving the sub-agent that actually sent it.
154    ///
155    /// Returns `None` if `task_id` is unknown or has no pending request.
156    pub fn try_recv_secret_request_for(&mut self, task_id: &str) -> Option<SecretRequest> {
157        self.agents
158            .get_mut(task_id)?
159            .pending_secret_rx
160            .try_recv()
161            .ok()
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::make_hook_env;
168
169    #[test]
170    fn make_hook_env_sets_agent_type_subagent() {
171        let env = make_hook_env("task-42", "my-agent", "Shell");
172        assert_eq!(
173            env.get("ZEPH_AGENT_TYPE").map(String::as_str),
174            Some("subagent")
175        );
176        assert_eq!(
177            env.get("ZEPH_AGENT_ID").map(String::as_str),
178            Some("task-42")
179        );
180        assert_eq!(
181            env.get("ZEPH_AGENT_NAME").map(String::as_str),
182            Some("my-agent")
183        );
184        assert_eq!(env.get("ZEPH_TOOL_NAME").map(String::as_str), Some("Shell"));
185    }
186}