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}