Skip to main content

harn_vm/orchestration/
nested_invocation.rs

1//! Capability-ceiling guard for nested Harn invocations.
2//!
3//! When a script that already runs under a parent execution policy
4//! launches another Harn invocation — `harn run`, `harn workflow run`,
5//! `harn supervisor fire/replay`, or an embedding host — Harn must scan
6//! the target and reject anything that asks for *more* than the parent
7//! ceiling. This module hosts the scanner: it accepts a parent
8//! [`CapabilityPolicy`] plus a description of the nested target, and
9//! returns the list of dimensions where the target widens the parent.
10//!
11//! The scanner is deliberately conservative: when in doubt about what
12//! the target requests, it errs on the side of "ask for more" so the
13//! parent rejects rather than silently widens. It is the integration
14//! layer's job to wire this into the actual launch points (the `exec`,
15//! `shell`, `host_call` builtins, the workflow CLI, the supervisor
16//! API).
17
18use 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/// One Harn invocation that could be launched from inside another.
27/// Each variant carries the data the scanner needs to project an
28/// effective requested ceiling.
29#[derive(Clone, Debug)]
30pub enum NestedInvocationTarget<'a> {
31    /// A `harn workflow run`-style invocation against a parsed bundle.
32    WorkflowBundle(&'a WorkflowBundle),
33    /// A `harn run`-style invocation against a Harn script source. The
34    /// scanner does a coarse string scan for builtins that require
35    /// elevated capabilities; that is enough to catch the obvious
36    /// widening attempts without forcing the AST into this layer.
37    HarnScript { path: &'a str, source: &'a str },
38    /// An embedding-host harness manifest. The scanner reads
39    /// `capability_ceiling` and `tools` if present, falling back to
40    /// "request everything" so the parent rejects unstructured
41    /// manifests rather than rubber-stamping them.
42    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
60/// Compute the capability ceiling that running `target` would request
61/// of its parent runtime. This mirrors the projection used inside
62/// [`bundle_capability_ceiling`] so workflow bundles and standalone
63/// scripts share one comparison axis.
64pub 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
72/// Enforce the parent ceiling against a nested invocation target.
73/// Returns a populated report; callers reject the launch if
74/// `report.allowed()` is false.
75pub 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    // A read-only root is in scope if the parent could read it — i.e. it
172    // is one of the parent's writable or read-only roots. The parent only
173    // bounds this dimension once it declares roots of either kind.
174    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
193/// Coarse capability projection for a Harn script source. We look for
194/// stdlib builtin tokens (`exec`, `shell`, `http_*`, `write_file`,
195/// `connector_call`, `llm_call`, etc.) and project an `(operation,
196/// side_effect_level)` set that the parent must allow. This is not
197/// type-aware — it intentionally over-includes rather than miss a
198/// widening attempt.
199fn 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
266/// Strip line/block comments and the contents of string literals so the
267/// builtin scanner only sees actual source identifiers. Quoted text is
268/// replaced with whitespace of the same length to keep byte offsets and
269/// line numbers stable for any future diagnostics.
270fn 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}