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
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0
use std::collections::HashMap;
use std::time::Duration;
use zeph_common::secret::Secret;
use super::SubAgentManager;
use crate::error::SubAgentError;
use crate::grants::{GrantKind, GrantedSecret, SecretRequest};
/// Build the standard hook environment for a sub-agent lifecycle event.
pub(crate) fn make_hook_env(
task_id: &str,
agent_name: &str,
tool_name: &str,
) -> HashMap<String, String> {
let mut env = HashMap::new();
env.insert("ZEPH_AGENT_ID".to_owned(), task_id.to_owned());
env.insert("ZEPH_AGENT_NAME".to_owned(), agent_name.to_owned());
env.insert("ZEPH_AGENT_TYPE".to_owned(), "subagent".to_owned());
env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
env
}
impl SubAgentManager {
/// Approve a secret request for a running sub-agent.
///
/// Called after the user approves a vault secret access prompt. The secret
/// key must appear in the sub-agent definition's allowed `secrets` list;
/// otherwise the request is auto-denied.
///
/// # Errors
///
/// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
/// [`SubAgentError::Invalid`] if the key is not in the definition's allowed list.
pub fn approve_secret(
&mut self,
task_id: &str,
secret_key: &str,
ttl: Duration,
) -> Result<(), SubAgentError> {
let handle = self
.agents
.get_mut(task_id)
.ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
handle.grants.sweep_expired();
if !handle
.def
.permissions
.secrets
.iter()
.any(|k| k == secret_key)
{
tracing::warn!(task_id, "secret request denied: key not in allowed list");
return Err(SubAgentError::Invalid(format!(
"secret is not in the allowed secrets list for '{}'",
handle.def.name
)));
}
handle.grants.grant_secret(secret_key, ttl);
Ok(())
}
/// Deliver a resolved secret value to a waiting sub-agent loop.
///
/// Should be called after the user approves the request and the caller has resolved
/// `key` to its actual vault value (see [`approve_secret`](Self::approve_secret)).
/// Requires an active grant for `key` — delivery is refused if
/// [`approve_secret`](Self::approve_secret) was never called or the grant's TTL has
/// already elapsed, making
/// [`PermissionGrants::is_active`][crate::grants::PermissionGrants::is_active]
/// load-bearing rather than unused bookkeeping.
///
/// The delivered value is stamped with the grant's expiry (see [`GrantedSecret`]) so the
/// sub-agent loop can keep re-validating the TTL locally on every subsequent tool call,
/// rather than trusting this one-time gate for the remainder of a long-running turn loop.
///
/// # Errors
///
/// Returns [`SubAgentError::NotFound`] if the task ID is unknown, or
/// [`SubAgentError::Invalid`] if there is no active grant for `key`.
pub fn deliver_secret(
&mut self,
task_id: &str,
key: &str,
value: Secret,
) -> Result<(), SubAgentError> {
let handle = self
.agents
.get_mut(task_id)
.ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
let Some(expires_at) = handle.grants.expires_at(&GrantKind::Secret(key.to_owned())) else {
tracing::warn!(
task_id,
"secret delivery denied: no active grant (missing approval or TTL expired)"
);
return Err(SubAgentError::Invalid(
"no active grant for this secret".to_owned(),
));
};
handle
.secret_tx
.try_send(Some(GrantedSecret { value, expires_at }))
.map_err(|e| SubAgentError::Channel(e.to_string()))
}
/// Deny a pending secret request — sends `None` to unblock the waiting sub-agent loop.
///
/// # Errors
///
/// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
/// [`SubAgentError::Channel`] if the channel is full or closed.
pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
let handle = self
.agents
.get_mut(task_id)
.ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
handle
.secret_tx
.try_send(None)
.map_err(|e| SubAgentError::Channel(e.to_string()))
}
/// Try to receive a pending secret request from any sub-agent (non-blocking).
///
/// Polls each active agent's request channel once. Returns `Some((task_id, request))`
/// if any agent has a pending request, or `None` if all channels are empty.
/// Call this from the main agent loop to surface approval prompts to the user.
pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
for handle in self.agents.values_mut() {
if let Ok(req) = handle.pending_secret_rx.try_recv() {
return Some((handle.task_id.clone(), req));
}
}
None
}
/// Try to receive a pending secret request from one specific sub-agent (non-blocking).
///
/// Unlike [`try_recv_secret_request`](Self::try_recv_secret_request), this only polls
/// `task_id`'s own request channel, so it never pops and discards an unrelated sibling
/// sub-agent's pending request. Use this when the caller already knows which sub-agent
/// it wants to act on (e.g. an explicit `/agent approve <id>` command), instead of the
/// pop-then-filter pattern of polling [`try_recv_secret_request`](Self::try_recv_secret_request)
/// and discarding non-matching results — a discarded result is popped off the channel
/// and lost forever, silently starving the sub-agent that actually sent it.
///
/// Returns `None` if `task_id` is unknown or has no pending request.
pub fn try_recv_secret_request_for(&mut self, task_id: &str) -> Option<SecretRequest> {
self.agents
.get_mut(task_id)?
.pending_secret_rx
.try_recv()
.ok()
}
}
#[cfg(test)]
mod tests {
use super::make_hook_env;
#[test]
fn make_hook_env_sets_agent_type_subagent() {
let env = make_hook_env("task-42", "my-agent", "Shell");
assert_eq!(
env.get("ZEPH_AGENT_TYPE").map(String::as_str),
Some("subagent")
);
assert_eq!(
env.get("ZEPH_AGENT_ID").map(String::as_str),
Some("task-42")
);
assert_eq!(
env.get("ZEPH_AGENT_NAME").map(String::as_str),
Some("my-agent")
);
assert_eq!(env.get("ZEPH_TOOL_NAME").map(String::as_str), Some("Shell"));
}
}