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