1use std::path::{Path, PathBuf};
2
3#[derive(Clone, Debug, PartialEq, Eq)]
4pub struct RunSandboxOptions {
5 pub enabled: bool,
7 pub workspace_root: Option<PathBuf>,
11 pub write_roots: Vec<PathBuf>,
15 pub read_only_roots: Vec<PathBuf>,
18 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 pub fn sandboxed(allow_process_network: bool) -> Self {
38 Self::default().with_process_network(allow_process_network)
39 }
40
41 pub fn with_process_network(mut self, enabled: bool) -> Self {
44 self.allow_process_network = enabled;
45 self
46 }
47
48 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 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 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 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 let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
136
137 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
153pub(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
185fn 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
195fn 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
209fn 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 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 root = PathBuf::from("/out/coordination");
362 let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
363 let expected = format!(
369 "sandbox active; extra write root: {}\n",
370 rendered_jail_root(&root).display()
371 );
372 assert_eq!(
373 sandbox_grant_disclosure(&options).as_deref(),
374 Some(expected.as_str()),
375 );
376 }
377
378 #[test]
379 fn multiple_grants_join_on_one_line() {
380 let write_a = PathBuf::from("/out/a");
381 let write_b = PathBuf::from("/out/b");
382 let read_shared = PathBuf::from("/ref/shared");
383 let options = RunSandboxOptions::sandboxed(true)
384 .with_write_roots(vec![write_a.clone(), write_b.clone()])
385 .with_read_only_roots(vec![read_shared.clone()]);
386 let expected = format!(
391 "sandbox active; extra write roots: {}, {}; \
392 extra read-only root: {}; subprocess network allowed\n",
393 rendered_jail_root(&write_a).display(),
394 rendered_jail_root(&write_b).display(),
395 rendered_jail_root(&read_shared).display(),
396 );
397 assert_eq!(
398 sandbox_grant_disclosure(&options).as_deref(),
399 Some(expected.as_str()),
400 );
401 }
402
403 #[test]
404 fn process_network_alone_is_disclosed() {
405 let options = RunSandboxOptions::sandboxed(true);
406 assert_eq!(
407 sandbox_grant_disclosure(&options).as_deref(),
408 Some("sandbox active; subprocess network allowed\n"),
409 );
410 }
411
412 #[test]
413 fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
414 let temp = tempfile::tempdir().expect("temp dir");
419 let real = temp.path().join("real-state");
420 std::fs::create_dir(&real).expect("create real dir");
421
422 #[cfg(unix)]
424 {
425 let link = temp.path().join("link-state");
426 std::os::unix::fs::symlink(&real, &link).expect("symlink");
427 let jailed = rendered_jail_root(&link);
428 assert_eq!(
429 jailed,
430 real.canonicalize().expect("canonical real"),
431 "symlinked grant should jail to the real target"
432 );
433 let line = sandbox_grant_disclosure(
434 &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
435 )
436 .expect("disclosure");
437 assert!(
438 line.contains(&jailed.display().to_string())
439 && !line.contains(&link.display().to_string()),
440 "symlinked grant must disclose the canonical jail path: {line}"
441 );
442 }
443
444 let dotted = real.join("..").join("real-state");
446 let jailed_dots = rendered_jail_root(&dotted);
447 assert_eq!(
448 jailed_dots,
449 real.canonicalize().expect("canonical real"),
450 "`..` grant should collapse to the real dir"
451 );
452 let dots_line =
453 sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
454 .expect("disclosure");
455 assert!(
456 dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
457 "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
458 );
459 }
460}