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