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::policy::operation_is_covered;
23use super::workflow_bundle::WorkflowBundle;
24use super::workflow_patch::{bundle_capability_ceiling, CapabilityCeilingViolation};
25use super::CapabilityPolicy;
26
27/// One Harn invocation that could be launched from inside another.
28/// Each variant carries the data the scanner needs to project an
29/// effective requested ceiling.
30#[derive(Clone, Debug)]
31pub enum NestedInvocationTarget<'a> {
32    /// A `harn workflow run`-style invocation against a parsed bundle.
33    WorkflowBundle(&'a WorkflowBundle),
34    /// A `harn run`-style invocation against a Harn script source. The
35    /// scanner does a coarse string scan for builtins that require
36    /// elevated capabilities; that is enough to catch the obvious
37    /// widening attempts without forcing the AST into this layer.
38    HarnScript { path: &'a str, source: &'a str },
39    /// An embedding-host harness manifest. The scanner reads
40    /// `capability_ceiling` and `tools` if present, falling back to
41    /// "request everything" so the parent rejects unstructured
42    /// manifests rather than rubber-stamping them.
43    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
61/// Compute the capability ceiling that running `target` would request
62/// of its parent runtime. This mirrors the projection used inside
63/// [`bundle_capability_ceiling`] so workflow bundles and standalone
64/// scripts share one comparison axis.
65pub 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
73/// Enforce the parent ceiling against a nested invocation target.
74/// Returns a populated report; callers reject the launch if
75/// `report.allowed()` is false.
76pub 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    // A read-only root is in scope if the parent could read it — i.e. it
186    // is one of the parent's writable or read-only roots. The parent only
187    // bounds this dimension once it declares roots of either kind.
188    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
207/// Coarse capability projection for a Harn script source. We look for
208/// stdlib builtin tokens (`exec`, `shell`, `http_*`, `write_file`,
209/// `connector_call`, `llm_call`, etc.) and project an `(operation,
210/// side_effect_level)` set that the parent must allow. This is not
211/// type-aware — it intentionally over-includes rather than miss a
212/// widening attempt.
213fn 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
280/// Strip line/block comments and the contents of string literals so the
281/// builtin scanner only sees actual source identifiers. Quoted text is
282/// replaced with whitespace of the same length to keep byte offsets and
283/// line numbers stable for any future diagnostics.
284fn 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}