1use std::path::{Path, PathBuf};
2
3use super::EnvironmentPolicyConfig;
4
5#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct RunSandboxOptions {
7 pub enabled: bool,
9 pub workspace_root: Option<PathBuf>,
13 pub write_roots: Vec<PathBuf>,
17 pub read_only_roots: Vec<PathBuf>,
20 pub process_read_roots: Vec<PathBuf>,
22 pub process_write_roots: Vec<PathBuf>,
24 pub allow_process_network: bool,
27 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 pub fn sandboxed(allow_process_network: bool) -> Self {
53 Self::default().with_process_network(allow_process_network)
54 }
55
56 pub fn with_process_network(mut self, enabled: bool) -> Self {
59 self.allow_process_network = enabled;
60 self
61 }
62
63 pub fn disabled() -> Self {
65 Self {
66 enabled: false,
67 ..Self::default()
68 }
69 }
70
71 pub(crate) fn with_environment_policy(mut self, environment: EnvironmentPolicyConfig) -> Self {
73 self.environment = environment;
74 self
75 }
76
77 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 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 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 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 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
120pub(crate) fn sandbox_options_from_args(args: &crate::cli::SandboxArgs) -> RunSandboxOptions {
127 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 let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
198
199 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
215pub(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
261fn 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
271fn 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
285fn 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 let mut process_read_roots = process_read_roots
328 .iter()
329 .map(|path| normalize_run_workspace_root(path.as_path()))
330 .map(|path| path.display().to_string())
331 .collect::<Vec<_>>();
332 if let Ok(runtime_executable) = std::env::current_exe() {
343 let runtime_executable = normalize_run_workspace_root(&runtime_executable)
344 .display()
345 .to_string();
346 if !process_read_roots.contains(&runtime_executable) {
347 process_read_roots.push(runtime_executable);
348 }
349 }
350
351 harn_vm::orchestration::CapabilityPolicy {
352 workspace_roots,
353 read_only_roots: read_only_roots
354 .iter()
355 .map(|path| normalize_run_workspace_root(path.as_path()))
356 .map(|path| path.display().to_string())
357 .collect(),
358 process_sandbox: harn_vm::orchestration::ProcessSandboxPolicy {
359 presets: None,
360 read_roots: process_read_roots,
361 write_roots: process_write_roots
362 .iter()
363 .map(|path| normalize_run_workspace_root(path.as_path()))
364 .map(|path| path.display().to_string())
365 .collect(),
366 },
367 side_effect_level: Some(
368 if allow_process_network {
369 harn_vm::tool_annotations::SideEffectLevel::Network
370 } else {
371 harn_vm::tool_annotations::SideEffectLevel::ProcessExec
372 }
373 .as_str()
374 .to_string(),
375 ),
376 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
377 ..harn_vm::orchestration::CapabilityPolicy::default()
378 }
379}
380
381fn normalize_run_workspace_root(path: &Path) -> PathBuf {
382 if path.is_absolute() {
383 return path.to_path_buf();
384 }
385 std::env::current_dir()
386 .map(|cwd| cwd.join(path))
387 .unwrap_or_else(|_| path.to_path_buf())
388}
389
390pub(super) fn default_run_workspace_root(
391 project_root: Option<&Path>,
392 source_parent: &Path,
393) -> PathBuf {
394 project_root
395 .map(Path::to_path_buf)
396 .or_else(|| std::env::current_dir().ok())
397 .unwrap_or_else(|| source_parent.to_path_buf())
398}
399
400pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
401 let active_policy = harn_vm::orchestration::current_execution_policy();
402 let active = active_policy.is_some();
403 let workspace_roots = active_policy
404 .as_ref()
405 .map(|policy| render_policy_roots(&policy.workspace_roots))
406 .unwrap_or_default();
407 let read_only_roots = active_policy
408 .as_ref()
409 .map(|policy| render_policy_roots(&policy.read_only_roots))
410 .unwrap_or_default();
411 let profile = active_policy
412 .as_ref()
413 .map(|policy| policy.sandbox_profile.as_str())
414 .unwrap_or("unrestricted");
415 let side_effect_level = active_policy
416 .as_ref()
417 .and_then(|policy| policy.side_effect_level.as_deref())
418 .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
419 let process_network_enabled =
420 harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
421 >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
422 let egress = if sandbox.enabled {
423 "explicit_policy_required"
424 } else if active {
425 "host_policy"
426 } else {
427 "unrestricted"
428 };
429 let write_roots = sandbox
430 .write_roots
431 .iter()
432 .map(|path| rendered_jail_root(path).display().to_string())
433 .collect::<Vec<_>>();
434 let process_read_roots = active_policy
435 .as_ref()
436 .map(|policy| render_policy_roots(&policy.process_sandbox.read_roots))
437 .unwrap_or_default();
438 let process_write_roots = active_policy
439 .as_ref()
440 .map(|policy| render_policy_roots(&policy.process_sandbox.write_roots))
441 .unwrap_or_default();
442
443 serde_json::json!({
444 "run_default_enabled": sandbox.enabled,
445 "active": active,
446 "workspace_roots": workspace_roots,
447 "write_roots": write_roots,
448 "read_only_roots": read_only_roots,
449 "process_read_roots": process_read_roots,
450 "process_write_roots": process_write_roots,
451 "profile": profile,
452 "process_network_requested": sandbox.allow_process_network,
453 "process_network_enabled": process_network_enabled,
454 "side_effect_level": side_effect_level,
455 "egress": egress,
456 })
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn default_run_discloses_nothing() {
465 assert_eq!(
466 sandbox_grant_disclosure(&RunSandboxOptions::default()),
467 None
468 );
469 }
470
471 #[test]
472 fn disabled_sandbox_discloses_nothing() {
473 let mut options = RunSandboxOptions::disabled();
476 options.write_roots = vec![PathBuf::from("/out/coordination")];
477 assert_eq!(sandbox_grant_disclosure(&options), None);
478 }
479
480 #[test]
481 fn single_write_root_names_the_delta() {
482 let root = PathBuf::from("/out/coordination");
483 let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
484 let expected = format!(
490 "sandbox active; extra write root: {}\n",
491 rendered_jail_root(&root).display()
492 );
493 assert_eq!(
494 sandbox_grant_disclosure(&options).as_deref(),
495 Some(expected.as_str()),
496 );
497 }
498
499 #[test]
500 fn multiple_grants_join_on_one_line() {
501 let write_a = PathBuf::from("/out/a");
502 let write_b = PathBuf::from("/out/b");
503 let read_shared = PathBuf::from("/ref/shared");
504 let options = RunSandboxOptions::sandboxed(true)
505 .with_write_roots(vec![write_a.clone(), write_b.clone()])
506 .with_read_only_roots(vec![read_shared.clone()]);
507 let expected = format!(
512 "sandbox active; extra write roots: {}, {}; \
513 extra read-only root: {}; subprocess network allowed\n",
514 rendered_jail_root(&write_a).display(),
515 rendered_jail_root(&write_b).display(),
516 rendered_jail_root(&read_shared).display(),
517 );
518 assert_eq!(
519 sandbox_grant_disclosure(&options).as_deref(),
520 Some(expected.as_str()),
521 );
522 }
523
524 #[test]
525 fn process_network_alone_is_disclosed() {
526 let options = RunSandboxOptions::sandboxed(true);
527 assert_eq!(
528 sandbox_grant_disclosure(&options).as_deref(),
529 Some("sandbox active; subprocess network allowed\n"),
530 );
531 }
532
533 #[test]
534 fn subprocess_roots_stay_process_only_and_are_disclosed() {
535 let workspace = tempfile::tempdir().unwrap();
536 let process_read = workspace.path().join("sdk");
537 let process_write = workspace.path().join("cache");
538 let options = RunSandboxOptions::default()
539 .with_process_read_roots(vec![process_read.clone()])
540 .with_process_write_roots(vec![process_write.clone()]);
541 let policy = default_run_capability_policy(
542 workspace.path(),
543 &[],
544 &[],
545 &options.process_read_roots,
546 &options.process_write_roots,
547 false,
548 );
549
550 assert_eq!(
551 policy.process_sandbox.read_roots,
552 vec![
553 process_read.display().to_string(),
554 normalize_run_workspace_root(&std::env::current_exe().unwrap())
555 .display()
556 .to_string(),
557 ]
558 );
559 assert_eq!(
560 policy.process_sandbox.write_roots,
561 vec![process_write.display().to_string()]
562 );
563 assert_eq!(
564 policy.workspace_roots,
565 vec![workspace.path().display().to_string()]
566 );
567 assert!(policy.read_only_roots.is_empty());
568
569 let disclosure = sandbox_grant_disclosure(&options).unwrap();
570 assert!(disclosure.contains("extra subprocess write root"));
571 assert!(disclosure.contains("extra subprocess read root"));
572 }
573
574 #[test]
575 fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
576 let temp = tempfile::tempdir().expect("temp dir");
581 let real = temp.path().join("real-state");
582 std::fs::create_dir(&real).expect("create real dir");
583
584 #[cfg(unix)]
586 {
587 let link = temp.path().join("link-state");
588 std::os::unix::fs::symlink(&real, &link).expect("symlink");
589 let jailed = rendered_jail_root(&link);
590 assert_eq!(
591 jailed,
592 real.canonicalize().expect("canonical real"),
593 "symlinked grant should jail to the real target"
594 );
595 let line = sandbox_grant_disclosure(
596 &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
597 )
598 .expect("disclosure");
599 assert!(
600 line.contains(&jailed.display().to_string())
601 && !line.contains(&link.display().to_string()),
602 "symlinked grant must disclose the canonical jail path: {line}"
603 );
604 }
605
606 let dotted = real.join("..").join("real-state");
608 let jailed_dots = rendered_jail_root(&dotted);
609 assert_eq!(
610 jailed_dots,
611 real.canonicalize().expect("canonical real"),
612 "`..` grant should collapse to the real dir"
613 );
614 let dots_line =
615 sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
616 .expect("disclosure");
617 assert!(
618 dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
619 "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
620 );
621 }
622}