zeroclaw_runtime/subagent/mod.rs
1//! Runtime-spawned ephemeral sub-agents that inherit their parent
2//! agent's identity by default: same UUID, same `SecurityPolicy`, same
3//! memory allowlist. A SubAgent run is auditable as a child of the
4//! parent and stays inside the parent's permissions envelope.
5//!
6//! Two spawn sites converge on [`SubAgentSpawn`]: the agent-loop tool
7//! `spawn_subagent` and the cron scheduler's `JobType::Agent` dispatch.
8//! Sharing the surface keeps permission inheritance, tracing-span
9//! shape, and audit attribution uniform.
10//!
11//! Power-users may narrow a SubAgent's permissions via
12//! [`SubAgentOverrides`]; [`SubAgentSpawn::build`] validates each
13//! override as a subset of the parent (using
14//! [`SecurityPolicy::ensure_no_escalation_beyond`] for the policy and
15//! an alias-set containment check for the memory allowlist) and
16//! returns `Err` with the originating violation chained on any
17//! escalation.
18//!
19//! The memory allowlist is carried as a set of agent **aliases** (the
20//! `[agents.<alias>]` config keys), not backend storage identifiers.
21//! Consumers that build an [`AgentScopedMemory`] must resolve aliases
22//! to backend identifiers via
23//! [`zeroclaw_memory::Memory::ensure_agent_uuid`] first — SQL-backed
24//! stores use UUIDs from the `agents` table; Markdown / Qdrant / None
25//! use the alias verbatim per the trait default. Holding aliases at
26//! this layer means [`SubAgentSpawn::for_agent`] does not need a
27//! backend handle to construct.
28
29use anyhow::{Context, Result};
30use std::collections::HashSet;
31use std::sync::Arc;
32
33use zeroclaw_config::policy::SecurityPolicy;
34use zeroclaw_config::schema::Config;
35
36/// Optional narrowing applied to a SubAgent at spawn time. `None` on
37/// every field means "inherit parent verbatim"; `Some(...)` narrows.
38/// Each field is independently validated by [`SubAgentSpawn::build`]
39/// to reject any value that escalates beyond the parent.
40///
41/// The default-everything-inherits model means the common case is
42/// `SubAgentOverrides::default()` — a no-op.
43#[derive(Debug, Clone, Default)]
44pub struct SubAgentOverrides {
45 /// Override the SubAgent's [`SecurityPolicy`]. Validated as a
46 /// subset of the parent via
47 /// [`SecurityPolicy::ensure_no_escalation_beyond`].
48 pub policy: Option<SecurityPolicy>,
49 /// Override the SubAgent's memory allowlist (the set of sibling
50 /// agent **aliases** the SubAgent may recall from, as written in
51 /// `[agents.<alias>]` keys). Validated as a subset of the
52 /// parent's allowlist; any alias here that is not on the parent's
53 /// list is rejected.
54 ///
55 /// These are config-layer aliases, not backend storage
56 /// identifiers. Consumers that build an [`AgentScopedMemory`]
57 /// must resolve aliases to backend identifiers via
58 /// [`zeroclaw_memory::Memory::ensure_agent_uuid`] before passing
59 /// them to the wrapper (SQL backends use UUIDs; Markdown / Qdrant
60 /// / None use the alias verbatim per the trait default). The
61 /// in-tree consumer today is `zeroclaw_memory::create_memory_for_agent`,
62 /// which performs the resolution.
63 pub allowed_agent_aliases: Option<HashSet<String>>,
64}
65
66/// Constructed SubAgent context: bound parent identity, validated
67/// child policy, and the resolved memory allowlist.
68#[derive(Debug, Clone)]
69pub struct SubAgentContext {
70 /// The parent agent's alias (e.g. `"researcher"`). SubAgents share
71 /// the parent's identity at the data layer (no separate row in the
72 /// `agents` table); the distinction between parent and sub-run is
73 /// captured at the tracing-span level
74 /// (`agent.<alias>.subagent.<run_id>`).
75 pub parent_alias: String,
76 /// The validated [`SecurityPolicy`] this SubAgent operates under.
77 /// Identical to the parent's when `SubAgentOverrides::policy` is
78 /// `None`; otherwise a narrowed copy that passed
79 /// [`SecurityPolicy::ensure_no_escalation_beyond`].
80 pub policy: Arc<SecurityPolicy>,
81 /// Resolved memory allowlist as a set of agent **aliases**. The
82 /// bound `parent_alias` is always included so the SubAgent always
83 /// sees the parent's own rows; the rest is either the parent's
84 /// allowlist verbatim or a validated subset.
85 ///
86 /// See [`SubAgentOverrides::allowed_agent_aliases`] for the
87 /// alias-vs-backend-identifier distinction; consumers that build
88 /// an [`AgentScopedMemory`] must resolve to backend identifiers
89 /// before passing the set to the wrapper.
90 pub allowed_agent_aliases: HashSet<String>,
91}
92
93/// Builder for a SubAgent spawn. The caller resolves a parent agent
94/// from the loaded config; [`Self::build`] applies any narrowing
95/// overrides and validates the result.
96#[derive(Debug)]
97pub struct SubAgentSpawn {
98 pub parent_alias: String,
99 pub parent_policy: Arc<SecurityPolicy>,
100 pub parent_allowed_agent_aliases: HashSet<String>,
101}
102
103impl SubAgentSpawn {
104 /// Resolve a parent's identity from the loaded config and an
105 /// agent alias. Returns `Err` when the alias does not name a
106 /// configured agent — the spawn site surfaces a structured
107 /// failure instead of invoking the agent loop on a nonexistent
108 /// identity.
109 pub fn for_agent(config: &Config, agent_alias: &str) -> Result<Self> {
110 let agent = config
111 .agents
112 .get(agent_alias)
113 .with_context(|| format!("no agent configured under alias {agent_alias:?}"))?;
114
115 let parent_policy = SecurityPolicy::for_agent(config, agent_alias)
116 .map(Arc::new)
117 .with_context(|| {
118 format!("could not resolve security policy for agent {agent_alias:?}")
119 })?;
120
121 let mut parent_allowed_agent_aliases: HashSet<String> = agent
122 .workspace
123 .read_memory_from
124 .iter()
125 .map(|alias| alias.as_str().to_string())
126 .collect();
127 parent_allowed_agent_aliases.insert(agent_alias.to_string());
128
129 Ok(Self {
130 parent_alias: agent_alias.to_string(),
131 parent_policy,
132 parent_allowed_agent_aliases,
133 })
134 }
135
136 /// Apply `overrides` to the parent's permissions and return a
137 /// validated [`SubAgentContext`]. On any escalation, returns
138 /// `Err` with the originating violation in the error chain.
139 ///
140 /// When the caller supplies a policy override, the child inherits
141 /// the parent's `PerSenderTracker` so action and cost budgets are
142 /// shared between parent and SubAgent runs. Otherwise a SubAgent
143 /// could be spawned to bypass the parent's `max_actions_per_hour`
144 /// or `max_cost_per_day_cents` ceiling by consuming from a
145 /// fresh-zeroed bucket; the inheritance closes that escape. The
146 /// no-override path already shares the bucket via
147 /// `Arc<SecurityPolicy>` cloning.
148 pub fn build(self, overrides: SubAgentOverrides) -> Result<SubAgentContext> {
149 let policy = if let Some(mut child_policy) = overrides.policy {
150 child_policy
151 .ensure_no_escalation_beyond(&self.parent_policy)
152 .map_err(|violation| {
153 ::zeroclaw_log::record!(
154 WARN,
155 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
156 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
157 .with_attrs(::serde_json::json!({
158 "violation": violation.to_string(),
159 })),
160 "subagent build refused: policy override escalates beyond parent"
161 );
162 anyhow::Error::msg(format!(
163 "subagent policy override escalates beyond parent: {violation}"
164 ))
165 })?;
166 // Share the parent's action/cost tracker. `PerSenderTracker`
167 // is `Clone` (deep-copy of buckets) but the SubAgent must
168 // see the parent's live bucket state, not a frozen
169 // snapshot, so steal the parent's tracker by cloning the
170 // inner `Arc<SecurityPolicy>` once and assigning the
171 // child's `tracker` field from it.
172 child_policy.tracker = self.parent_policy.tracker.clone();
173 Arc::new(child_policy)
174 } else {
175 self.parent_policy.clone()
176 };
177
178 let allowed_agent_aliases = if let Some(child_allowed) = overrides.allowed_agent_aliases {
179 for alias in &child_allowed {
180 if !self.parent_allowed_agent_aliases.contains(alias) {
181 anyhow::bail!(
182 "subagent allowlist override contains alias {alias:?} not present on \
183 parent's memory allowlist; SubAgent overrides may only narrow"
184 );
185 }
186 }
187 let mut resolved = child_allowed;
188 resolved.insert(self.parent_alias.clone());
189 resolved
190 } else {
191 self.parent_allowed_agent_aliases
192 };
193
194 Ok(SubAgentContext {
195 parent_alias: self.parent_alias,
196 policy,
197 allowed_agent_aliases,
198 })
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::path::PathBuf;
206 use zeroclaw_config::schema::{AliasedAgentConfig, RiskProfileConfig};
207
208 fn config_with_agent(alias: &str) -> Config {
209 let mut config = Config::default();
210 config
211 .risk_profiles
212 .insert("default".to_string(), RiskProfileConfig::default());
213 config.agents.insert(
214 alias.to_string(),
215 AliasedAgentConfig {
216 risk_profile: "default".to_string(),
217 ..AliasedAgentConfig::default()
218 },
219 );
220 config
221 }
222
223 #[test]
224 fn for_agent_resolves_parent_identity_from_config() {
225 let config = config_with_agent("alpha");
226 let ctx = SubAgentSpawn::for_agent(&config, "alpha")
227 .expect("for_agent must succeed for a configured agent")
228 .build(SubAgentOverrides::default())
229 .expect("inherits-verbatim build must succeed");
230 assert_eq!(ctx.parent_alias, "alpha");
231 assert!(
232 ctx.allowed_agent_aliases.contains("alpha"),
233 "an agent always sees its own rows"
234 );
235 }
236
237 #[test]
238 fn for_agent_errors_on_unknown_alias() {
239 let err = SubAgentSpawn::for_agent(&Config::default(), "missing")
240 .expect_err("unknown alias must error");
241 assert!(
242 err.to_string().contains("missing"),
243 "expected the missing alias in the error, got: {err}"
244 );
245 }
246
247 #[test]
248 fn build_inherits_verbatim_when_overrides_are_default() {
249 let config = config_with_agent("alpha");
250 let spawn = SubAgentSpawn::for_agent(&config, "alpha").unwrap();
251 let parent_policy = spawn.parent_policy.clone();
252 let parent_allowlist = spawn.parent_allowed_agent_aliases.clone();
253
254 let ctx = spawn.build(SubAgentOverrides::default()).unwrap();
255 assert!(Arc::ptr_eq(&ctx.policy, &parent_policy));
256 assert_eq!(ctx.allowed_agent_aliases, parent_allowlist);
257 }
258
259 #[test]
260 fn build_rejects_policy_override_that_escalates_paths() {
261 let config = config_with_agent("alpha");
262 let spawn = SubAgentSpawn::for_agent(&config, "alpha").unwrap();
263
264 let mut child_policy = (*spawn.parent_policy).clone();
265 // Add an rw root the parent doesn't have — escalation.
266 child_policy.allowed_roots.push(PathBuf::from("/secrets"));
267
268 let err = spawn
269 .build(SubAgentOverrides {
270 policy: Some(child_policy),
271 ..SubAgentOverrides::default()
272 })
273 .expect_err("escalating override must be rejected");
274 assert!(
275 err.to_string().contains("/secrets"),
276 "expected the escalating path in the error chain, got: {err}"
277 );
278 }
279
280 #[test]
281 fn build_rejects_allowlist_override_with_alias_not_on_parent() {
282 let config = config_with_agent("alpha");
283 let spawn = SubAgentSpawn::for_agent(&config, "alpha").unwrap();
284
285 let mut rogue = HashSet::new();
286 rogue.insert("rogue-agent".to_string());
287
288 let err = spawn
289 .build(SubAgentOverrides {
290 allowed_agent_aliases: Some(rogue),
291 ..SubAgentOverrides::default()
292 })
293 .expect_err("allowlist override with foreign alias must be rejected");
294 assert!(
295 err.to_string().contains("rogue-agent"),
296 "expected the rogue alias in the error chain, got: {err}"
297 );
298 }
299
300 #[test]
301 fn build_accepts_narrowed_allowlist_subset() {
302 let config = config_with_agent("alpha");
303 let spawn = SubAgentSpawn::for_agent(&config, "alpha").unwrap();
304
305 // Empty subset is still allowed; the bound parent alias is added back.
306 let ctx = spawn
307 .build(SubAgentOverrides {
308 allowed_agent_aliases: Some(HashSet::new()),
309 ..SubAgentOverrides::default()
310 })
311 .expect("narrowing to {} is a valid subset");
312 assert_eq!(ctx.allowed_agent_aliases.len(), 1);
313 assert!(ctx.allowed_agent_aliases.contains("alpha"));
314 }
315
316 #[test]
317 fn build_with_override_inherits_parent_action_budget() {
318 // SubAgent runs must consume from the parent's action budget
319 // so spawning children cannot bypass `max_actions_per_hour`.
320 // The override path (caller-supplied policy) is the one with
321 // the bug; the inherit-verbatim path is correct by Arc reuse.
322 let config = config_with_agent("alpha");
323 let spawn = SubAgentSpawn::for_agent(&config, "alpha").unwrap();
324 let parent_policy = spawn.parent_policy.clone();
325
326 // Burn the parent's action budget right up to the ceiling so
327 // the child's first record_action would push past it.
328 for _ in 0..parent_policy.max_actions_per_hour {
329 assert!(
330 parent_policy.record_action(),
331 "parent budget should accept records up to its ceiling"
332 );
333 }
334
335 // Build a child policy that's a subset of the parent (no
336 // escalation) but with the default fresh tracker. The fix
337 // copies the parent's tracker into the child so the next
338 // record_action sees the parent's exhausted bucket.
339 let child_policy = (*parent_policy).clone();
340 let ctx = spawn
341 .build(SubAgentOverrides {
342 policy: Some(child_policy),
343 ..SubAgentOverrides::default()
344 })
345 .expect("inheriting policy as a subset must succeed");
346
347 assert!(
348 !ctx.policy.record_action(),
349 "child must inherit parent's exhausted action budget; \
350 a fresh bucket here means the budget is bypass-able by \
351 spawning a SubAgent"
352 );
353 }
354}