Skip to main content

harn_cli/commands/run/
sandbox.rs

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