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