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 process_read_roots: Vec<PathBuf>,
20 pub process_write_roots: Vec<PathBuf>,
22 pub allow_process_network: bool,
25}
26
27impl Default for RunSandboxOptions {
28 fn default() -> Self {
29 Self {
30 enabled: true,
31 workspace_root: None,
32 write_roots: Vec::new(),
33 read_only_roots: Vec::new(),
34 process_read_roots: Vec::new(),
35 process_write_roots: Vec::new(),
36 allow_process_network: false,
37 }
38 }
39}
40
41impl RunSandboxOptions {
42 pub fn sandboxed(allow_process_network: bool) -> Self {
44 Self::default().with_process_network(allow_process_network)
45 }
46
47 pub fn with_process_network(mut self, enabled: bool) -> Self {
50 self.allow_process_network = enabled;
51 self
52 }
53
54 pub fn disabled() -> Self {
56 Self {
57 enabled: false,
58 workspace_root: None,
59 write_roots: Vec::new(),
60 read_only_roots: Vec::new(),
61 process_read_roots: Vec::new(),
62 process_write_roots: Vec::new(),
63 allow_process_network: false,
64 }
65 }
66
67 pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
69 self.workspace_root = Some(workspace_root.into());
70 self
71 }
72
73 pub fn with_write_roots<I>(mut self, write_roots: I) -> Self
75 where
76 I: IntoIterator<Item = PathBuf>,
77 {
78 self.write_roots = write_roots.into_iter().collect();
79 self
80 }
81
82 pub fn with_read_only_roots<I>(mut self, read_only_roots: I) -> Self
84 where
85 I: IntoIterator<Item = PathBuf>,
86 {
87 self.read_only_roots = read_only_roots.into_iter().collect();
88 self
89 }
90
91 pub fn with_process_read_roots<I>(mut self, roots: I) -> Self
93 where
94 I: IntoIterator<Item = PathBuf>,
95 {
96 self.process_read_roots = roots.into_iter().collect();
97 self
98 }
99
100 pub fn with_process_write_roots<I>(mut self, roots: I) -> Self
102 where
103 I: IntoIterator<Item = PathBuf>,
104 {
105 self.process_write_roots = roots.into_iter().collect();
106 self
107 }
108}
109
110pub(crate) fn run_sandbox_options_from_args(args: &crate::cli::RunArgs) -> RunSandboxOptions {
111 if args.no_sandbox {
112 return RunSandboxOptions::disabled();
113 }
114 RunSandboxOptions::sandboxed(args.allow_process_network)
115 .with_write_roots(args.write_root.iter().cloned())
116 .with_read_only_roots(args.read_only_root.iter().cloned())
117 .with_process_read_roots(args.sandbox_read_root.iter().cloned())
118 .with_process_write_roots(args.sandbox_write_root.iter().cloned())
119}
120
121struct ExecutionPolicyGuard;
122
123impl Drop for ExecutionPolicyGuard {
124 fn drop(&mut self) {
125 harn_vm::orchestration::pop_execution_policy();
126 }
127}
128
129pub(super) struct RunSandboxScope {
130 _execution_policy: Option<ExecutionPolicyGuard>,
131 _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
132 _ssrf_guard: Option<harn_vm::egress::SsrfGuardScope>,
133}
134
135impl RunSandboxScope {
136 fn disabled() -> Self {
137 Self {
138 _execution_policy: None,
139 _egress_policy: None,
140 _ssrf_guard: None,
141 }
142 }
143}
144
145pub(super) fn install_run_sandbox_scope(
146 options: &RunSandboxOptions,
147 workspace_root: &Path,
148 stderr: &mut String,
149) -> RunSandboxScope {
150 if !options.enabled {
151 stderr.push_str(
152 "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
153 );
154 return RunSandboxScope::disabled();
155 }
156
157 let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
158 harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
159 workspace_root,
160 &options.write_roots,
161 &options.read_only_roots,
162 &options.process_read_roots,
163 &options.process_write_roots,
164 options.allow_process_network,
165 ));
166 Some(ExecutionPolicyGuard)
167 } else {
168 None
169 };
170 let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
171 let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
175
176 if let Some(disclosure) = sandbox_grant_disclosure(options) {
182 stderr.push_str(&disclosure);
183 }
184
185 RunSandboxScope {
186 _execution_policy: execution_policy,
187 _egress_policy: egress_policy,
188 _ssrf_guard: ssrf_guard,
189 }
190}
191
192pub(super) fn sandbox_grant_disclosure(options: &RunSandboxOptions) -> Option<String> {
197 if !options.enabled {
198 return None;
199 }
200 let mut deltas: Vec<String> = Vec::new();
201 if !options.write_roots.is_empty() {
202 deltas.push(format!(
203 "extra write root{}: {}",
204 plural_suffix(options.write_roots.len()),
205 display_grant_roots(&options.write_roots),
206 ));
207 }
208 if !options.read_only_roots.is_empty() {
209 deltas.push(format!(
210 "extra read-only root{}: {}",
211 plural_suffix(options.read_only_roots.len()),
212 display_grant_roots(&options.read_only_roots),
213 ));
214 }
215 if !options.process_write_roots.is_empty() {
216 deltas.push(format!(
217 "extra subprocess write root{}: {}",
218 plural_suffix(options.process_write_roots.len()),
219 display_grant_roots(&options.process_write_roots),
220 ));
221 }
222 if !options.process_read_roots.is_empty() {
223 deltas.push(format!(
224 "extra subprocess read root{}: {}",
225 plural_suffix(options.process_read_roots.len()),
226 display_grant_roots(&options.process_read_roots),
227 ));
228 }
229 if options.allow_process_network {
230 deltas.push("subprocess network allowed".to_string());
231 }
232 if deltas.is_empty() {
233 return None;
234 }
235 Some(format!("sandbox active; {}\n", deltas.join("; ")))
236}
237
238fn display_grant_roots(roots: &[PathBuf]) -> String {
241 roots
242 .iter()
243 .map(|path| rendered_jail_root(path).display().to_string())
244 .collect::<Vec<_>>()
245 .join(", ")
246}
247
248fn rendered_jail_root(path: &Path) -> PathBuf {
257 harn_vm::process_sandbox::render_policy_root(
258 &normalize_run_workspace_root(path).display().to_string(),
259 )
260}
261
262fn render_policy_roots(roots: &[String]) -> Vec<String> {
265 roots
266 .iter()
267 .map(|root| {
268 harn_vm::process_sandbox::render_policy_root(root)
269 .display()
270 .to_string()
271 })
272 .collect()
273}
274
275fn plural_suffix(count: usize) -> &'static str {
276 if count == 1 {
277 ""
278 } else {
279 "s"
280 }
281}
282
283pub(super) fn default_run_capability_policy(
284 workspace_root: &Path,
285 write_roots: &[PathBuf],
286 read_only_roots: &[PathBuf],
287 process_read_roots: &[PathBuf],
288 process_write_roots: &[PathBuf],
289 allow_process_network: bool,
290) -> harn_vm::orchestration::CapabilityPolicy {
291 let mut workspace_roots = Vec::with_capacity(1 + write_roots.len());
292 workspace_roots.push(
293 normalize_run_workspace_root(workspace_root)
294 .display()
295 .to_string(),
296 );
297 workspace_roots.extend(
298 write_roots
299 .iter()
300 .map(|path| normalize_run_workspace_root(path.as_path()))
301 .map(|path| path.display().to_string()),
302 );
303
304 harn_vm::orchestration::CapabilityPolicy {
305 workspace_roots,
306 read_only_roots: read_only_roots
307 .iter()
308 .map(|path| normalize_run_workspace_root(path.as_path()))
309 .map(|path| path.display().to_string())
310 .collect(),
311 process_sandbox: harn_vm::orchestration::ProcessSandboxPolicy {
312 presets: None,
313 read_roots: process_read_roots
314 .iter()
315 .map(|path| normalize_run_workspace_root(path.as_path()))
316 .map(|path| path.display().to_string())
317 .collect(),
318 write_roots: process_write_roots
319 .iter()
320 .map(|path| normalize_run_workspace_root(path.as_path()))
321 .map(|path| path.display().to_string())
322 .collect(),
323 },
324 side_effect_level: Some(
325 if allow_process_network {
326 harn_vm::tool_annotations::SideEffectLevel::Network
327 } else {
328 harn_vm::tool_annotations::SideEffectLevel::ProcessExec
329 }
330 .as_str()
331 .to_string(),
332 ),
333 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
334 ..harn_vm::orchestration::CapabilityPolicy::default()
335 }
336}
337
338fn normalize_run_workspace_root(path: &Path) -> PathBuf {
339 if path.is_absolute() {
340 return path.to_path_buf();
341 }
342 std::env::current_dir()
343 .map(|cwd| cwd.join(path))
344 .unwrap_or_else(|_| path.to_path_buf())
345}
346
347pub(super) fn default_run_workspace_root(
348 project_root: Option<&Path>,
349 source_parent: &Path,
350) -> PathBuf {
351 project_root
352 .map(Path::to_path_buf)
353 .or_else(|| std::env::current_dir().ok())
354 .unwrap_or_else(|| source_parent.to_path_buf())
355}
356
357pub(super) fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
358 let active_policy = harn_vm::orchestration::current_execution_policy();
359 let active = active_policy.is_some();
360 let workspace_roots = active_policy
361 .as_ref()
362 .map(|policy| render_policy_roots(&policy.workspace_roots))
363 .unwrap_or_default();
364 let read_only_roots = active_policy
365 .as_ref()
366 .map(|policy| render_policy_roots(&policy.read_only_roots))
367 .unwrap_or_default();
368 let profile = active_policy
369 .as_ref()
370 .map(|policy| policy.sandbox_profile.as_str())
371 .unwrap_or("unrestricted");
372 let side_effect_level = active_policy
373 .as_ref()
374 .and_then(|policy| policy.side_effect_level.as_deref())
375 .unwrap_or(harn_vm::tool_annotations::SideEffectLevel::MAX.as_str());
376 let process_network_enabled =
377 harn_vm::tool_annotations::SideEffectLevel::rank_str(side_effect_level)
378 >= harn_vm::tool_annotations::SideEffectLevel::Network.rank();
379 let egress = if sandbox.enabled {
380 "explicit_policy_required"
381 } else if active {
382 "host_policy"
383 } else {
384 "unrestricted"
385 };
386 let write_roots = sandbox
387 .write_roots
388 .iter()
389 .map(|path| rendered_jail_root(path).display().to_string())
390 .collect::<Vec<_>>();
391 let process_read_roots = active_policy
392 .as_ref()
393 .map(|policy| render_policy_roots(&policy.process_sandbox.read_roots))
394 .unwrap_or_default();
395 let process_write_roots = active_policy
396 .as_ref()
397 .map(|policy| render_policy_roots(&policy.process_sandbox.write_roots))
398 .unwrap_or_default();
399
400 serde_json::json!({
401 "run_default_enabled": sandbox.enabled,
402 "active": active,
403 "workspace_roots": workspace_roots,
404 "write_roots": write_roots,
405 "read_only_roots": read_only_roots,
406 "process_read_roots": process_read_roots,
407 "process_write_roots": process_write_roots,
408 "profile": profile,
409 "process_network_requested": sandbox.allow_process_network,
410 "process_network_enabled": process_network_enabled,
411 "side_effect_level": side_effect_level,
412 "egress": egress,
413 })
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn default_run_discloses_nothing() {
422 assert_eq!(
423 sandbox_grant_disclosure(&RunSandboxOptions::default()),
424 None
425 );
426 }
427
428 #[test]
429 fn disabled_sandbox_discloses_nothing() {
430 let mut options = RunSandboxOptions::disabled();
433 options.write_roots = vec![PathBuf::from("/out/coordination")];
434 assert_eq!(sandbox_grant_disclosure(&options), None);
435 }
436
437 #[test]
438 fn single_write_root_names_the_delta() {
439 let root = PathBuf::from("/out/coordination");
440 let options = RunSandboxOptions::default().with_write_roots(vec![root.clone()]);
441 let expected = format!(
447 "sandbox active; extra write root: {}\n",
448 rendered_jail_root(&root).display()
449 );
450 assert_eq!(
451 sandbox_grant_disclosure(&options).as_deref(),
452 Some(expected.as_str()),
453 );
454 }
455
456 #[test]
457 fn multiple_grants_join_on_one_line() {
458 let write_a = PathBuf::from("/out/a");
459 let write_b = PathBuf::from("/out/b");
460 let read_shared = PathBuf::from("/ref/shared");
461 let options = RunSandboxOptions::sandboxed(true)
462 .with_write_roots(vec![write_a.clone(), write_b.clone()])
463 .with_read_only_roots(vec![read_shared.clone()]);
464 let expected = format!(
469 "sandbox active; extra write roots: {}, {}; \
470 extra read-only root: {}; subprocess network allowed\n",
471 rendered_jail_root(&write_a).display(),
472 rendered_jail_root(&write_b).display(),
473 rendered_jail_root(&read_shared).display(),
474 );
475 assert_eq!(
476 sandbox_grant_disclosure(&options).as_deref(),
477 Some(expected.as_str()),
478 );
479 }
480
481 #[test]
482 fn process_network_alone_is_disclosed() {
483 let options = RunSandboxOptions::sandboxed(true);
484 assert_eq!(
485 sandbox_grant_disclosure(&options).as_deref(),
486 Some("sandbox active; subprocess network allowed\n"),
487 );
488 }
489
490 #[test]
491 fn subprocess_roots_stay_process_only_and_are_disclosed() {
492 let workspace = tempfile::tempdir().unwrap();
493 let process_read = workspace.path().join("sdk");
494 let process_write = workspace.path().join("cache");
495 let options = RunSandboxOptions::default()
496 .with_process_read_roots(vec![process_read.clone()])
497 .with_process_write_roots(vec![process_write.clone()]);
498 let policy = default_run_capability_policy(
499 workspace.path(),
500 &[],
501 &[],
502 &options.process_read_roots,
503 &options.process_write_roots,
504 false,
505 );
506
507 assert_eq!(
508 policy.process_sandbox.read_roots,
509 vec![process_read.display().to_string()]
510 );
511 assert_eq!(
512 policy.process_sandbox.write_roots,
513 vec![process_write.display().to_string()]
514 );
515 assert_eq!(
516 policy.workspace_roots,
517 vec![workspace.path().display().to_string()]
518 );
519 assert!(policy.read_only_roots.is_empty());
520
521 let disclosure = sandbox_grant_disclosure(&options).unwrap();
522 assert!(disclosure.contains("extra subprocess write root"));
523 assert!(disclosure.contains("extra subprocess read root"));
524 }
525
526 #[test]
527 fn disclosure_names_the_canonical_jail_path_not_the_raw_grant() {
528 let temp = tempfile::tempdir().expect("temp dir");
533 let real = temp.path().join("real-state");
534 std::fs::create_dir(&real).expect("create real dir");
535
536 #[cfg(unix)]
538 {
539 let link = temp.path().join("link-state");
540 std::os::unix::fs::symlink(&real, &link).expect("symlink");
541 let jailed = rendered_jail_root(&link);
542 assert_eq!(
543 jailed,
544 real.canonicalize().expect("canonical real"),
545 "symlinked grant should jail to the real target"
546 );
547 let line = sandbox_grant_disclosure(
548 &RunSandboxOptions::default().with_write_roots(vec![link.clone()]),
549 )
550 .expect("disclosure");
551 assert!(
552 line.contains(&jailed.display().to_string())
553 && !line.contains(&link.display().to_string()),
554 "symlinked grant must disclose the canonical jail path: {line}"
555 );
556 }
557
558 let dotted = real.join("..").join("real-state");
560 let jailed_dots = rendered_jail_root(&dotted);
561 assert_eq!(
562 jailed_dots,
563 real.canonicalize().expect("canonical real"),
564 "`..` grant should collapse to the real dir"
565 );
566 let dots_line =
567 sandbox_grant_disclosure(&RunSandboxOptions::default().with_write_roots(vec![dotted]))
568 .expect("disclosure");
569 assert!(
570 dots_line.contains(&jailed_dots.display().to_string()) && !dots_line.contains(".."),
571 "`..` grant must disclose the collapsed jail path with no `..`: {dots_line}"
572 );
573 }
574}