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