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 harn_vm::orchestration::CapabilityPolicy {
328 workspace_roots,
329 read_only_roots: read_only_roots
330 .iter()
331 .map(|path| normalize_run_workspace_root(path.as_path()))
332 .map(|path| path.display().to_string())
333 .collect(),
334 process_sandbox: harn_vm::orchestration::ProcessSandboxPolicy {
335 presets: None,
336 read_roots: process_read_roots
337 .iter()
338 .map(|path| normalize_run_workspace_root(path.as_path()))
339 .map(|path| path.display().to_string())
340 .collect(),
341 write_roots: process_write_roots
342 .iter()
343 .map(|path| normalize_run_workspace_root(path.as_path()))
344 .map(|path| path.display().to_string())
345 .collect(),
346 },
347 side_effect_level: Some(
348 if allow_process_network {
349 harn_vm::tool_annotations::SideEffectLevel::Network
350 } else {
351 harn_vm::tool_annotations::SideEffectLevel::ProcessExec
352 }
353 .as_str()
354 .to_string(),
355 ),
356 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
357 ..harn_vm::orchestration::CapabilityPolicy::default()
358 }
359}
360
361fn normalize_run_workspace_root(path: &Path) -> PathBuf {
362 if path.is_absolute() {
363 return path.to_path_buf();
364 }
365 std::env::current_dir()
366 .map(|cwd| cwd.join(path))
367 .unwrap_or_else(|_| path.to_path_buf())
368}
369
370pub(super) fn default_run_workspace_root(
371 project_root: Option<&Path>,
372 source_parent: &Path,
373) -> PathBuf {
374 project_root
375 .map(Path::to_path_buf)
376 .or_else(|| std::env::current_dir().ok())
377 .unwrap_or_else(|| source_parent.to_path_buf())
378}
379
380pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
381 let active_policy = harn_vm::orchestration::current_execution_policy();
382 let active = active_policy.is_some();
383 let workspace_roots = active_policy
384 .as_ref()
385 .map(|policy| render_policy_roots(&policy.workspace_roots))
386 .unwrap_or_default();
387 let read_only_roots = active_policy
388 .as_ref()
389 .map(|policy| render_policy_roots(&policy.read_only_roots))
390 .unwrap_or_default();
391 let profile = active_policy
392 .as_ref()
393 .map(|policy| policy.sandbox_profile.as_str())
394 .unwrap_or("unrestricted");
395 let side_effect_level = active_policy
396 .as_ref()
397 .and_then(|policy| policy.side_effect_level.as_deref())
398 .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
399 let process_network_enabled =
400 harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
401 >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
402 let egress = if sandbox.enabled {
403 "explicit_policy_required"
404 } else if active {
405 "host_policy"
406 } else {
407 "unrestricted"
408 };
409 let write_roots = sandbox
410 .write_roots
411 .iter()
412 .map(|path| rendered_jail_root(path).display().to_string())
413 .collect::<Vec<_>>();
414 let process_read_roots = active_policy
415 .as_ref()
416 .map(|policy| render_policy_roots(&policy.process_sandbox.read_roots))
417 .unwrap_or_default();
418 let process_write_roots = active_policy
419 .as_ref()
420 .map(|policy| render_policy_roots(&policy.process_sandbox.write_roots))
421 .unwrap_or_default();
422
423 serde_json::json!({
424 "run_default_enabled": sandbox.enabled,
425 "active": active,
426 "workspace_roots": workspace_roots,
427 "write_roots": write_roots,
428 "read_only_roots": read_only_roots,
429 "process_read_roots": process_read_roots,
430 "process_write_roots": process_write_roots,
431 "profile": profile,
432 "process_network_requested": sandbox.allow_process_network,
433 "process_network_enabled": process_network_enabled,
434 "side_effect_level": side_effect_level,
435 "egress": egress,
436 })
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn default_run_discloses_nothing() {
445 assert_eq!(
446 sandbox_grant_disclosure(&RunSandboxOptions::default()),
447 None
448 );
449 }
450
451 #[test]
452 fn disabled_sandbox_discloses_nothing() {
453 let mut options = RunSandboxOptions::disabled();
456 options.write_roots = vec![PathBuf::from("/out/coordination")];
457 assert_eq!(sandbox_grant_disclosure(&options), None);
458 }
459
460 #[test]
461 fn single_write_root_names_the_delta() {
462 let root = PathBuf::from("/out/coordination");
463 let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
464 let expected = format!(
470 "sandbox active; extra write root: {}\n",
471 rendered_jail_root(&root).display()
472 );
473 assert_eq!(
474 sandbox_grant_disclosure(&options).as_deref(),
475 Some(expected.as_str()),
476 );
477 }
478
479 #[test]
480 fn multiple_grants_join_on_one_line() {
481 let write_a = PathBuf::from("/out/a");
482 let write_b = PathBuf::from("/out/b");
483 let read_shared = PathBuf::from("/ref/shared");
484 let options = RunSandboxOptions::sandboxed(true)
485 .with_write_roots(vec![write_a.clone(), write_b.clone()])
486 .with_read_only_roots(vec![read_shared.clone()]);
487 let expected = format!(
492 "sandbox active; extra write roots: {}, {}; \
493 extra read-only root: {}; subprocess network allowed\n",
494 rendered_jail_root(&write_a).display(),
495 rendered_jail_root(&write_b).display(),
496 rendered_jail_root(&read_shared).display(),
497 );
498 assert_eq!(
499 sandbox_grant_disclosure(&options).as_deref(),
500 Some(expected.as_str()),
501 );
502 }
503
504 #[test]
505 fn process_network_alone_is_disclosed() {
506 let options = RunSandboxOptions::sandboxed(true);
507 assert_eq!(
508 sandbox_grant_disclosure(&options).as_deref(),
509 Some("sandbox active; subprocess network allowed\n"),
510 );
511 }
512
513 #[test]
514 fn subprocess_roots_stay_process_only_and_are_disclosed() {
515 let workspace = tempfile::tempdir().unwrap();
516 let process_read = workspace.path().join("sdk");
517 let process_write = workspace.path().join("cache");
518 let options = RunSandboxOptions::default()
519 .with_process_read_roots(vec![process_read.clone()])
520 .with_process_write_roots(vec![process_write.clone()]);
521 let policy = default_run_capability_policy(
522 workspace.path(),
523 &[],
524 &[],
525 &options.process_read_roots,
526 &options.process_write_roots,
527 false,
528 );
529
530 assert_eq!(
531 policy.process_sandbox.read_roots,
532 vec![process_read.display().to_string()]
533 );
534 assert_eq!(
535 policy.process_sandbox.write_roots,
536 vec![process_write.display().to_string()]
537 );
538 assert_eq!(
539 policy.workspace_roots,
540 vec![workspace.path().display().to_string()]
541 );
542 assert!(policy.read_only_roots.is_empty());
543
544 let disclosure = sandbox_grant_disclosure(&options).unwrap();
545 assert!(disclosure.contains("extra subprocess write root"));
546 assert!(disclosure.contains("extra subprocess read root"));
547 }
548
549 #[test]
550 fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
551 let temp = tempfile::tempdir().expect("temp dir");
556 let real = temp.path().join("real-state");
557 std::fs::create_dir(&real).expect("create real dir");
558
559 #[cfg(unix)]
561 {
562 let link = temp.path().join("link-state");
563 std::os::unix::fs::symlink(&real, &link).expect("symlink");
564 let jailed = rendered_jail_root(&link);
565 assert_eq!(
566 jailed,
567 real.canonicalize().expect("canonical real"),
568 "symlinked grant should jail to the real target"
569 );
570 let line = sandbox_grant_disclosure(
571 &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
572 )
573 .expect("disclosure");
574 assert!(
575 line.contains(&jailed.display().to_string())
576 && !line.contains(&link.display().to_string()),
577 "symlinked grant must disclose the canonical jail path: {line}"
578 );
579 }
580
581 let dotted = real.join("..").join("real-state");
583 let jailed_dots = rendered_jail_root(&dotted);
584 assert_eq!(
585 jailed_dots,
586 real.canonicalize().expect("canonical real"),
587 "`..` grant should collapse to the real dir"
588 );
589 let dots_line =
590 sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
591 .expect("disclosure");
592 assert!(
593 dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
594 "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
595 );
596 }
597}