1use std::cell::RefCell;
38use std::collections::BTreeSet;
39use std::path::{Component, Path, PathBuf};
40use std::process::{Command, Output, Stdio};
41use std::rc::Rc;
42
43use crate::orchestration::{CapabilityPolicy, SandboxProfile};
44use crate::value::{ErrorCategory, VmError, VmValue};
45use crate::vm::Vm;
46
47#[cfg(target_os = "linux")]
48mod linux;
49#[cfg(target_os = "macos")]
50mod macos;
51#[cfg(target_os = "openbsd")]
52mod openbsd;
53#[cfg(target_os = "windows")]
54mod windows;
55
56const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
57
58thread_local! {
59 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
60}
61
62#[derive(Clone, Copy)]
63pub(crate) enum FsAccess {
64 Read,
65 Write,
66 Delete,
67}
68
69#[derive(Clone, Debug, Default)]
70pub struct ProcessCommandConfig {
71 pub cwd: Option<PathBuf>,
72 pub env: Vec<(String, String)>,
73 pub stdin_null: bool,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub(crate) enum SandboxFallback {
78 Off,
79 Warn,
80 Enforce,
81}
82
83pub(crate) trait SandboxBackend {
94 fn name() -> &'static str;
96
97 fn available() -> bool;
101
102 fn prepare_std_command(
108 program: &str,
109 args: &[String],
110 command: &mut Command,
111 policy: &CapabilityPolicy,
112 profile: SandboxProfile,
113 ) -> Result<PrepareOutcome, VmError>;
114
115 fn prepare_tokio_command(
117 program: &str,
118 args: &[String],
119 command: &mut tokio::process::Command,
120 policy: &CapabilityPolicy,
121 profile: SandboxProfile,
122 ) -> Result<PrepareOutcome, VmError>;
123
124 fn run_to_output(
129 program: &str,
130 args: &[String],
131 config: &ProcessCommandConfig,
132 policy: &CapabilityPolicy,
133 profile: SandboxProfile,
134 ) -> Result<Output, VmError> {
135 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
136 apply_process_config(&mut command, config);
137 command
138 .output()
139 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
140 }
141}
142
143pub(crate) enum PrepareOutcome {
147 Direct,
149 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
155 WrappedExec { wrapper: String, args: Vec<String> },
156}
157
158#[cfg(target_os = "linux")]
159type ActiveBackend = linux::Backend;
160#[cfg(target_os = "macos")]
161type ActiveBackend = macos::Backend;
162#[cfg(target_os = "openbsd")]
163type ActiveBackend = openbsd::Backend;
164#[cfg(target_os = "windows")]
165type ActiveBackend = windows::Backend;
166#[cfg(not(any(
167 target_os = "linux",
168 target_os = "macos",
169 target_os = "openbsd",
170 target_os = "windows"
171)))]
172type ActiveBackend = NoopBackend;
173
174#[cfg(not(any(
175 target_os = "linux",
176 target_os = "macos",
177 target_os = "openbsd",
178 target_os = "windows"
179)))]
180pub(crate) struct NoopBackend;
181
182#[cfg(not(any(
183 target_os = "linux",
184 target_os = "macos",
185 target_os = "openbsd",
186 target_os = "windows"
187)))]
188impl SandboxBackend for NoopBackend {
189 fn name() -> &'static str {
190 "noop"
191 }
192 fn available() -> bool {
193 false
194 }
195 fn prepare_std_command(
196 _program: &str,
197 _args: &[String],
198 _command: &mut Command,
199 _policy: &CapabilityPolicy,
200 _profile: SandboxProfile,
201 ) -> Result<PrepareOutcome, VmError> {
202 Ok(PrepareOutcome::Direct)
203 }
204 fn prepare_tokio_command(
205 _program: &str,
206 _args: &[String],
207 _command: &mut tokio::process::Command,
208 _policy: &CapabilityPolicy,
209 _profile: SandboxProfile,
210 ) -> Result<PrepareOutcome, VmError> {
211 Ok(PrepareOutcome::Direct)
212 }
213}
214
215pub(crate) fn reset_sandbox_state() {
216 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
217}
218
219pub fn active_backend_name() -> &'static str {
223 ActiveBackend::name()
224}
225
226pub fn active_backend_available() -> bool {
231 ActiveBackend::available()
232}
233
234pub fn register_sandbox_builtins(vm: &mut Vm) {
238 vm.register_builtin("sandbox_active_backend", |_args, _out| {
239 Ok(VmValue::String(Rc::from(active_backend_name())))
240 });
241 vm.register_builtin("sandbox_backend_available", |_args, _out| {
242 Ok(VmValue::Bool(active_backend_available()))
243 });
244 vm.register_builtin("sandbox_active_profile", |_args, _out| {
245 let profile = crate::orchestration::current_execution_policy()
246 .map(|policy| policy.sandbox_profile)
247 .unwrap_or(SandboxProfile::Unrestricted);
248 Ok(VmValue::String(Rc::from(profile.as_str())))
249 });
250}
251
252pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
253 let Some(policy) = crate::orchestration::current_execution_policy() else {
254 return Ok(());
255 };
256 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
257 return Ok(());
258 }
259 let candidate = normalize_for_policy(path);
260 let roots = normalized_workspace_roots(&policy);
261 if roots.iter().any(|root| path_is_within(&candidate, root)) {
262 return Ok(());
263 }
264 Err(sandbox_rejection(format!(
265 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
266 access.verb(),
267 candidate.display(),
268 roots
269 .iter()
270 .map(|root| root.display().to_string())
271 .collect::<Vec<_>>()
272 .join(", ")
273 )))
274}
275
276pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
277 let Some(policy) = crate::orchestration::current_execution_policy() else {
278 return Ok(());
279 };
280 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
281 return Ok(());
282 }
283 let candidate = normalize_for_policy(path);
284 let roots = normalized_workspace_roots(&policy);
285 if roots.iter().any(|root| path_is_within(&candidate, root)) {
286 return Ok(());
287 }
288 Err(sandbox_rejection(format!(
289 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
290 candidate.display(),
291 roots
292 .iter()
293 .map(|root| root.display().to_string())
294 .collect::<Vec<_>>()
295 .join(", ")
296 )))
297}
298
299pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
300 let (policy, profile) = match active_sandbox_policy() {
301 Some(value) => value,
302 None => {
303 let mut command = Command::new(program);
304 command.args(args);
305 return Ok(command);
306 }
307 };
308 build_std_command::<ActiveBackend>(program, args, &policy, profile)
309}
310
311pub fn tokio_command_for(
312 program: &str,
313 args: &[String],
314) -> Result<tokio::process::Command, VmError> {
315 let (policy, profile) = match active_sandbox_policy() {
316 Some(value) => value,
317 None => {
318 let mut command = tokio::process::Command::new(program);
319 command.args(args);
320 return Ok(command);
321 }
322 };
323 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
324}
325
326pub fn command_output(
327 program: &str,
328 args: &[String],
329 config: &ProcessCommandConfig,
330) -> Result<Output, VmError> {
331 if let Some(intercepted) =
336 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
337 {
338 return intercepted.map_err(|message| {
339 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(message)))
340 });
341 }
342
343 let recording =
344 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
345
346 let output = match active_sandbox_policy() {
347 Some((policy, profile)) => {
348 ActiveBackend::run_to_output(program, args, config, &policy, profile)?
349 }
350 None => {
351 let mut command = Command::new(program);
352 command.args(args);
353 apply_process_config(&mut command, config);
354 command.output().map_err(|error| {
355 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
356 })?
357 }
358 };
359 if let Some(error) = process_violation_error(&output) {
360 return Err(error);
361 }
362 if let Some(span) = recording {
363 span.finish(&output);
364 }
365 Ok(output)
366}
367
368fn build_std_command<B: SandboxBackend + ?Sized>(
369 program: &str,
370 args: &[String],
371 policy: &CapabilityPolicy,
372 profile: SandboxProfile,
373) -> Result<Command, VmError> {
374 let mut command = Command::new(program);
375 command.args(args);
376 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
377 PrepareOutcome::Direct => Ok(command),
378 PrepareOutcome::WrappedExec { wrapper, args } => {
379 let mut wrapped = Command::new(wrapper);
380 wrapped.args(args);
381 Ok(wrapped)
382 }
383 }
384}
385
386fn build_tokio_command<B: SandboxBackend + ?Sized>(
387 program: &str,
388 args: &[String],
389 policy: &CapabilityPolicy,
390 profile: SandboxProfile,
391) -> Result<tokio::process::Command, VmError> {
392 let mut command = tokio::process::Command::new(program);
393 command.args(args);
394 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
395 PrepareOutcome::Direct => Ok(command),
396 PrepareOutcome::WrappedExec { wrapper, args } => {
397 let mut wrapped = tokio::process::Command::new(wrapper);
398 wrapped.args(args);
399 Ok(wrapped)
400 }
401 }
402}
403
404pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
405 let policy = crate::orchestration::current_execution_policy()?;
406 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
407 return None;
408 }
409 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
410 || !ActiveBackend::available()
411 {
412 return None;
413 }
414 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
415 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
416 if !output.status.success()
417 && (stderr.contains("operation not permitted")
418 || stderr.contains("permission denied")
419 || stderr.contains("access is denied")
420 || stdout.contains("operation not permitted"))
421 {
422 return Some(sandbox_rejection(format!(
423 "sandbox violation: process was denied by the OS sandbox (status {})",
424 output.status.code().unwrap_or(-1)
425 )));
426 }
427 if sandbox_signal_status(output) {
428 return Some(sandbox_rejection(format!(
429 "sandbox violation: process was terminated by the OS sandbox (status {})",
430 output.status
431 )));
432 }
433 None
434}
435
436pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
437 let policy = crate::orchestration::current_execution_policy()?;
438 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
439 return None;
440 }
441 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
442 || !ActiveBackend::available()
443 {
444 return None;
445 }
446 let message = error.to_string().to_ascii_lowercase();
447 if error.kind() == std::io::ErrorKind::PermissionDenied
448 || message.contains("operation not permitted")
449 || message.contains("permission denied")
450 || message.contains("access is denied")
451 {
452 return Some(sandbox_rejection(format!(
453 "sandbox violation: process was denied by the OS sandbox before exec: {error}"
454 )));
455 }
456 None
457}
458
459#[cfg(unix)]
460fn sandbox_signal_status(output: &std::process::Output) -> bool {
461 use std::os::unix::process::ExitStatusExt;
462
463 matches!(
464 output.status.signal(),
465 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
466 )
467}
468
469#[cfg(not(unix))]
470fn sandbox_signal_status(_output: &std::process::Output) -> bool {
471 false
472}
473
474pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
482 let policy = crate::orchestration::current_execution_policy()?;
483 let profile = policy.sandbox_profile;
484 match profile {
485 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
486 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
487 if effective_fallback(profile) == SandboxFallback::Off {
488 None
489 } else {
490 Some((policy, profile))
491 }
492 }
493 }
494}
495
496fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
497 if let Some(cwd) = config.cwd.as_ref() {
498 command.current_dir(cwd);
499 }
500 command.envs(config.env.iter().map(|(key, value)| (key, value)));
501 if config.stdin_null {
502 command.stdin(Stdio::null());
503 }
504}
505
506fn spawn_error(error: std::io::Error) -> VmError {
507 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(format!(
508 "process spawn failed: {error}"
509 ))))
510}
511
512pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
517 if matches!(profile, SandboxProfile::OsHardened) {
518 return SandboxFallback::Enforce;
519 }
520 match std::env::var(HANDLER_SANDBOX_ENV)
521 .unwrap_or_else(|_| "warn".to_string())
522 .trim()
523 .to_ascii_lowercase()
524 .as_str()
525 {
526 "0" | "false" | "off" | "none" => SandboxFallback::Off,
527 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
528 _ => SandboxFallback::Warn,
529 }
530}
531
532pub(crate) fn warn_once(key: &str, message: &str) {
533 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
534 if inserted {
535 crate::events::log_warn("handler_sandbox", message);
536 }
537}
538
539pub(crate) fn sandbox_rejection(message: String) -> VmError {
540 VmError::CategorizedError {
541 message,
542 category: ErrorCategory::ToolRejected,
543 }
544}
545
546#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
556pub(crate) fn unavailable(
557 message: &str,
558 profile: SandboxProfile,
559) -> Result<PrepareOutcome, VmError> {
560 match effective_fallback(profile) {
561 SandboxFallback::Off | SandboxFallback::Warn => {
562 warn_once("handler_sandbox_unavailable", message);
563 Ok(PrepareOutcome::Direct)
564 }
565 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
566 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
567 ))),
568 }
569}
570
571fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
572 if policy.workspace_roots.is_empty() {
573 return vec![normalize_for_policy(
574 &crate::stdlib::process::execution_root_path(),
575 )];
576 }
577 policy
578 .workspace_roots
579 .iter()
580 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
581 .collect()
582}
583
584pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
585 normalized_workspace_roots(policy)
586}
587
588fn resolve_policy_path(path: &str) -> PathBuf {
589 let candidate = PathBuf::from(path);
590 if candidate.is_absolute() {
591 candidate
592 } else {
593 crate::stdlib::process::execution_root_path().join(candidate)
594 }
595}
596
597fn normalize_for_policy(path: &Path) -> PathBuf {
598 let absolute = if path.is_absolute() {
599 path.to_path_buf()
600 } else {
601 crate::stdlib::process::execution_root_path().join(path)
602 };
603 let absolute = normalize_lexically(&absolute);
604 if let Ok(canonical) = absolute.canonicalize() {
605 return canonical;
606 }
607
608 let mut existing = absolute.as_path();
609 let mut suffix = Vec::new();
610 while !existing.exists() {
611 let Some(parent) = existing.parent() else {
612 return normalize_lexically(&absolute);
613 };
614 if let Some(name) = existing.file_name() {
615 suffix.push(name.to_os_string());
616 }
617 existing = parent;
618 }
619
620 let mut normalized = existing
621 .canonicalize()
622 .unwrap_or_else(|_| normalize_lexically(existing));
623 for component in suffix.iter().rev() {
624 normalized.push(component);
625 }
626 normalize_lexically(&normalized)
627}
628
629fn normalize_lexically(path: &Path) -> PathBuf {
630 let mut normalized = PathBuf::new();
631 for component in path.components() {
632 match component {
633 Component::CurDir => {}
634 Component::ParentDir => {
635 normalized.pop();
636 }
637 other => normalized.push(other.as_os_str()),
638 }
639 }
640 normalized
641}
642
643fn path_is_within(path: &Path, root: &Path) -> bool {
644 path == root || path.starts_with(root)
645}
646
647#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
648pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
649 fn rank(value: &str) -> usize {
650 match value {
651 "none" => 0,
652 "read_only" => 1,
653 "workspace_write" => 2,
654 "process_exec" => 3,
655 "network" => 4,
656 _ => 5,
657 }
658 }
659 policy
660 .side_effect_level
661 .as_ref()
662 .map(|level| rank(level) >= rank("network"))
663 .unwrap_or(true)
664}
665
666#[cfg(any(target_os = "macos", target_os = "openbsd", target_os = "windows"))]
667pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
668 policy.capabilities.is_empty()
669 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
670}
671
672#[cfg(any(
673 target_os = "linux",
674 target_os = "macos",
675 target_os = "openbsd",
676 target_os = "windows"
677))]
678pub(crate) fn policy_allows_capability(
679 policy: &CapabilityPolicy,
680 capability: &str,
681 ops: &[&str],
682) -> bool {
683 policy
684 .capabilities
685 .get(capability)
686 .map(|allowed| {
687 ops.iter()
688 .any(|op| allowed.iter().any(|candidate| candidate == op))
689 })
690 .unwrap_or(false)
691}
692
693impl FsAccess {
694 fn verb(self) -> &'static str {
695 match self {
696 FsAccess::Read => "read",
697 FsAccess::Write => "write",
698 FsAccess::Delete => "delete",
699 }
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706 use crate::orchestration::{pop_execution_policy, push_execution_policy};
707
708 #[test]
709 fn missing_create_path_normalizes_against_existing_parent() {
710 let dir = tempfile::tempdir().unwrap();
711 let nested = dir.path().join("a/../new.txt");
712 let normalized = normalize_for_policy(&nested);
713 assert_eq!(
714 normalized,
715 normalize_for_policy(&dir.path().join("new.txt"))
716 );
717 }
718
719 #[test]
720 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
721 let dir = tempfile::tempdir().unwrap();
722 crate::stdlib::process::set_thread_execution_context(Some(
723 crate::orchestration::RunExecutionRecord {
724 cwd: Some(dir.path().to_string_lossy().into_owned()),
725 source_dir: None,
726 env: Default::default(),
727 adapter: None,
728 repo_path: None,
729 worktree_path: None,
730 branch: None,
731 base_ref: None,
732 cleanup: None,
733 },
734 ));
735 push_execution_policy(CapabilityPolicy {
736 sandbox_profile: SandboxProfile::Worktree,
737 ..CapabilityPolicy::default()
738 });
739
740 assert!(
741 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
742 );
743 let outside = tempfile::tempdir().unwrap();
744 assert!(enforce_fs_path(
745 "read_file",
746 &outside.path().join("outside.txt"),
747 FsAccess::Read
748 )
749 .is_err());
750
751 pop_execution_policy();
752 crate::stdlib::process::set_thread_execution_context(None);
753 }
754
755 #[test]
756 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
757 let dir = tempfile::tempdir().unwrap();
758 crate::stdlib::process::set_thread_execution_context(Some(
759 crate::orchestration::RunExecutionRecord {
760 cwd: Some(dir.path().to_string_lossy().into_owned()),
761 source_dir: None,
762 env: Default::default(),
763 adapter: None,
764 repo_path: None,
765 worktree_path: None,
766 branch: None,
767 base_ref: None,
768 cleanup: None,
769 },
770 ));
771 push_execution_policy(CapabilityPolicy {
772 sandbox_profile: SandboxProfile::Worktree,
773 ..CapabilityPolicy::default()
774 });
775
776 assert!(enforce_process_cwd(dir.path()).is_ok());
777 let outside = tempfile::tempdir().unwrap();
778 assert!(enforce_process_cwd(outside.path()).is_err());
779
780 pop_execution_policy();
781 crate::stdlib::process::set_thread_execution_context(None);
782 }
783
784 #[test]
785 fn path_within_root_accepts_root_and_children() {
786 let root = Path::new("/tmp/harn-root");
787 assert!(path_is_within(root, root));
788 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
789 assert!(!path_is_within(
790 Path::new("/tmp/harn-root-other/file"),
791 root
792 ));
793 }
794
795 #[test]
796 fn os_hardened_profile_overrides_fallback_env() {
797 assert_eq!(
802 effective_fallback(SandboxProfile::OsHardened),
803 SandboxFallback::Enforce
804 );
805 }
806
807 #[test]
808 fn unrestricted_profile_skips_active_sandbox() {
809 let policy = CapabilityPolicy {
810 sandbox_profile: SandboxProfile::Unrestricted,
811 workspace_roots: vec!["/tmp".to_string()],
812 ..Default::default()
813 };
814 crate::orchestration::push_execution_policy(policy);
815 let result = active_sandbox_policy();
816 crate::orchestration::pop_execution_policy();
817 assert!(
818 result.is_none(),
819 "Unrestricted profile must short-circuit sandbox dispatch"
820 );
821 }
822
823 #[test]
824 fn worktree_profile_engages_active_sandbox() {
825 let policy = CapabilityPolicy {
826 sandbox_profile: SandboxProfile::Worktree,
827 workspace_roots: vec!["/tmp".to_string()],
828 ..Default::default()
829 };
830 crate::orchestration::push_execution_policy(policy);
831 let result = active_sandbox_policy();
832 crate::orchestration::pop_execution_policy();
833 assert!(
834 result.is_some(),
835 "Worktree profile must keep sandbox dispatch active"
836 );
837 }
838}