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
// 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 zeph_sanitizer::secret_mask::SecretCategory;
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_lock().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_lock().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_lock()
.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(),
));
};
// Register the delivered value for masking (#6492): closes the forwarding-path leak
// (the drain masks against this same registry, which was never populated with the
// secret it was supposed to catch) and feeds the agent loop's own tool-result masking
// pass, which consults this registry before content reaches the transcript, LLM
// context, or debug dump.
if let Some(registry) = self.secret_registry.as_ref() {
registry.register(key, value.expose(), SecretCategory::from_key_name(key));
}
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 std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use zeph_common::secret::Secret;
use zeph_sanitizer::secret_mask::SecretMaskRegistry;
use super::make_hook_env;
use crate::def::SubAgentDef;
use crate::grants::{GrantedSecret, PermissionGrants};
use crate::manager::{SubAgentHandle, SubAgentManager, SubAgentStatus};
use crate::state::SubAgentState;
/// Builds a [`SubAgentHandle`] with a LIVE `secret_tx`/`secret_rx` pair — unlike
/// [`SubAgentHandle::for_test`], which immediately drops the receiver (documented as
/// valid only for metadata inspection, never for a call that actually sends on the
/// channel). `deliver_secret` calls `secret_tx.try_send(..)`, so a dropped receiver
/// would make every delivery fail with `Channel("channel closed")` regardless of the
/// registration logic under test here.
fn handle_with_live_secret_channel(
id: &str,
def: SubAgentDef,
) -> (SubAgentHandle, mpsc::Receiver<Option<GrantedSecret>>) {
let initial_status = SubAgentStatus {
state: SubAgentState::Working,
last_message: None,
turns_used: 0,
started_at: Instant::now(),
};
let (status_tx, status_rx) = watch::channel(initial_status);
drop(status_tx);
let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
drop(pending_secret_rx_tx);
let (secret_tx, secret_rx) = mpsc::channel(1);
let handle = SubAgentHandle {
id: id.to_owned(),
task_id: id.to_owned(),
def,
state: SubAgentState::Working,
join_handle: None,
cancel: CancellationToken::new(),
status_rx,
grants: std::sync::Arc::new(std::sync::Mutex::new(PermissionGrants::default())),
pending_secret_rx,
secret_tx,
started_at_str: String::new(),
transcript_dir: None,
mcp_tool_names: Vec::new(),
};
(handle, secret_rx)
}
#[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"));
}
// --- #6492: deliver_secret registers into the shared secret-mask registry ---
#[test]
fn deliver_secret_registers_value_into_secret_mask_registry() {
let mut mgr = SubAgentManager::new(4);
let registry = Arc::new(SecretMaskRegistry::new());
mgr.set_secret_registry(Arc::clone(®istry));
let (handle, _secret_rx) =
handle_with_live_secret_channel("task-1", SubAgentDef::for_test("helper"));
handle
.grants_lock()
.grant_secret("SOME_VAULT_KEY", Duration::from_mins(5));
mgr.insert_handle_for_test("task-1".to_owned(), handle);
mgr.deliver_secret(
"task-1",
"SOME_VAULT_KEY",
Secret::new("the-secret-value-123"),
)
.expect("delivery must succeed: an active grant exists");
assert!(
registry.would_mask("value is the-secret-value-123"),
"a delivered secret must be registered into the shared mask registry — closes \
the gap where the forwarding drain masked against a registry that was never \
populated with the secret it was supposed to catch"
);
}
#[test]
fn deliver_secret_without_registry_still_succeeds() {
// Regression guard: registering into the mask registry must stay best-effort — a
// session with no registry wired (the `None` default) must not lose secret delivery.
let mut mgr = SubAgentManager::new(4);
let (handle, _secret_rx) =
handle_with_live_secret_channel("task-1", SubAgentDef::for_test("helper"));
handle
.grants_lock()
.grant_secret("SOME_VAULT_KEY", Duration::from_mins(5));
mgr.insert_handle_for_test("task-1".to_owned(), handle);
let result = mgr.deliver_secret(
"task-1",
"SOME_VAULT_KEY",
Secret::new("the-secret-value-123"),
);
assert!(
result.is_ok(),
"delivery must succeed even with no registry wired"
);
}
#[test]
fn deliver_secret_without_active_grant_is_denied() {
// Baseline: delivery must still be refused when there is no active grant, unaffected
// by the new registration step (which only runs after the grant check succeeds).
let mut mgr = SubAgentManager::new(4);
let registry = Arc::new(SecretMaskRegistry::new());
mgr.set_secret_registry(Arc::clone(®istry));
let handle = SubAgentHandle::for_test("task-1", SubAgentDef::for_test("helper"));
mgr.insert_handle_for_test("task-1".to_owned(), handle);
let result = mgr.deliver_secret(
"task-1",
"SOME_VAULT_KEY",
Secret::new("the-secret-value-123"),
);
assert!(
result.is_err(),
"delivery without an active grant must be denied"
);
assert!(
!registry.would_mask("value is the-secret-value-123"),
"a denied delivery must never register the secret"
);
}
}