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    RunSandboxScope {
138        _execution_policy: execution_policy,
139        _egress_policy: egress_policy,
140        _ssrf_guard: ssrf_guard,
141    }
142}
143
144pub(super) fn default_run_capability_policy(
145    workspace_root: &Path,
146    write_roots: &[PathBuf],
147    read_only_roots: &[PathBuf],
148    allow_process_network: bool,
149) -> harn_vm::orchestration::CapabilityPolicy {
150    let mut workspace_roots = Vec::with_capacity(1 + write_roots.len());
151    workspace_roots.push(
152        normalize_run_workspace_root(workspace_root)
153            .display()
154            .to_string(),
155    );
156    workspace_roots.extend(
157        write_roots
158            .iter()
159            .map(|path| normalize_run_workspace_root(path.as_path()))
160            .map(|path| path.display().to_string()),
161    );
162
163    harn_vm::orchestration::CapabilityPolicy {
164        workspace_roots,
165        read_only_roots: read_only_roots
166            .iter()
167            .map(|path| normalize_run_workspace_root(path.as_path()))
168            .map(|path| path.display().to_string())
169            .collect(),
170        side_effect_level: Some(
171            if allow_process_network {
172                harn_vm::tool_annotations::SideEffectLevel::Network
173            } else {
174                harn_vm::tool_annotations::SideEffectLevel::ProcessExec
175            }
176            .as_str()
177            .to_string(),
178        ),
179        sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
180        ..harn_vm::orchestration::CapabilityPolicy::default()
181    }
182}
183
184fn normalize_run_workspace_root(path: &Path) -> PathBuf {
185    if path.is_absolute() {
186        return path.to_path_buf();
187    }
188    std::env::current_dir()
189        .map(|cwd| cwd.join(path))
190        .unwrap_or_else(|_| path.to_path_buf())
191}
192
193pub(super) fn default_run_workspace_root(
194    project_root: Option<&Path>,
195    source_parent: &Path,
196) -> PathBuf {
197    project_root
198        .map(Path::to_path_buf)
199        .or_else(|| std::env::current_dir().ok())
200        .unwrap_or_else(|| source_parent.to_path_buf())
201}
202
203pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
204    let active_policy = harn_vm::orchestration::current_execution_policy();
205    let active = active_policy.is_some();
206    let workspace_roots = active_policy
207        .as_ref()
208        .map(|policy| policy.workspace_roots.clone())
209        .unwrap_or_default();
210    let read_only_roots = active_policy
211        .as_ref()
212        .map(|policy| policy.read_only_roots.clone())
213        .unwrap_or_default();
214    let profile = active_policy
215        .as_ref()
216        .map(|policy| policy.sandbox_profile.as_str())
217        .unwrap_or("unrestricted");
218    let side_effect_level = active_policy
219        .as_ref()
220        .and_then(|policy| policy.side_effect_level.as_deref())
221        .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
222    let process_network_enabled =
223        harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
224            >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
225    let egress = if sandbox.enabled {
226        "explicit_policy_required"
227    } else if active {
228        "host_policy"
229    } else {
230        "unrestricted"
231    };
232    let write_roots = sandbox
233        .write_roots
234        .iter()
235        .map(|path| normalize_run_workspace_root(path).display().to_string())
236        .collect::<Vec<_>>();
237
238    serde_json::json!({
239        "run_default_enabled": sandbox.enabled,
240        "active": active,
241        "workspace_roots": workspace_roots,
242        "write_roots": write_roots,
243        "read_only_roots": read_only_roots,
244        "profile": profile,
245        "process_network_requested": sandbox.allow_process_network,
246        "process_network_enabled": process_network_enabled,
247        "side_effect_level": side_effect_level,
248        "egress": egress,
249    })
250}