Skip to main content

harn_cli/commands/run/
sandbox.rs

1use std::path::{Path, PathBuf};
2
3use super::EnvironmentPolicyConfig;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct RunSandboxOptions {
7    /// Install the default `harn run` sandbox for this invocation.
8    pub enabled: bool,
9    /// Override the workspace root used by the default sandbox. This is
10    /// intended for host-generated scripts whose source file lives outside
11    /// the workspace they operate on.
12    pub workspace_root: Option<PathBuf>,
13    /// Extra writable filesystem roots mounted into the direct-run
14    /// sandbox. These extend the write jail without disabling path
15    /// enforcement or egress policy.
16    pub write_roots: Vec<PathBuf>,
17    /// Extra read-only filesystem roots. `path` resolving under one of
18    /// these entries is scoped for reads, but writes still fail.
19    pub read_only_roots: Vec<PathBuf>,
20    /// Extra roots readable only by spawned subprocesses.
21    pub process_read_roots: Vec<PathBuf>,
22    /// Extra roots writable only by spawned subprocesses.
23    pub process_write_roots: Vec<PathBuf>,
24    /// Raise the direct-run side-effect ceiling to permit network-capable
25    /// subprocesses without disabling filesystem or process confinement.
26    pub allow_process_network: bool,
27    /// Session environment policy for this run. Always present: the default
28    /// captures the launcher environment at launch.
29    ///
30    /// `pub(crate)`: the config is launcher-internal and parsed from
31    /// CLI flags, not part of the in-process `execute_run` API surface.
32    pub(crate) environment: EnvironmentPolicyConfig,
33}
34
35impl Default for RunSandboxOptions {
36    fn default() -> Self {
37        Self {
38            enabled: true,
39            workspace_root: None,
40            write_roots: Vec::new(),
41            read_only_roots: Vec::new(),
42            process_read_roots: Vec::new(),
43            process_write_roots: Vec::new(),
44            allow_process_network: false,
45            environment: EnvironmentPolicyConfig::default(),
46        }
47    }
48}
49
50impl RunSandboxOptions {
51    /// Construct the default sandbox with an explicit subprocess-network choice.
52    pub fn sandboxed(allow_process_network: bool) -> Self {
53        Self::default().with_process_network(allow_process_network)
54    }
55
56    /// Permit addressable sockets in commands spawned by this run while the
57    /// rest of the direct-run sandbox remains active.
58    pub fn with_process_network(mut self, enabled: bool) -> Self {
59        self.allow_process_network = enabled;
60        self
61    }
62
63    /// Disable the default direct-run sandbox and egress guard.
64    pub fn disabled() -> Self {
65        Self {
66            enabled: false,
67            ..Self::default()
68        }
69    }
70
71    /// Attach the session environment policy to this run.
72    pub(crate) fn with_environment_policy(mut self, environment: EnvironmentPolicyConfig) -> Self {
73        self.environment = environment;
74        self
75    }
76
77    /// Constrain the default sandbox to an explicit workspace root.
78    pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
79        self.workspace_root = Some(workspace_root.into());
80        self
81    }
82
83    /// Add writable roots to the default sandbox policy.
84    pub fn with_write_roots<I>(mut self, write_roots: I) -> Self
85    where
86        I: IntoIterator<Item = PathBuf>,
87    {
88        self.write_roots = write_roots.into_iter().collect();
89        self
90    }
91
92    /// Add read-only roots to the default sandbox policy.
93    pub fn with_read_only_roots<I>(mut self, read_only_roots: I) -> Self
94    where
95        I: IntoIterator<Item = PathBuf>,
96    {
97        self.read_only_roots = read_only_roots.into_iter().collect();
98        self
99    }
100
101    /// Add subprocess-only read roots to the default sandbox policy.
102    pub fn with_process_read_roots<I>(mut self, roots: I) -> Self
103    where
104        I: IntoIterator<Item = PathBuf>,
105    {
106        self.process_read_roots = roots.into_iter().collect();
107        self
108    }
109
110    /// Add subprocess-only write roots to the default sandbox policy.
111    pub fn with_process_write_roots<I>(mut self, roots: I) -> Self
112    where
113        I: IntoIterator<Item = PathBuf>,
114    {
115        self.process_write_roots = roots.into_iter().collect();
116        self
117    }
118}
119
120/// Build the run's confinement options from the shared sandbox flag block.
121///
122/// Every command that launches a run goes through here, so `harn run` and
123/// `harn time run` cannot end up enforcing different policies for the same
124/// flags — the reason [`crate::cli::SandboxArgs`] is one struct rather than a
125/// block copied per command.
126pub(crate) fn sandbox_options_from_args(args: &crate::cli::SandboxArgs) -> RunSandboxOptions {
127    // Parse the environment policy at the same args boundary as the filesystem
128    // roots; a malformed `--grant` fails the invocation loudly here.
129    let capability = EnvironmentPolicyConfig::from_flags(args.environment_policy, &args.grant)
130        .unwrap_or_else(|error| crate::command_error(&error));
131    let options = if args.no_sandbox {
132        RunSandboxOptions::disabled()
133    } else {
134        RunSandboxOptions::sandboxed(args.allow_process_network)
135    };
136    options
137        .with_write_roots(args.write_root.iter().cloned())
138        .with_read_only_roots(args.read_only_root.iter().cloned())
139        .with_process_read_roots(args.sandbox_read_root.iter().cloned())
140        .with_process_write_roots(args.sandbox_write_root.iter().cloned())
141        .with_environment_policy(capability)
142}
143
144struct ExecutionPolicyGuard;
145
146impl Drop for ExecutionPolicyGuard {
147    fn drop(&mut self) {
148        harn_vm::orchestration::pop_execution_policy();
149    }
150}
151
152pub(super) struct RunSandboxScope {
153    _execution_policy: Option<ExecutionPolicyGuard>,
154    _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
155    _ssrf_guard: Option<harn_vm::egress::SsrfGuardScope>,
156}
157
158impl RunSandboxScope {
159    fn disabled() -> Self {
160        Self {
161            _execution_policy: None,
162            _egress_policy: None,
163            _ssrf_guard: None,
164        }
165    }
166}
167
168pub(super) fn install_run_sandbox_scope(
169    options: &RunSandboxOptions,
170    workspace_root: &Path,
171    stderr: &mut String,
172) -> RunSandboxScope {
173    if !options.enabled {
174        stderr.push_str(
175            "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
176        );
177        return RunSandboxScope::disabled();
178    }
179
180    let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
181        harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
182            workspace_root,
183            &options.write_roots,
184            &options.read_only_roots,
185            &options.process_read_roots,
186            &options.process_write_roots,
187            options.allow_process_network,
188        ));
189        Some(ExecutionPolicyGuard)
190    } else {
191        None
192    };
193    let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
194    // Default-on the SSRF private-address guard for outbound HTTP. Callers can
195    // opt out with `harness.net.egress_policy({block_private:"off"})` /
196    // `HARN_EGRESS_BLOCK_PRIVATE=off`.
197    let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
198
199    // Disclose caller-declared grants that widened the default sandbox. This is
200    // the narrow-scope counterpart to the `--no-sandbox` banner: a routine run
201    // with no grants stays silent (no alarm fatigue), while a run that opened an
202    // out-of-jail write/read root or subprocess network gets exactly one line
203    // naming the delta. The filesystem, process, and egress defaults stay armed.
204    if let Some(disclosure) = sandbox_grant_disclosure(options) {
205        stderr.push_str(&disclosure);
206    }
207
208    RunSandboxScope {
209        _execution_policy: execution_policy,
210        _egress_policy: egress_policy,
211        _ssrf_guard: ssrf_guard,
212    }
213}
214
215/// Render the one-line disclosure naming exactly how caller-declared grants
216/// widened the active sandbox profile, or `None` for an unmodified default run.
217/// Kept separate from the `--no-sandbox` warning so the full escape hatch and a
218/// narrow grant read differently in the terminal.
219pub(super) fn sandbox_grant_disclosure(options: &RunSandboxOptions) -> Option<String> {
220    if !options.enabled {
221        return None;
222    }
223    let mut deltas: Vec<String> = Vec::new();
224    if !options.write_roots.is_empty() {
225        deltas.push(format!(
226            "extra write root{}: {}",
227            plural_suffix(options.write_roots.len()),
228            display_grant_roots(&options.write_roots),
229        ));
230    }
231    if !options.read_only_roots.is_empty() {
232        deltas.push(format!(
233            "extra read-only root{}: {}",
234            plural_suffix(options.read_only_roots.len()),
235            display_grant_roots(&options.read_only_roots),
236        ));
237    }
238    if !options.process_write_roots.is_empty() {
239        deltas.push(format!(
240            "extra subprocess write root{}: {}",
241            plural_suffix(options.process_write_roots.len()),
242            display_grant_roots(&options.process_write_roots),
243        ));
244    }
245    if !options.process_read_roots.is_empty() {
246        deltas.push(format!(
247            "extra subprocess read root{}: {}",
248            plural_suffix(options.process_read_roots.len()),
249            display_grant_roots(&options.process_read_roots),
250        ));
251    }
252    if options.allow_process_network {
253        deltas.push("subprocess network allowed".to_string());
254    }
255    if deltas.is_empty() {
256        return None;
257    }
258    Some(format!("sandbox active; {}\n", deltas.join("; ")))
259}
260
261/// Render each grant to the exact path the sandbox jails it to, so the disclosed
262/// string always equals the enforced write/read root.
263fn display_grant_roots(roots: &[PathBuf]) -> String {
264    roots
265        .iter()
266        .map(|path| rendered_jail_root(path).display().to_string())
267        .collect::<Vec<_>>()
268        .join(", ")
269}
270
271/// The absolute, symlink-canonicalized path the sandbox actually jails a grant
272/// to. `default_run_capability_policy` seeds the policy's workspace roots by
273/// running each grant through `normalize_run_workspace_root`; the runtime then
274/// renders every root to its jail path via `render_policy_root` (lexical
275/// normalize + best-effort canonicalize). Disclosure and attestation reproduce
276/// that full pipeline through the runtime's single owner so a reported path —
277/// symlinks and `..` segments resolved — equals the enforced root and never
278/// diverges from the jail. Canonicalization is best-effort and never panics.
279fn rendered_jail_root(path: &Path) -> PathBuf {
280    harn_vm::process_sandbox::render_policy_root(
281        &normalize_run_workspace_root(path).display().to_string(),
282    )
283}
284
285/// Render a policy's already-configured root strings to their enforced jail
286/// paths through the same single owner the OS backends use.
287fn render_policy_roots(roots: &[String]) -> Vec<String> {
288    roots
289        .iter()
290        .map(|root| {
291            harn_vm::process_sandbox::render_policy_root(root)
292                .display()
293                .to_string()
294        })
295        .collect()
296}
297
298fn plural_suffix(count: usize) -> &'static str {
299    if count == 1 {
300        ""
301    } else {
302        "s"
303    }
304}
305
306pub(super) fn default_run_capability_policy(
307    workspace_root: &Path,
308    write_roots: &[PathBuf],
309    read_only_roots: &[PathBuf],
310    process_read_roots: &[PathBuf],
311    process_write_roots: &[PathBuf],
312    allow_process_network: bool,
313) -> harn_vm::orchestration::CapabilityPolicy {
314    let mut workspace_roots = Vec::with_capacity(1 + write_roots.len());
315    workspace_roots.push(
316        normalize_run_workspace_root(workspace_root)
317            .display()
318            .to_string(),
319    );
320    workspace_roots.extend(
321        write_roots
322            .iter()
323            .map(|path| normalize_run_workspace_root(path.as_path()))
324            .map(|path| path.display().to_string()),
325    );
326
327    let mut process_read_roots = process_read_roots
328        .iter()
329        .map(|path| normalize_run_workspace_root(path.as_path()))
330        .map(|path| path.display().to_string())
331        .collect::<Vec<_>>();
332    // A sandboxed Harn script may delegate back to the exact runtime that is
333    // executing it (for example through `sh` or `/usr/bin/time`). OS sandbox
334    // policies are inherited across that wrapper process, so the runtime must
335    // be in the process-only policy rather than granted only when it is the
336    // immediate `process.run` program.
337    //
338    // Grant the file itself, never its containing directory. This preserves
339    // attenuation while making self-hosted checks independent of whether the
340    // verified binary lives in the workspace, `/usr/bin`, or a CI artifact
341    // directory such as GitHub's `$RUNNER_TEMP`.
342    if let Ok(runtime_executable) = std::env::current_exe() {
343        let runtime_executable = normalize_run_workspace_root(&runtime_executable)
344            .display()
345            .to_string();
346        if !process_read_roots.contains(&runtime_executable) {
347            process_read_roots.push(runtime_executable);
348        }
349    }
350
351    harn_vm::orchestration::CapabilityPolicy {
352        workspace_roots,
353        read_only_roots: read_only_roots
354            .iter()
355            .map(|path| normalize_run_workspace_root(path.as_path()))
356            .map(|path| path.display().to_string())
357            .collect(),
358        process_sandbox: harn_vm::orchestration::ProcessSandboxPolicy {
359            presets: None,
360            read_roots: process_read_roots,
361            write_roots: process_write_roots
362                .iter()
363                .map(|path| normalize_run_workspace_root(path.as_path()))
364                .map(|path| path.display().to_string())
365                .collect(),
366        },
367        side_effect_level: Some(
368            if allow_process_network {
369                harn_vm::tool_annotations::SideEffectLevel::Network
370            } else {
371                harn_vm::tool_annotations::SideEffectLevel::ProcessExec
372            }
373            .as_str()
374            .to_string(),
375        ),
376        sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
377        ..harn_vm::orchestration::CapabilityPolicy::default()
378    }
379}
380
381fn normalize_run_workspace_root(path: &Path) -> PathBuf {
382    if path.is_absolute() {
383        return path.to_path_buf();
384    }
385    std::env::current_dir()
386        .map(|cwd| cwd.join(path))
387        .unwrap_or_else(|_| path.to_path_buf())
388}
389
390pub(super) fn default_run_workspace_root(
391    project_root: Option<&Path>,
392    source_parent: &Path,
393) -> PathBuf {
394    project_root
395        .map(Path::to_path_buf)
396        .or_else(|| std::env::current_dir().ok())
397        .unwrap_or_else(|| source_parent.to_path_buf())
398}
399
400pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
401    let active_policy = harn_vm::orchestration::current_execution_policy();
402    let active = active_policy.is_some();
403    let workspace_roots = active_policy
404        .as_ref()
405        .map(|policy| render_policy_roots(&policy.workspace_roots))
406        .unwrap_or_default();
407    let read_only_roots = active_policy
408        .as_ref()
409        .map(|policy| render_policy_roots(&policy.read_only_roots))
410        .unwrap_or_default();
411    let profile = active_policy
412        .as_ref()
413        .map(|policy| policy.sandbox_profile.as_str())
414        .unwrap_or("unrestricted");
415    let side_effect_level = active_policy
416        .as_ref()
417        .and_then(|policy| policy.side_effect_level.as_deref())
418        .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
419    let process_network_enabled =
420        harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
421            >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
422    let egress = if sandbox.enabled {
423        "explicit_policy_required"
424    } else if active {
425        "host_policy"
426    } else {
427        "unrestricted"
428    };
429    let write_roots = sandbox
430        .write_roots
431        .iter()
432        .map(|path| rendered_jail_root(path).display().to_string())
433        .collect::<Vec<_>>();
434    let process_read_roots = active_policy
435        .as_ref()
436        .map(|policy| render_policy_roots(&policy.process_sandbox.read_roots))
437        .unwrap_or_default();
438    let process_write_roots = active_policy
439        .as_ref()
440        .map(|policy| render_policy_roots(&policy.process_sandbox.write_roots))
441        .unwrap_or_default();
442
443    serde_json::json!({
444        "run_default_enabled": sandbox.enabled,
445        "active": active,
446        "workspace_roots": workspace_roots,
447        "write_roots": write_roots,
448        "read_only_roots": read_only_roots,
449        "process_read_roots": process_read_roots,
450        "process_write_roots": process_write_roots,
451        "profile": profile,
452        "process_network_requested": sandbox.allow_process_network,
453        "process_network_enabled": process_network_enabled,
454        "side_effect_level": side_effect_level,
455        "egress": egress,
456    })
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn default_run_discloses_nothing() {
465        assert_eq!(
466            sandbox_grant_disclosure(&RunSandboxOptions::default()),
467            None
468        );
469    }
470
471    #[test]
472    fn disabled_sandbox_discloses_nothing() {
473        // `--no-sandbox` carries its own blanket warning; the grant disclosure
474        // never fires for a disabled sandbox even if fields were populated.
475        let mut options = RunSandboxOptions::disabled();
476        options.write_roots = vec![PathBuf::from("/out/coordination")];
477        assert_eq!(sandbox_grant_disclosure(&options), None);
478    }
479
480    #[test]
481    fn single_write_root_names_the_delta() {
482        let root = PathBuf::from("/out/coordination");
483        let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
484        // The disclosed path is the enforced jail root, which is platform
485        // specific (a verbatim `\\?\` path on Windows). Derive the expectation
486        // from the same renderer the production path uses so the assertion pins
487        // the singular wording and composition without hard-coding a Unix-only
488        // rendering.
489        let expected = format!(
490            "sandbox active; extra write root: {}\n",
491            rendered_jail_root(&root).display()
492        );
493        assert_eq!(
494            sandbox_grant_disclosure(&options).as_deref(),
495            Some(expected.as_str()),
496        );
497    }
498
499    #[test]
500    fn multiple_grants_join_on_one_line() {
501        let write_a = PathBuf::from("/out/a");
502        let write_b = PathBuf::from("/out/b");
503        let read_shared = PathBuf::from("/ref/shared");
504        let options = RunSandboxOptions::sandboxed(true)
505            .with_write_roots(vec![write_a.clone(), write_b.clone()])
506            .with_read_only_roots(vec![read_shared.clone()]);
507        // Pin the plural wording, the delta ordering (write roots, then
508        // read-only, then network), and the `, ` / `; ` joins while deriving
509        // each enforced jail path from the shared renderer so the test holds on
510        // Windows, where jail roots render as verbatim paths.
511        let expected = format!(
512            "sandbox active; extra write roots: {}, {}; \
513             extra read-only root: {}; subprocess network allowed\n",
514            rendered_jail_root(&write_a).display(),
515            rendered_jail_root(&write_b).display(),
516            rendered_jail_root(&read_shared).display(),
517        );
518        assert_eq!(
519            sandbox_grant_disclosure(&options).as_deref(),
520            Some(expected.as_str()),
521        );
522    }
523
524    #[test]
525    fn process_network_alone_is_disclosed() {
526        let options = RunSandboxOptions::sandboxed(true);
527        assert_eq!(
528            sandbox_grant_disclosure(&options).as_deref(),
529            Some("sandbox active; subprocess network allowed\n"),
530        );
531    }
532
533    #[test]
534    fn subprocess_roots_stay_process_only_and_are_disclosed() {
535        let workspace = tempfile::tempdir().unwrap();
536        let process_read = workspace.path().join("sdk");
537        let process_write = workspace.path().join("cache");
538        let options = RunSandboxOptions::default()
539            .with_process_read_roots(vec![process_read.clone()])
540            .with_process_write_roots(vec![process_write.clone()]);
541        let policy = default_run_capability_policy(
542            workspace.path(),
543            &[],
544            &[],
545            &options.process_read_roots,
546            &options.process_write_roots,
547            false,
548        );
549
550        assert_eq!(
551            policy.process_sandbox.read_roots,
552            vec![
553                process_read.display().to_string(),
554                normalize_run_workspace_root(&std::env::current_exe().unwrap())
555                    .display()
556                    .to_string(),
557            ]
558        );
559        assert_eq!(
560            policy.process_sandbox.write_roots,
561            vec![process_write.display().to_string()]
562        );
563        assert_eq!(
564            policy.workspace_roots,
565            vec![workspace.path().display().to_string()]
566        );
567        assert!(policy.read_only_roots.is_empty());
568
569        let disclosure = sandbox_grant_disclosure(&options).unwrap();
570        assert!(disclosure.contains("extra subprocess write root"));
571        assert!(disclosure.contains("extra subprocess read root"));
572    }
573
574    #[test]
575    fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
576        // The whole point of the disclosure is precision, so a grant given
577        // through a symlink or with `..` segments must be disclosed as the path
578        // the sandbox actually jails to — the runtime canonicalizes at policy
579        // render time — not the pre-canonical string the caller typed.
580        let temp = tempfile::tempdir().expect("temp dir");
581        let real = temp.path().join("real-state");
582        std::fs::create_dir(&real).expect("create real dir");
583
584        // Symlinked grant resolves to the real target it jails to.
585        #[cfg(unix)]
586        {
587            let link = temp.path().join("link-state");
588            std::os::unix::fs::symlink(&real, &link).expect("symlink");
589            let jailed = rendered_jail_root(&link);
590            assert_eq!(
591                jailed,
592                real.canonicalize().expect("canonical real"),
593                "symlinked grant should jail to the real target"
594            );
595            let line = sandbox_grant_disclosure(
596                &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
597            )
598            .expect("disclosure");
599            assert!(
600                line.contains(&jailed.display().to_string())
601                    && !line.contains(&link.display().to_string()),
602                "symlinked grant must disclose the canonical jail path: {line}"
603            );
604        }
605
606        // A `..`-containing grant collapses to the same canonical jail path.
607        let dotted = real.join("..").join("real-state");
608        let jailed_dots = rendered_jail_root(&dotted);
609        assert_eq!(
610            jailed_dots,
611            real.canonicalize().expect("canonical real"),
612            "`..` grant should collapse to the real dir"
613        );
614        let dots_line =
615            sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
616                .expect("disclosure");
617        assert!(
618            dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
619            "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
620        );
621    }
622}