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 `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    harn_vm::orchestration::CapabilityPolicy {
328        workspace_roots,
329        read_only_roots: read_only_roots
330            .iter()
331            .map(|path| normalize_run_workspace_root(path.as_path()))
332            .map(|path| path.display().to_string())
333            .collect(),
334        process_sandbox: harn_vm::orchestration::ProcessSandboxPolicy {
335            presets: None,
336            read_roots: process_read_roots
337                .iter()
338                .map(|path| normalize_run_workspace_root(path.as_path()))
339                .map(|path| path.display().to_string())
340                .collect(),
341            write_roots: process_write_roots
342                .iter()
343                .map(|path| normalize_run_workspace_root(path.as_path()))
344                .map(|path| path.display().to_string())
345                .collect(),
346        },
347        side_effect_level: Some(
348            if allow_process_network {
349                harn_vm::tool_annotations::SideEffectLevel::Network
350            } else {
351                harn_vm::tool_annotations::SideEffectLevel::ProcessExec
352            }
353            .as_str()
354            .to_string(),
355        ),
356        sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
357        ..harn_vm::orchestration::CapabilityPolicy::default()
358    }
359}
360
361fn normalize_run_workspace_root(path: &Path) -> PathBuf {
362    if path.is_absolute() {
363        return path.to_path_buf();
364    }
365    std::env::current_dir()
366        .map(|cwd| cwd.join(path))
367        .unwrap_or_else(|_| path.to_path_buf())
368}
369
370pub(super) fn default_run_workspace_root(
371    project_root: Option<&Path>,
372    source_parent: &Path,
373) -> PathBuf {
374    project_root
375        .map(Path::to_path_buf)
376        .or_else(|| std::env::current_dir().ok())
377        .unwrap_or_else(|| source_parent.to_path_buf())
378}
379
380pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
381    let active_policy = harn_vm::orchestration::current_execution_policy();
382    let active = active_policy.is_some();
383    let workspace_roots = active_policy
384        .as_ref()
385        .map(|policy| render_policy_roots(&policy.workspace_roots))
386        .unwrap_or_default();
387    let read_only_roots = active_policy
388        .as_ref()
389        .map(|policy| render_policy_roots(&policy.read_only_roots))
390        .unwrap_or_default();
391    let profile = active_policy
392        .as_ref()
393        .map(|policy| policy.sandbox_profile.as_str())
394        .unwrap_or("unrestricted");
395    let side_effect_level = active_policy
396        .as_ref()
397        .and_then(|policy| policy.side_effect_level.as_deref())
398        .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
399    let process_network_enabled =
400        harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
401            >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
402    let egress = if sandbox.enabled {
403        "explicit_policy_required"
404    } else if active {
405        "host_policy"
406    } else {
407        "unrestricted"
408    };
409    let write_roots = sandbox
410        .write_roots
411        .iter()
412        .map(|path| rendered_jail_root(path).display().to_string())
413        .collect::<Vec<_>>();
414    let process_read_roots = active_policy
415        .as_ref()
416        .map(|policy| render_policy_roots(&policy.process_sandbox.read_roots))
417        .unwrap_or_default();
418    let process_write_roots = active_policy
419        .as_ref()
420        .map(|policy| render_policy_roots(&policy.process_sandbox.write_roots))
421        .unwrap_or_default();
422
423    serde_json::json!({
424        "run_default_enabled": sandbox.enabled,
425        "active": active,
426        "workspace_roots": workspace_roots,
427        "write_roots": write_roots,
428        "read_only_roots": read_only_roots,
429        "process_read_roots": process_read_roots,
430        "process_write_roots": process_write_roots,
431        "profile": profile,
432        "process_network_requested": sandbox.allow_process_network,
433        "process_network_enabled": process_network_enabled,
434        "side_effect_level": side_effect_level,
435        "egress": egress,
436    })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn default_run_discloses_nothing() {
445        assert_eq!(
446            sandbox_grant_disclosure(&RunSandboxOptions::default()),
447            None
448        );
449    }
450
451    #[test]
452    fn disabled_sandbox_discloses_nothing() {
453        // `--no-sandbox` carries its own blanket warning; the grant disclosure
454        // never fires for a disabled sandbox even if fields were populated.
455        let mut options = RunSandboxOptions::disabled();
456        options.write_roots = vec![PathBuf::from("/out/coordination")];
457        assert_eq!(sandbox_grant_disclosure(&options), None);
458    }
459
460    #[test]
461    fn single_write_root_names_the_delta() {
462        let root = PathBuf::from("/out/coordination");
463        let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
464        // The disclosed path is the enforced jail root, which is platform
465        // specific (a verbatim `\\?\` path on Windows). Derive the expectation
466        // from the same renderer the production path uses so the assertion pins
467        // the singular wording and composition without hard-coding a Unix-only
468        // rendering.
469        let expected = format!(
470            "sandbox active; extra write root: {}\n",
471            rendered_jail_root(&root).display()
472        );
473        assert_eq!(
474            sandbox_grant_disclosure(&options).as_deref(),
475            Some(expected.as_str()),
476        );
477    }
478
479    #[test]
480    fn multiple_grants_join_on_one_line() {
481        let write_a = PathBuf::from("/out/a");
482        let write_b = PathBuf::from("/out/b");
483        let read_shared = PathBuf::from("/ref/shared");
484        let options = RunSandboxOptions::sandboxed(true)
485            .with_write_roots(vec![write_a.clone(), write_b.clone()])
486            .with_read_only_roots(vec![read_shared.clone()]);
487        // Pin the plural wording, the delta ordering (write roots, then
488        // read-only, then network), and the `, ` / `; ` joins while deriving
489        // each enforced jail path from the shared renderer so the test holds on
490        // Windows, where jail roots render as verbatim paths.
491        let expected = format!(
492            "sandbox active; extra write roots: {}, {}; \
493             extra read-only root: {}; subprocess network allowed\n",
494            rendered_jail_root(&write_a).display(),
495            rendered_jail_root(&write_b).display(),
496            rendered_jail_root(&read_shared).display(),
497        );
498        assert_eq!(
499            sandbox_grant_disclosure(&options).as_deref(),
500            Some(expected.as_str()),
501        );
502    }
503
504    #[test]
505    fn process_network_alone_is_disclosed() {
506        let options = RunSandboxOptions::sandboxed(true);
507        assert_eq!(
508            sandbox_grant_disclosure(&options).as_deref(),
509            Some("sandbox active; subprocess network allowed\n"),
510        );
511    }
512
513    #[test]
514    fn subprocess_roots_stay_process_only_and_are_disclosed() {
515        let workspace = tempfile::tempdir().unwrap();
516        let process_read = workspace.path().join("sdk");
517        let process_write = workspace.path().join("cache");
518        let options = RunSandboxOptions::default()
519            .with_process_read_roots(vec![process_read.clone()])
520            .with_process_write_roots(vec![process_write.clone()]);
521        let policy = default_run_capability_policy(
522            workspace.path(),
523            &[],
524            &[],
525            &options.process_read_roots,
526            &options.process_write_roots,
527            false,
528        );
529
530        assert_eq!(
531            policy.process_sandbox.read_roots,
532            vec![process_read.display().to_string()]
533        );
534        assert_eq!(
535            policy.process_sandbox.write_roots,
536            vec![process_write.display().to_string()]
537        );
538        assert_eq!(
539            policy.workspace_roots,
540            vec![workspace.path().display().to_string()]
541        );
542        assert!(policy.read_only_roots.is_empty());
543
544        let disclosure = sandbox_grant_disclosure(&options).unwrap();
545        assert!(disclosure.contains("extra subprocess write root"));
546        assert!(disclosure.contains("extra subprocess read root"));
547    }
548
549    #[test]
550    fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
551        // The whole point of the disclosure is precision, so a grant given
552        // through a symlink or with `..` segments must be disclosed as the path
553        // the sandbox actually jails to — the runtime canonicalizes at policy
554        // render time — not the pre-canonical string the caller typed.
555        let temp = tempfile::tempdir().expect("temp dir");
556        let real = temp.path().join("real-state");
557        std::fs::create_dir(&real).expect("create real dir");
558
559        // Symlinked grant resolves to the real target it jails to.
560        #[cfg(unix)]
561        {
562            let link = temp.path().join("link-state");
563            std::os::unix::fs::symlink(&real, &link).expect("symlink");
564            let jailed = rendered_jail_root(&link);
565            assert_eq!(
566                jailed,
567                real.canonicalize().expect("canonical real"),
568                "symlinked grant should jail to the real target"
569            );
570            let line = sandbox_grant_disclosure(
571                &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
572            )
573            .expect("disclosure");
574            assert!(
575                line.contains(&jailed.display().to_string())
576                    && !line.contains(&link.display().to_string()),
577                "symlinked grant must disclose the canonical jail path: {line}"
578            );
579        }
580
581        // A `..`-containing grant collapses to the same canonical jail path.
582        let dotted = real.join("..").join("real-state");
583        let jailed_dots = rendered_jail_root(&dotted);
584        assert_eq!(
585            jailed_dots,
586            real.canonicalize().expect("canonical real"),
587            "`..` grant should collapse to the real dir"
588        );
589        let dots_line =
590            sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
591                .expect("disclosure");
592        assert!(
593            dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
594            "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
595        );
596    }
597}