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    /// Raise the direct-run side-effect ceiling to permit network-capable
19    /// subprocesses without disabling filesystem or process confinement.
20    pub allow_process_network: bool,
21}
22
23impl Default for RunSandboxOptions {
24    fn default() -> Self {
25        Self {
26            enabled: true,
27            workspace_root: None,
28            write_roots: Vec::new(),
29            read_only_roots: Vec::new(),
30            allow_process_network: false,
31        }
32    }
33}
34
35impl RunSandboxOptions {
36    /// Construct the default sandbox with an explicit subprocess-network choice.
37    pub fn sandboxed(allow_process_network: bool) -> Self {
38        Self::default().with_process_network(allow_process_network)
39    }
40
41    /// Permit addressable sockets in commands spawned by this run while the
42    /// rest of the direct-run sandbox remains active.
43    pub fn with_process_network(mut self, enabled: bool) -> Self {
44        self.allow_process_network = enabled;
45        self
46    }
47
48    /// Disable the default direct-run sandbox and egress guard.
49    pub fn disabled() -> Self {
50        Self {
51            enabled: false,
52            workspace_root: None,
53            write_roots: Vec::new(),
54            read_only_roots: Vec::new(),
55            allow_process_network: false,
56        }
57    }
58
59    /// Constrain the default sandbox to an explicit workspace root.
60    pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
61        self.workspace_root = Some(workspace_root.into());
62        self
63    }
64
65    /// Add writable roots to the default sandbox policy.
66    pub fn with_write_roots<I>(mut self, write_roots: I) -> Self
67    where
68        I: IntoIterator<Item = PathBuf>,
69    {
70        self.write_roots = write_roots.into_iter().collect();
71        self
72    }
73
74    /// Add read-only roots to the default sandbox policy.
75    pub fn with_read_only_roots<I>(mut self, read_only_roots: I) -> Self
76    where
77        I: IntoIterator<Item = PathBuf>,
78    {
79        self.read_only_roots = read_only_roots.into_iter().collect();
80        self
81    }
82}
83
84struct ExecutionPolicyGuard;
85
86impl Drop for ExecutionPolicyGuard {
87    fn drop(&mut self) {
88        harn_vm::orchestration::pop_execution_policy();
89    }
90}
91
92pub(super) struct RunSandboxScope {
93    _execution_policy: Option<ExecutionPolicyGuard>,
94    _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
95    _ssrf_guard: Option<harn_vm::egress::SsrfGuardScope>,
96}
97
98impl RunSandboxScope {
99    fn disabled() -> Self {
100        Self {
101            _execution_policy: None,
102            _egress_policy: None,
103            _ssrf_guard: None,
104        }
105    }
106}
107
108pub(super) fn install_run_sandbox_scope(
109    options: &RunSandboxOptions,
110    workspace_root: &Path,
111    stderr: &mut String,
112) -> RunSandboxScope {
113    if !options.enabled {
114        stderr.push_str(
115            "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
116        );
117        return RunSandboxScope::disabled();
118    }
119
120    let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
121        harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
122            workspace_root,
123            &options.write_roots,
124            &options.read_only_roots,
125            options.allow_process_network,
126        ));
127        Some(ExecutionPolicyGuard)
128    } else {
129        None
130    };
131    let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
132    // Default-on the SSRF private-address guard for outbound HTTP. Callers can
133    // opt out with `egress_policy({block_private:"off"})` /
134    // `HARN_EGRESS_BLOCK_PRIVATE=off`.
135    let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
136
137    // Disclose caller-declared grants that widened the default sandbox. This is
138    // the narrow-scope counterpart to the `--no-sandbox` banner: a routine run
139    // with no grants stays silent (no alarm fatigue), while a run that opened an
140    // out-of-jail write/read root or subprocess network gets exactly one line
141    // naming the delta. The filesystem, process, and egress defaults stay armed.
142    if let Some(disclosure) = sandbox_grant_disclosure(options) {
143        stderr.push_str(&disclosure);
144    }
145
146    RunSandboxScope {
147        _execution_policy: execution_policy,
148        _egress_policy: egress_policy,
149        _ssrf_guard: ssrf_guard,
150    }
151}
152
153/// Render the one-line disclosure naming exactly how caller-declared grants
154/// widened the active sandbox profile, or `None` for an unmodified default run.
155/// Kept separate from the `--no-sandbox` warning so the full escape hatch and a
156/// narrow grant read differently in the terminal.
157pub(super) fn sandbox_grant_disclosure(options: &RunSandboxOptions) -> Option<String> {
158    if !options.enabled {
159        return None;
160    }
161    let mut deltas: Vec<String> = Vec::new();
162    if !options.write_roots.is_empty() {
163        deltas.push(format!(
164            "extra write root{}: {}",
165            plural_suffix(options.write_roots.len()),
166            display_grant_roots(&options.write_roots),
167        ));
168    }
169    if !options.read_only_roots.is_empty() {
170        deltas.push(format!(
171            "extra read-only root{}: {}",
172            plural_suffix(options.read_only_roots.len()),
173            display_grant_roots(&options.read_only_roots),
174        ));
175    }
176    if options.allow_process_network {
177        deltas.push("subprocess network allowed".to_string());
178    }
179    if deltas.is_empty() {
180        return None;
181    }
182    Some(format!("sandbox active; {}\n", deltas.join("; ")))
183}
184
185/// Render each grant to the exact path the sandbox jails it to, so the disclosed
186/// string always equals the enforced write/read root.
187fn display_grant_roots(roots: &[PathBuf]) -> String {
188    roots
189        .iter()
190        .map(|path| rendered_jail_root(path).display().to_string())
191        .collect::<Vec<_>>()
192        .join(", ")
193}
194
195/// The absolute, symlink-canonicalized path the sandbox actually jails a grant
196/// to. `default_run_capability_policy` seeds the policy's workspace roots by
197/// running each grant through `normalize_run_workspace_root`; the runtime then
198/// renders every root to its jail path via `render_policy_root` (lexical
199/// normalize + best-effort canonicalize). Disclosure and attestation reproduce
200/// that full pipeline through the runtime's single owner so a reported path —
201/// symlinks and `..` segments resolved — equals the enforced root and never
202/// diverges from the jail. Canonicalization is best-effort and never panics.
203fn rendered_jail_root(path: &Path) -> PathBuf {
204    harn_vm::process_sandbox::render_policy_root(
205        &normalize_run_workspace_root(path).display().to_string(),
206    )
207}
208
209/// Render a policy's already-configured root strings to their enforced jail
210/// paths through the same single owner the OS backends use.
211fn render_policy_roots(roots: &[String]) -> Vec<String> {
212    roots
213        .iter()
214        .map(|root| {
215            harn_vm::process_sandbox::render_policy_root(root)
216                .display()
217                .to_string()
218        })
219        .collect()
220}
221
222fn plural_suffix(count: usize) -> &'static str {
223    if count == 1 {
224        ""
225    } else {
226        "s"
227    }
228}
229
230pub(super) fn default_run_capability_policy(
231    workspace_root: &Path,
232    write_roots: &[PathBuf],
233    read_only_roots: &[PathBuf],
234    allow_process_network: bool,
235) -> harn_vm::orchestration::CapabilityPolicy {
236    let mut workspace_roots = Vec::with_capacity(1 + write_roots.len());
237    workspace_roots.push(
238        normalize_run_workspace_root(workspace_root)
239            .display()
240            .to_string(),
241    );
242    workspace_roots.extend(
243        write_roots
244            .iter()
245            .map(|path| normalize_run_workspace_root(path.as_path()))
246            .map(|path| path.display().to_string()),
247    );
248
249    harn_vm::orchestration::CapabilityPolicy {
250        workspace_roots,
251        read_only_roots: read_only_roots
252            .iter()
253            .map(|path| normalize_run_workspace_root(path.as_path()))
254            .map(|path| path.display().to_string())
255            .collect(),
256        side_effect_level: Some(
257            if allow_process_network {
258                harn_vm::tool_annotations::SideEffectLevel::Network
259            } else {
260                harn_vm::tool_annotations::SideEffectLevel::ProcessExec
261            }
262            .as_str()
263            .to_string(),
264        ),
265        sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
266        ..harn_vm::orchestration::CapabilityPolicy::default()
267    }
268}
269
270fn normalize_run_workspace_root(path: &Path) -> PathBuf {
271    if path.is_absolute() {
272        return path.to_path_buf();
273    }
274    std::env::current_dir()
275        .map(|cwd| cwd.join(path))
276        .unwrap_or_else(|_| path.to_path_buf())
277}
278
279pub(super) fn default_run_workspace_root(
280    project_root: Option<&Path>,
281    source_parent: &Path,
282) -> PathBuf {
283    project_root
284        .map(Path::to_path_buf)
285        .or_else(|| std::env::current_dir().ok())
286        .unwrap_or_else(|| source_parent.to_path_buf())
287}
288
289pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
290    let active_policy = harn_vm::orchestration::current_execution_policy();
291    let active = active_policy.is_some();
292    let workspace_roots = active_policy
293        .as_ref()
294        .map(|policy| render_policy_roots(&policy.workspace_roots))
295        .unwrap_or_default();
296    let read_only_roots = active_policy
297        .as_ref()
298        .map(|policy| render_policy_roots(&policy.read_only_roots))
299        .unwrap_or_default();
300    let profile = active_policy
301        .as_ref()
302        .map(|policy| policy.sandbox_profile.as_str())
303        .unwrap_or("unrestricted");
304    let side_effect_level = active_policy
305        .as_ref()
306        .and_then(|policy| policy.side_effect_level.as_deref())
307        .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
308    let process_network_enabled =
309        harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
310            >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
311    let egress = if sandbox.enabled {
312        "explicit_policy_required"
313    } else if active {
314        "host_policy"
315    } else {
316        "unrestricted"
317    };
318    let write_roots = sandbox
319        .write_roots
320        .iter()
321        .map(|path| rendered_jail_root(path).display().to_string())
322        .collect::<Vec<_>>();
323
324    serde_json::json!({
325        "run_default_enabled": sandbox.enabled,
326        "active": active,
327        "workspace_roots": workspace_roots,
328        "write_roots": write_roots,
329        "read_only_roots": read_only_roots,
330        "profile": profile,
331        "process_network_requested": sandbox.allow_process_network,
332        "process_network_enabled": process_network_enabled,
333        "side_effect_level": side_effect_level,
334        "egress": egress,
335    })
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn default_run_discloses_nothing() {
344        assert_eq!(
345            sandbox_grant_disclosure(&RunSandboxOptions::default()),
346            None
347        );
348    }
349
350    #[test]
351    fn disabled_sandbox_discloses_nothing() {
352        // `--no-sandbox` carries its own blanket warning; the grant disclosure
353        // never fires for a disabled sandbox even if fields were populated.
354        let mut options = RunSandboxOptions::disabled();
355        options.write_roots = vec![PathBuf::from("/out/coordination")];
356        assert_eq!(sandbox_grant_disclosure(&options), None);
357    }
358
359    #[test]
360    fn single_write_root_names_the_delta() {
361        let options =
362            RunSandboxOptions::default().with_write_roots(vec![PathBuf::from("/out/coordination")]);
363        assert_eq!(
364            sandbox_grant_disclosure(&options).as_deref(),
365            Some("sandbox active; extra write root: /out/coordination\n"),
366        );
367    }
368
369    #[test]
370    fn multiple_grants_join_on_one_line() {
371        let options = RunSandboxOptions::sandboxed(true)
372            .with_write_roots(vec![PathBuf::from("/out/a"), PathBuf::from("/out/b")])
373            .with_read_only_roots(vec![PathBuf::from("/ref/shared")]);
374        assert_eq!(
375            sandbox_grant_disclosure(&options).as_deref(),
376            Some(
377                "sandbox active; extra write roots: /out/a, /out/b; \
378                 extra read-only root: /ref/shared; subprocess network allowed\n"
379            ),
380        );
381    }
382
383    #[test]
384    fn process_network_alone_is_disclosed() {
385        let options = RunSandboxOptions::sandboxed(true);
386        assert_eq!(
387            sandbox_grant_disclosure(&options).as_deref(),
388            Some("sandbox active; subprocess network allowed\n"),
389        );
390    }
391
392    #[test]
393    fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
394        // The whole point of the disclosure is precision, so a grant given
395        // through a symlink or with `..` segments must be disclosed as the path
396        // the sandbox actually jails to — the runtime canonicalizes at policy
397        // render time — not the pre-canonical string the caller typed.
398        let temp = tempfile::tempdir().expect("temp dir");
399        let real = temp.path().join("real-state");
400        std::fs::create_dir(&real).expect("create real dir");
401
402        // Symlinked grant resolves to the real target it jails to.
403        #[cfg(unix)]
404        {
405            let link = temp.path().join("link-state");
406            std::os::unix::fs::symlink(&real, &link).expect("symlink");
407            let jailed = rendered_jail_root(&link);
408            assert_eq!(
409                jailed,
410                real.canonicalize().expect("canonical real"),
411                "symlinked grant should jail to the real target"
412            );
413            let line = sandbox_grant_disclosure(
414                &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
415            )
416            .expect("disclosure");
417            assert!(
418                line.contains(&jailed.display().to_string())
419                    && !line.contains(&link.display().to_string()),
420                "symlinked grant must disclose the canonical jail path: {line}"
421            );
422        }
423
424        // A `..`-containing grant collapses to the same canonical jail path.
425        let dotted = real.join("..").join("real-state");
426        let jailed_dots = rendered_jail_root(&dotted);
427        assert_eq!(
428            jailed_dots,
429            real.canonicalize().expect("canonical real"),
430            "`..` grant should collapse to the real dir"
431        );
432        let dots_line =
433            sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
434                .expect("disclosure");
435        assert!(
436            dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
437            "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
438        );
439    }
440}