1use std::collections::BTreeSet;
19
20use serde::{Deserialize, Serialize};
21
22use super::workflow_bundle::WorkflowBundle;
23use super::workflow_patch::{bundle_capability_ceiling, CapabilityCeilingViolation};
24use super::CapabilityPolicy;
25
26#[derive(Clone, Debug)]
30pub enum NestedInvocationTarget<'a> {
31 WorkflowBundle(&'a WorkflowBundle),
33 HarnScript { path: &'a str, source: &'a str },
38 BurinHarness { manifest: &'a serde_json::Value },
43}
44
45#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
46pub struct NestedInvocationCeilingReport {
47 pub target_kind: String,
48 pub target_label: String,
49 pub parent: CapabilityPolicy,
50 pub requested: CapabilityPolicy,
51 pub violations: Vec<CapabilityCeilingViolation>,
52}
53
54impl NestedInvocationCeilingReport {
55 pub fn allowed(&self) -> bool {
56 self.violations.is_empty()
57 }
58}
59
60pub fn requested_ceiling_for_target(target: &NestedInvocationTarget<'_>) -> CapabilityPolicy {
65 match target {
66 NestedInvocationTarget::WorkflowBundle(bundle) => bundle_capability_ceiling(bundle),
67 NestedInvocationTarget::HarnScript { source, .. } => scan_harn_script_ceiling(source),
68 NestedInvocationTarget::BurinHarness { manifest } => scan_burin_manifest_ceiling(manifest),
69 }
70}
71
72pub fn enforce_nested_invocation_ceiling(
76 parent: &CapabilityPolicy,
77 target: &NestedInvocationTarget<'_>,
78) -> NestedInvocationCeilingReport {
79 let requested = requested_ceiling_for_target(target);
80 let violations = collect_violations(parent, &requested);
81 let (kind, label) = match target {
82 NestedInvocationTarget::WorkflowBundle(bundle) => {
83 ("workflow_bundle".to_string(), bundle.id.clone())
84 }
85 NestedInvocationTarget::HarnScript { path, .. } => {
86 ("harn_script".to_string(), path.to_string())
87 }
88 NestedInvocationTarget::BurinHarness { manifest } => {
89 let label = manifest
90 .get("id")
91 .and_then(|value| value.as_str())
92 .unwrap_or("<unknown>")
93 .to_string();
94 ("burin_harness".to_string(), label)
95 }
96 };
97 NestedInvocationCeilingReport {
98 target_kind: kind,
99 target_label: label,
100 parent: parent.clone(),
101 requested,
102 violations,
103 }
104}
105
106fn collect_violations(
107 parent: &CapabilityPolicy,
108 requested: &CapabilityPolicy,
109) -> Vec<CapabilityCeilingViolation> {
110 let mut violations = Vec::new();
111 if parent.tools_are_restricted() {
112 for tool in requested.allowed_tool_patterns() {
113 if !parent.tool_pattern_allows(tool) {
114 violations.push(CapabilityCeilingViolation {
115 kind: "tool".to_string(),
116 detail: format!("nested target requests tool '{tool}' outside parent ceiling"),
117 });
118 }
119 }
120 }
121 for (capability, ops) in requested.allowed_capabilities() {
122 match parent.capability_operations(capability) {
123 Some(parent_ops) => {
124 if ops.is_empty() && !parent_ops.is_empty() {
125 violations.push(CapabilityCeilingViolation {
126 kind: "capability".to_string(),
127 detail: format!(
128 "nested target requests every '{capability}' operation outside parent ceiling"
129 ),
130 });
131 continue;
132 }
133 for op in ops {
134 if !parent_ops.is_empty() && !parent_ops.contains(op) {
135 violations.push(CapabilityCeilingViolation {
136 kind: "capability".to_string(),
137 detail: format!(
138 "nested target requests '{capability}.{op}' outside parent ceiling"
139 ),
140 });
141 }
142 }
143 }
144 None if parent.capabilities_are_restricted() => {
145 violations.push(CapabilityCeilingViolation {
146 kind: "capability".to_string(),
147 detail: format!(
148 "nested target requests capability '{capability}' outside parent ceiling"
149 ),
150 });
151 }
152 _ => {}
153 }
154 }
155 if let (Some(parent_level), Some(requested_level)) = (
156 parent.side_effect_level.as_deref(),
157 requested.side_effect_level.as_deref(),
158 ) {
159 if rank(requested_level) > rank(parent_level) {
160 violations.push(CapabilityCeilingViolation {
161 kind: "side_effect_level".to_string(),
162 detail: format!(
163 "nested target requests side_effect_level '{requested_level}' outside parent ceiling '{parent_level}'"
164 ),
165 });
166 }
167 }
168 if !parent.workspace_roots.is_empty() {
169 for root in &requested.workspace_roots {
170 if !parent.workspace_roots.contains(root) {
171 violations.push(CapabilityCeilingViolation {
172 kind: "workspace_root".to_string(),
173 detail: format!(
174 "nested target requests workspace_root '{root}' outside parent allowlist"
175 ),
176 });
177 }
178 }
179 }
180 if !parent.workspace_roots.is_empty() || !parent.read_only_roots.is_empty() {
184 for root in &requested.read_only_roots {
185 if !parent.workspace_roots.contains(root) && !parent.read_only_roots.contains(root) {
186 violations.push(CapabilityCeilingViolation {
187 kind: "read_only_root".to_string(),
188 detail: format!(
189 "nested target requests read_only_root '{root}' outside parent allowlist"
190 ),
191 });
192 }
193 }
194 }
195 violations
196}
197
198fn rank(level: &str) -> usize {
199 crate::tool_annotations::SideEffectLevel::rank_str(level)
200}
201
202fn scan_harn_script_ceiling(source: &str) -> CapabilityPolicy {
209 let stripped = strip_comments(source);
210 let mut capabilities: std::collections::BTreeMap<String, BTreeSet<String>> =
211 std::collections::BTreeMap::new();
212 let mut max_side_effect: Option<&'static str> = None;
213
214 for (token, capability, op, side_effect) in BUILTIN_CAPABILITIES {
215 if contains_call(&stripped, token) {
216 capabilities
217 .entry((*capability).to_string())
218 .or_default()
219 .insert((*op).to_string());
220 max_side_effect = match max_side_effect {
221 Some(current) if rank(current) >= rank(side_effect) => Some(current),
222 _ => Some(side_effect),
223 };
224 }
225 }
226
227 CapabilityPolicy {
228 tools: Vec::new(),
229 capabilities: capabilities
230 .into_iter()
231 .map(|(k, v)| (k, v.into_iter().collect()))
232 .collect(),
233 workspace_roots: Vec::new(),
234 read_only_roots: Vec::new(),
235 side_effect_level: max_side_effect.map(|level| level.to_string()),
236 recursion_limit: None,
237 tool_arg_constraints: Vec::new(),
238 tool_annotations: std::collections::BTreeMap::new(),
239 sandbox_profile: crate::orchestration::SandboxProfile::default(),
240 process_sandbox: Default::default(),
241 }
242}
243
244fn scan_burin_manifest_ceiling(manifest: &serde_json::Value) -> CapabilityPolicy {
245 if let Some(ceiling) = manifest.get("capability_ceiling") {
246 if let Ok(parsed) = serde_json::from_value::<CapabilityPolicy>(ceiling.clone()) {
247 return parsed;
248 }
249 }
250 let tools = manifest
251 .get("tools")
252 .and_then(|value| value.as_array())
253 .map(|tools| {
254 tools
255 .iter()
256 .filter_map(|tool| tool.as_str().map(str::to_string))
257 .collect::<Vec<_>>()
258 })
259 .unwrap_or_default();
260
261 CapabilityPolicy {
262 tools,
263 capabilities: std::collections::BTreeMap::new(),
264 workspace_roots: Vec::new(),
265 read_only_roots: Vec::new(),
266 side_effect_level: Some("network".to_string()),
267 recursion_limit: None,
268 tool_arg_constraints: Vec::new(),
269 tool_annotations: std::collections::BTreeMap::new(),
270 sandbox_profile: crate::orchestration::SandboxProfile::default(),
271 process_sandbox: Default::default(),
272 }
273}
274
275fn strip_comments(source: &str) -> String {
280 let mut out = String::with_capacity(source.len());
281 let mut in_block = false;
282 let mut chars = source.chars().peekable();
283 while let Some(c) = chars.next() {
284 if in_block {
285 if c == '*' && matches!(chars.peek(), Some('/')) {
286 chars.next();
287 in_block = false;
288 }
289 continue;
290 }
291 if c == '/' {
292 match chars.peek() {
293 Some('/') => {
294 for next in chars.by_ref() {
295 if next == '\n' {
296 out.push('\n');
297 break;
298 }
299 }
300 continue;
301 }
302 Some('*') => {
303 chars.next();
304 in_block = true;
305 continue;
306 }
307 _ => {}
308 }
309 }
310 if c == '#' {
311 for next in chars.by_ref() {
312 if next == '\n' {
313 out.push('\n');
314 break;
315 }
316 }
317 continue;
318 }
319 if c == '"' || c == '\'' {
320 out.push(' ');
321 let quote = c;
322 while let Some(next) = chars.next() {
323 if next == '\\' {
324 chars.next();
325 out.push(' ');
326 out.push(' ');
327 continue;
328 }
329 if next == quote {
330 out.push(' ');
331 break;
332 }
333 out.push(if next == '\n' { '\n' } else { ' ' });
334 }
335 continue;
336 }
337 out.push(c);
338 }
339 out
340}
341
342fn contains_call(source: &str, token: &str) -> bool {
343 let bytes = source.as_bytes();
344 let needle = token.as_bytes();
345 if bytes.len() < needle.len() + 1 {
346 return false;
347 }
348 let mut start = 0;
349 while let Some(pos) = find_subslice(&bytes[start..], needle) {
350 let absolute = start + pos;
351 let before = if absolute == 0 {
352 None
353 } else {
354 Some(bytes[absolute - 1])
355 };
356 let after = bytes.get(absolute + needle.len()).copied();
357 let valid_before = match before {
358 None => true,
359 Some(c) => !is_identifier_byte(c),
360 };
361 let valid_after = matches!(after, Some(b'(') | Some(b' ') | Some(b'\t'))
362 || matches!(after, Some(b'\n') | Some(b'\r'));
363 if valid_before && valid_after {
364 return true;
365 }
366 start = absolute + needle.len();
367 }
368 false
369}
370
371fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
372 if needle.is_empty() || haystack.len() < needle.len() {
373 return None;
374 }
375 haystack
376 .windows(needle.len())
377 .position(|window| window == needle)
378}
379
380fn is_identifier_byte(b: u8) -> bool {
381 b.is_ascii_alphanumeric() || b == b'_'
382}
383
384const BUILTIN_CAPABILITIES: &[(&str, &str, &str, &str)] = &[
385 ("read_file", "workspace", "read_text", "read_only"),
386 ("read_file_result", "workspace", "read_text", "read_only"),
387 ("read_file_bytes", "workspace", "read_text", "read_only"),
388 (
389 "package_snapshot_open",
390 "workspace",
391 "read_text",
392 "read_only",
393 ),
394 ("render", "workspace", "read_text", "read_only"),
395 ("render_prompt", "workspace", "read_text", "read_only"),
396 (
397 "render_with_provenance",
398 "workspace",
399 "read_text",
400 "read_only",
401 ),
402 ("list_dir", "workspace", "list", "read_only"),
403 ("file_exists", "workspace", "exists", "read_only"),
404 ("path_status", "workspace", "exists", "read_only"),
405 ("stat", "workspace", "exists", "read_only"),
406 ("write_file", "workspace", "write_text", "workspace_write"),
407 (
408 "write_file_bytes",
409 "workspace",
410 "write_text",
411 "workspace_write",
412 ),
413 ("replace_file", "workspace", "write_text", "workspace_write"),
414 (
415 "replace_file_result",
416 "workspace",
417 "write_text",
418 "workspace_write",
419 ),
420 (
421 "replace_file_bytes",
422 "workspace",
423 "write_text",
424 "workspace_write",
425 ),
426 (
427 "replace_file_bytes_result",
428 "workspace",
429 "write_text",
430 "workspace_write",
431 ),
432 ("append_file", "workspace", "write_text", "workspace_write"),
433 (
434 "append_file_locked",
435 "workspace",
436 "write_text",
437 "workspace_write",
438 ),
439 ("mkdir", "workspace", "write_text", "workspace_write"),
440 ("copy_file", "workspace", "write_text", "workspace_write"),
441 ("delete_file", "workspace", "delete", "workspace_write"),
442 ("apply_edit", "workspace", "apply_edit", "workspace_write"),
443 ("exec", "process", "exec", "process_exec"),
444 ("exec_at", "process", "exec", "process_exec"),
445 ("shell", "process", "exec", "process_exec"),
446 ("shell_at", "process", "exec", "process_exec"),
447 ("http_get", "network", "http", "network"),
448 ("http_post", "network", "http", "network"),
449 ("http_put", "network", "http", "network"),
450 ("http_patch", "network", "http", "network"),
451 ("http_delete", "network", "http", "network"),
452 ("http_request", "network", "http", "network"),
453 ("http_download", "network", "http", "network"),
454 ("connector_call", "connector", "call", "network"),
455 ("secret_get", "connector", "secret_get", "read_only"),
456 ("llm_call", "llm", "call", "network"),
457 ("llm_call_safe", "llm", "call", "network"),
458 ("llm_completion", "llm", "call", "network"),
459 ("llm_stream", "llm", "call", "network"),
460 ("agent_loop", "llm", "call", "network"),
461 ("vision_ocr", "vision", "ocr", "process_exec"),
462 ("mcp_call", "process", "exec", "process_exec"),
463 ("mcp_connect", "process", "exec", "process_exec"),
464];
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::collections::BTreeMap;
470
471 fn permissive_parent() -> CapabilityPolicy {
472 let mut capabilities = BTreeMap::new();
473 capabilities.insert(
474 "workspace".to_string(),
475 vec!["read_text".to_string(), "list".to_string()],
476 );
477 capabilities.insert("connector".to_string(), vec!["call".to_string()]);
478 capabilities.insert("process".to_string(), vec!["exec".to_string()]);
479 capabilities.insert("network".to_string(), vec!["http".to_string()]);
480 capabilities.insert("llm".to_string(), vec!["call".to_string()]);
481 CapabilityPolicy {
482 tools: Vec::new(),
483 capabilities,
484 workspace_roots: Vec::new(),
485 read_only_roots: Vec::new(),
486 side_effect_level: Some("network".to_string()),
487 recursion_limit: None,
488 tool_arg_constraints: Vec::new(),
489 tool_annotations: BTreeMap::new(),
490 sandbox_profile: crate::orchestration::SandboxProfile::default(),
491 process_sandbox: Default::default(),
492 }
493 }
494
495 fn read_only_parent() -> CapabilityPolicy {
496 let mut capabilities = BTreeMap::new();
497 capabilities.insert(
498 "workspace".to_string(),
499 vec![
500 "read_text".to_string(),
501 "list".to_string(),
502 "exists".to_string(),
503 ],
504 );
505 CapabilityPolicy {
506 tools: Vec::new(),
507 capabilities,
508 workspace_roots: Vec::new(),
509 read_only_roots: Vec::new(),
510 side_effect_level: Some("read_only".to_string()),
511 recursion_limit: None,
512 tool_arg_constraints: Vec::new(),
513 tool_annotations: BTreeMap::new(),
514 sandbox_profile: crate::orchestration::SandboxProfile::default(),
515 process_sandbox: Default::default(),
516 }
517 }
518
519 #[test]
520 fn harn_script_with_only_reads_passes_under_read_only_parent() {
521 let source = r#"
522 let body = read_file("README.md")
523 let exists = file_exists("Cargo.toml")
524 "#;
525 let report = enforce_nested_invocation_ceiling(
526 &read_only_parent(),
527 &NestedInvocationTarget::HarnScript {
528 path: "test.harn",
529 source,
530 },
531 );
532 assert!(report.allowed(), "{report:#?}");
533 }
534
535 #[test]
536 fn pure_harn_script_inherits_restricted_parent_dimensions() {
537 let mut parent = read_only_parent();
538 parent.tools = vec!["read_file".to_string()];
539 let report = enforce_nested_invocation_ceiling(
540 &parent,
541 &NestedInvocationTarget::HarnScript {
542 path: "pure.harn",
543 source: "let answer = 42",
544 },
545 );
546
547 assert!(report.allowed(), "{report:#?}");
548 assert!(report.requested.tools.is_empty());
549 assert!(report.requested.capabilities.is_empty());
550 }
551
552 #[test]
553 fn harn_script_with_exec_is_rejected_under_read_only_parent() {
554 let source = r#"
555 let result = exec("ls", ["-la"])
556 "#;
557 let report = enforce_nested_invocation_ceiling(
558 &read_only_parent(),
559 &NestedInvocationTarget::HarnScript {
560 path: "exec.harn",
561 source,
562 },
563 );
564 assert!(!report.allowed());
565 let kinds: Vec<&str> = report.violations.iter().map(|v| v.kind.as_str()).collect();
566 assert!(kinds.contains(&"capability"));
567 assert!(kinds.contains(&"side_effect_level"));
568 }
569
570 #[test]
571 fn harn_script_with_http_is_rejected_under_read_only_parent() {
572 let source = r#"
573 http_get("https://example.com")
574 "#;
575 let report = enforce_nested_invocation_ceiling(
576 &read_only_parent(),
577 &NestedInvocationTarget::HarnScript {
578 path: "http.harn",
579 source,
580 },
581 );
582 assert!(!report.allowed());
583 }
584
585 #[test]
586 fn harn_script_with_vision_ocr_is_rejected_under_read_only_parent() {
587 let source = r#"
588 harness.system.vision_ocr("receipt.png")
589 "#;
590 let report = enforce_nested_invocation_ceiling(
591 &read_only_parent(),
592 &NestedInvocationTarget::HarnScript {
593 path: "vision.harn",
594 source,
595 },
596 );
597 assert!(!report.allowed());
598 let kinds: Vec<&str> = report.violations.iter().map(|v| v.kind.as_str()).collect();
599 assert!(kinds.contains(&"capability"));
600 assert!(kinds.contains(&"side_effect_level"));
601 }
602
603 #[test]
604 fn harn_script_keyword_inside_string_does_not_trigger() {
605 let source = r#"
606 let label = "exec is not invoked here"
607 let body = read_file("README.md")
608 "#;
609 let report = enforce_nested_invocation_ceiling(
610 &read_only_parent(),
611 &NestedInvocationTarget::HarnScript {
612 path: "string.harn",
613 source,
614 },
615 );
616 assert!(
617 report.allowed(),
618 "false positive on quoted token: {report:#?}"
619 );
620 }
621
622 #[test]
623 fn harn_script_keyword_in_comment_is_ignored() {
624 let source = r#"
625 // exec("rm -rf /") is a comment-only token and must not trip policy.
626 let x = read_file("README.md")
627 "#;
628 let report = enforce_nested_invocation_ceiling(
629 &read_only_parent(),
630 &NestedInvocationTarget::HarnScript {
631 path: "comments.harn",
632 source,
633 },
634 );
635 assert!(
636 report.allowed(),
637 "false positive on commented token: {report:#?}"
638 );
639 }
640
641 #[test]
642 fn workflow_bundle_with_act_auto_is_rejected_under_read_only_parent() {
643 let mut bundle = super::super::workflow_test_fixtures::pr_monitor_bundle();
644 bundle.policy.autonomy_tier = "act_auto".to_string();
645 let report = enforce_nested_invocation_ceiling(
646 &read_only_parent(),
647 &NestedInvocationTarget::WorkflowBundle(&bundle),
648 );
649 assert!(!report.allowed());
650 }
651
652 #[test]
653 fn burin_manifest_with_explicit_ceiling_is_used_directly() {
654 let manifest = serde_json::json!({
655 "id": "burin.harness.repair",
656 "capability_ceiling": {
657 "capabilities": {
658 "workspace": ["read_text"]
659 },
660 "side_effect_level": "read_only"
661 }
662 });
663 let report = enforce_nested_invocation_ceiling(
664 &read_only_parent(),
665 &NestedInvocationTarget::BurinHarness {
666 manifest: &manifest,
667 },
668 );
669 assert!(report.allowed(), "{report:#?}");
670 }
671
672 #[test]
673 fn burin_manifest_without_ceiling_falls_back_to_network_and_is_rejected() {
674 let manifest = serde_json::json!({"id": "burin.harness.unknown"});
675 let report = enforce_nested_invocation_ceiling(
676 &read_only_parent(),
677 &NestedInvocationTarget::BurinHarness {
678 manifest: &manifest,
679 },
680 );
681 assert!(!report.allowed());
682 }
683
684 #[test]
685 fn permissive_parent_accepts_workflow_bundle() {
686 let bundle = super::super::workflow_test_fixtures::pr_monitor_bundle();
687 let report = enforce_nested_invocation_ceiling(
688 &permissive_parent(),
689 &NestedInvocationTarget::WorkflowBundle(&bundle),
690 );
691 assert!(report.allowed(), "{report:#?}");
692 }
693}