Skip to main content

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}