1use super::{CapabilityPolicy, SandboxProfile};
19use crate::events::log_debug_meta;
20use crate::orchestration::{current_execution_policy, pop_execution_policy, push_execution_policy};
21use crate::value::{ErrorCategory, VmError, VmValue};
22
23pub const NESTED_KIND_OPTION_KEY: &str = "_nested_kind";
26pub const NESTED_LABEL_OPTION_KEY: &str = "_nested_label";
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum NestedExecutionKind {
36 AgentLoop,
38 SubAgentRun,
40 SpawnAgent,
42 WorkflowStage,
44 NestedWorkflow,
47 NestedInvocation,
50}
51
52impl NestedExecutionKind {
53 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::AgentLoop => "agent_loop",
56 Self::SubAgentRun => "sub_agent_run",
57 Self::SpawnAgent => "spawn_agent",
58 Self::WorkflowStage => "workflow_stage",
59 Self::NestedWorkflow => "nested_workflow",
60 Self::NestedInvocation => "nested_invocation",
61 }
62 }
63
64 pub fn parse_or_default(value: Option<&str>) -> Self {
67 match value {
68 Some("agent_loop") => Self::AgentLoop,
69 Some("sub_agent_run") => Self::SubAgentRun,
70 Some("spawn_agent") => Self::SpawnAgent,
71 Some("workflow_stage") => Self::WorkflowStage,
72 Some("nested_workflow") => Self::NestedWorkflow,
73 Some("nested_invocation") => Self::NestedInvocation,
74 _ => Self::AgentLoop,
75 }
76 }
77}
78
79#[derive(Debug)]
83pub struct NestedExecutionGuard {
84 pushed: bool,
85 pub parent_limit: Option<usize>,
88 pub child_limit: Option<usize>,
90 pub kind: NestedExecutionKind,
91 pub label: String,
92}
93
94impl Drop for NestedExecutionGuard {
95 fn drop(&mut self) {
96 if self.pushed {
97 pop_execution_policy();
98 }
99 }
100}
101
102impl NestedExecutionGuard {
103 pub(crate) fn disarm(mut self) {
106 self.pushed = false;
107 }
108}
109
110pub fn enter_nested_execution_policy(
134 requested: Option<CapabilityPolicy>,
135 kind: NestedExecutionKind,
136 label: &str,
137) -> Result<NestedExecutionGuard, VmError> {
138 let parent = current_execution_policy();
139 let parent_limit = parent.as_ref().and_then(|p| p.recursion_limit);
140
141 if matches!(parent_limit, Some(0)) {
142 emit_descent_event(kind, label, parent_limit, None, true);
143 return Err(nested_budget_exhausted(kind, label));
144 }
145
146 let requested_limit = requested.as_ref().and_then(|p| p.recursion_limit);
147 let decremented_parent = parent_limit.map(|n| n - 1);
148 let child_limit = match (decremented_parent, requested_limit) {
149 (Some(a), Some(b)) => Some(a.min(b)),
150 (Some(a), None) => Some(a),
151 (None, Some(b)) => Some(b),
152 (None, None) => None,
153 };
154
155 emit_descent_event(kind, label, parent_limit, child_limit, false);
156
157 let top_level_agent_loop = parent.is_none() && matches!(kind, NestedExecutionKind::AgentLoop);
158 let pushed = if child_limit.is_some() || top_level_agent_loop {
159 let mut carrier = parent.unwrap_or_else(|| {
160 if top_level_agent_loop {
161 top_level_agent_loop_policy()
162 } else {
163 CapabilityPolicy::default()
164 }
165 });
166 carrier.recursion_limit = child_limit;
167 push_execution_policy(carrier);
168 true
169 } else {
170 false
171 };
172
173 Ok(NestedExecutionGuard {
174 pushed,
175 parent_limit,
176 child_limit,
177 kind,
178 label: label.to_string(),
179 })
180}
181
182fn top_level_agent_loop_policy() -> CapabilityPolicy {
183 CapabilityPolicy {
184 sandbox_profile: SandboxProfile::OsHardened,
185 ..CapabilityPolicy::default()
186 }
187}
188
189pub fn annotate_nested_execution_options(
196 options: &mut crate::value::DictMap,
197 kind: NestedExecutionKind,
198 label: &str,
199) {
200 options.insert(
201 crate::value::intern_key(NESTED_KIND_OPTION_KEY),
202 VmValue::String(arcstr::ArcStr::from(kind.as_str().to_string())),
203 );
204 options.insert(
205 crate::value::intern_key(NESTED_LABEL_OPTION_KEY),
206 VmValue::String(arcstr::ArcStr::from(label.to_string())),
207 );
208}
209
210fn nested_budget_exhausted(kind: NestedExecutionKind, label: &str) -> VmError {
211 let label = if label.is_empty() { "<unnamed>" } else { label };
212 VmError::CategorizedError {
213 message: format!(
214 "nested execution budget exhausted before {}: {}",
215 kind.as_str(),
216 label
217 ),
218 category: ErrorCategory::BudgetExceeded,
219 }
220}
221
222fn emit_descent_event(
223 kind: NestedExecutionKind,
224 label: &str,
225 parent_limit: Option<usize>,
226 child_limit: Option<usize>,
227 rejected: bool,
228) {
229 let mut metadata = std::collections::BTreeMap::new();
230 metadata.insert(
231 "kind".to_string(),
232 serde_json::Value::String(kind.as_str().to_string()),
233 );
234 metadata.insert(
235 "label".to_string(),
236 serde_json::Value::String(label.to_string()),
237 );
238 metadata.insert(
239 "parent_recursion_limit".to_string(),
240 recursion_limit_to_json(parent_limit),
241 );
242 metadata.insert(
243 "child_recursion_limit".to_string(),
244 recursion_limit_to_json(child_limit),
245 );
246 metadata.insert("rejected".to_string(), serde_json::Value::Bool(rejected));
247 let message = if rejected {
248 format!(
249 "nested execution budget exhausted before {}: {}",
250 kind.as_str(),
251 label
252 )
253 } else {
254 format!("nested execution descent into {}: {}", kind.as_str(), label)
255 };
256 log_debug_meta("policy.nested_execution_descent", &message, metadata);
257}
258
259fn recursion_limit_to_json(value: Option<usize>) -> serde_json::Value {
260 match value {
261 Some(n) => serde_json::Value::Number(serde_json::Number::from(n)),
262 None => serde_json::Value::Null,
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::orchestration::clear_execution_policy_stacks;
270
271 fn policy_with_limit(limit: Option<usize>) -> CapabilityPolicy {
272 CapabilityPolicy {
273 recursion_limit: limit,
274 ..Default::default()
275 }
276 }
277
278 #[test]
279 fn none_parent_preserves_requested_limit() {
280 clear_execution_policy_stacks();
281 let requested = Some(policy_with_limit(Some(3)));
282 let guard =
283 enter_nested_execution_policy(requested, NestedExecutionKind::AgentLoop, "session-a")
284 .unwrap();
285 assert_eq!(guard.parent_limit, None);
286 assert_eq!(guard.child_limit, Some(3));
287 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(3));
288 assert_eq!(
289 current_execution_policy().unwrap().sandbox_profile,
290 crate::orchestration::SandboxProfile::OsHardened
291 );
292 drop(guard);
293 assert!(current_execution_policy().is_none());
294 }
295
296 #[test]
297 fn some_one_allows_one_child_and_gives_child_zero() {
298 clear_execution_policy_stacks();
299 push_execution_policy(policy_with_limit(Some(1)));
300 let guard =
301 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "child-1")
302 .unwrap();
303 assert_eq!(guard.parent_limit, Some(1));
304 assert_eq!(guard.child_limit, Some(0));
305 assert_eq!(current_execution_policy().unwrap().recursion_limit, Some(0));
306 drop(guard);
307 pop_execution_policy();
308 }
309
310 #[test]
311 fn some_zero_rejects_with_budget_exceeded() {
312 clear_execution_policy_stacks();
313 push_execution_policy(policy_with_limit(Some(0)));
314 let error =
315 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "research-worker")
316 .unwrap_err();
317 match error {
318 VmError::CategorizedError { message, category } => {
319 assert_eq!(category, ErrorCategory::BudgetExceeded);
320 assert!(
321 message.contains("agent_loop"),
322 "missing kind in message: {message}"
323 );
324 assert!(
325 message.contains("research-worker"),
326 "missing label in message: {message}"
327 );
328 }
329 other => panic!("expected CategorizedError, got {other:?}"),
330 }
331 pop_execution_policy();
332 }
333
334 #[test]
335 fn nested_chain_decrements_until_exhausted() {
336 clear_execution_policy_stacks();
337 let outer = enter_nested_execution_policy(
338 Some(policy_with_limit(Some(2))),
339 NestedExecutionKind::AgentLoop,
340 "outer",
341 )
342 .unwrap();
343 assert_eq!(outer.child_limit, Some(2));
344 let middle =
345 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "middle")
346 .unwrap();
347 assert_eq!(middle.child_limit, Some(1));
348 let inner =
349 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "inner").unwrap();
350 assert_eq!(inner.child_limit, Some(0));
351 let exhausted =
352 enter_nested_execution_policy(None, NestedExecutionKind::SubAgentRun, "innermost")
353 .unwrap_err();
354 assert!(matches!(
355 exhausted,
356 VmError::CategorizedError {
357 category: ErrorCategory::BudgetExceeded,
358 ..
359 }
360 ));
361 drop(inner);
362 drop(middle);
363 drop(outer);
364 }
365
366 #[test]
367 fn requested_limit_caps_below_parent() {
368 clear_execution_policy_stacks();
369 push_execution_policy(policy_with_limit(Some(8)));
370 let guard = enter_nested_execution_policy(
371 Some(policy_with_limit(Some(2))),
372 NestedExecutionKind::WorkflowStage,
373 "stage-1",
374 )
375 .unwrap();
376 assert_eq!(guard.parent_limit, Some(8));
377 assert_eq!(guard.child_limit, Some(2));
379 drop(guard);
380 pop_execution_policy();
381 }
382
383 #[test]
384 fn none_parent_and_none_requested_pushes_no_policy() {
385 clear_execution_policy_stacks();
386 let guard =
387 enter_nested_execution_policy(None, NestedExecutionKind::NestedWorkflow, "wf-1")
388 .unwrap();
389 assert!(current_execution_policy().is_none());
390 assert_eq!(guard.parent_limit, None);
391 assert_eq!(guard.child_limit, None);
392 drop(guard);
393 assert!(current_execution_policy().is_none());
394 }
395
396 #[test]
397 fn top_level_agent_loop_pushes_os_hardened_carrier_without_budget() {
398 clear_execution_policy_stacks();
399 let guard =
400 enter_nested_execution_policy(None, NestedExecutionKind::AgentLoop, "session-secure")
401 .unwrap();
402 let pushed = current_execution_policy().unwrap();
403 assert_eq!(pushed.recursion_limit, None);
404 assert_eq!(
405 pushed.sandbox_profile,
406 crate::orchestration::SandboxProfile::OsHardened
407 );
408 assert!(pushed.tools.is_empty());
409 assert!(pushed.capabilities.is_empty());
410 drop(guard);
411 assert!(current_execution_policy().is_none());
412 }
413
414 #[test]
415 fn top_level_carrier_does_not_propagate_requested_tools_or_capabilities() {
416 clear_execution_policy_stacks();
423 let requested = CapabilityPolicy {
424 tools: vec!["read_only".to_string()],
425 capabilities: std::collections::BTreeMap::from_iter([(
426 "workspace".to_string(),
427 vec!["read_text".to_string()],
428 )]),
429 side_effect_level: Some("read_only".to_string()),
430 recursion_limit: Some(4),
431 ..Default::default()
432 };
433 let guard = enter_nested_execution_policy(
434 Some(requested),
435 NestedExecutionKind::AgentLoop,
436 "session-x",
437 )
438 .unwrap();
439 let pushed = current_execution_policy().unwrap();
440 assert_eq!(pushed.recursion_limit, Some(4));
441 assert_eq!(
442 pushed.sandbox_profile,
443 crate::orchestration::SandboxProfile::OsHardened
444 );
445 assert!(pushed.tools.is_empty());
446 assert!(pushed.capabilities.is_empty());
447 assert!(pushed.side_effect_level.is_none());
448 drop(guard);
449 }
450
451 #[test]
452 fn carrier_inherits_parent_restrictions_when_nesting() {
453 clear_execution_policy_stacks();
459 let outer = CapabilityPolicy {
460 capabilities: std::collections::BTreeMap::from_iter([(
461 "workspace".to_string(),
462 vec!["read_text".to_string()],
463 )]),
464 side_effect_level: Some("read_only".to_string()),
465 recursion_limit: Some(3),
466 ..Default::default()
467 };
468 push_execution_policy(outer);
469 let guard =
470 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "stage-1")
471 .unwrap();
472 let pushed = current_execution_policy().unwrap();
473 assert_eq!(pushed.recursion_limit, Some(2));
475 assert_eq!(
479 pushed.capabilities.get("workspace"),
480 Some(&vec!["read_text".to_string()])
481 );
482 assert_eq!(pushed.side_effect_level.as_deref(), Some("read_only"));
483 drop(guard);
484 pop_execution_policy();
485 }
486
487 #[test]
488 fn workflow_stage_kind_observes_same_budget_semantics() {
489 clear_execution_policy_stacks();
490 push_execution_policy(policy_with_limit(Some(1)));
491 let guard =
495 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "build_stage")
496 .unwrap();
497 assert_eq!(guard.child_limit, Some(0));
498 let denied =
500 enter_nested_execution_policy(None, NestedExecutionKind::WorkflowStage, "verify_stage")
501 .unwrap_err();
502 match denied {
503 VmError::CategorizedError { message, category } => {
504 assert_eq!(category, ErrorCategory::BudgetExceeded);
505 assert!(message.contains("workflow_stage"));
506 assert!(message.contains("verify_stage"));
507 }
508 other => panic!("expected CategorizedError, got {other:?}"),
509 }
510 drop(guard);
511 pop_execution_policy();
512 }
513
514 #[test]
515 fn annotate_nested_execution_options_writes_canonical_keys() {
516 let mut options: crate::value::DictMap = crate::value::DictMap::new();
517 annotate_nested_execution_options(
518 &mut options,
519 NestedExecutionKind::SubAgentRun,
520 "research-worker",
521 );
522 match options.get(NESTED_KIND_OPTION_KEY).unwrap() {
523 VmValue::String(text) => assert_eq!(text.as_str(), "sub_agent_run"),
524 _ => panic!("kind not stored as string"),
525 }
526 match options.get(NESTED_LABEL_OPTION_KEY).unwrap() {
527 VmValue::String(text) => assert_eq!(text.as_str(), "research-worker"),
528 _ => panic!("label not stored as string"),
529 }
530 }
531}