1use std::cell::RefCell;
38use std::collections::BTreeSet;
39use std::io;
40use std::io::Write as _;
41use std::path::{Component, Path, PathBuf};
42use std::process::{Command, Output, Stdio};
43
44#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
45use crate::orchestration::ProcessSandboxPreset;
46use crate::orchestration::{CapabilityPolicy, SandboxProfile};
47use crate::value::{ErrorCategory, VmError, VmValue};
48use crate::vm::Vm;
49
50#[cfg(target_os = "linux")]
51mod linux;
52mod locked_append;
53#[cfg(target_os = "macos")]
54mod macos;
55#[cfg(target_os = "openbsd")]
56mod openbsd;
57#[cfg(target_os = "windows")]
58mod windows;
59
60pub(crate) use locked_append::AppendLockOptions;
61
62const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
63#[cfg(any(unix, windows))]
64const MAX_SCOPED_PATH_COMPONENTS: usize = 256;
65
66thread_local! {
67 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
68}
69
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum FsAccess {
75 Read,
76 Write,
77 Delete,
78}
79
80#[derive(Clone, Debug, Default)]
81pub struct ProcessCommandConfig {
82 pub cwd: Option<PathBuf>,
83 pub env: Vec<(String, String)>,
84 pub stdin_null: bool,
85}
86
87#[derive(Clone, Debug, Default)]
88pub struct ProcessSandboxScope {
89 pub workspace_roots: Vec<String>,
90}
91
92#[must_use]
93pub struct ProcessSandboxScopeGuard {
94 pushed: bool,
95}
96
97impl Drop for ProcessSandboxScopeGuard {
98 fn drop(&mut self) {
99 if self.pushed {
100 crate::orchestration::pop_execution_policy();
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub(crate) enum SandboxFallback {
107 Off,
108 Warn,
109 Enforce,
110}
111
112pub(crate) trait SandboxBackend {
123 fn name() -> &'static str;
125
126 fn available() -> bool;
130
131 fn prepare_std_command(
137 program: &str,
138 args: &[String],
139 command: &mut Command,
140 policy: &CapabilityPolicy,
141 profile: SandboxProfile,
142 ) -> Result<PrepareOutcome, VmError>;
143
144 fn prepare_tokio_command(
146 program: &str,
147 args: &[String],
148 command: &mut tokio::process::Command,
149 policy: &CapabilityPolicy,
150 profile: SandboxProfile,
151 ) -> Result<PrepareOutcome, VmError>;
152
153 fn run_to_output(
158 program: &str,
159 args: &[String],
160 config: &ProcessCommandConfig,
161 policy: &CapabilityPolicy,
162 profile: SandboxProfile,
163 ) -> Result<Output, VmError> {
164 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
165 apply_process_config(&mut command, config);
166 crate::op_interrupt::capture_output_interruptible(&mut command)
167 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
168 }
169}
170
171pub(crate) enum PrepareOutcome {
175 Direct,
177 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
183 WrappedExec { wrapper: String, args: Vec<String> },
184}
185
186#[cfg(target_os = "linux")]
187type ActiveBackend = linux::Backend;
188#[cfg(target_os = "macos")]
189type ActiveBackend = macos::Backend;
190#[cfg(target_os = "openbsd")]
191type ActiveBackend = openbsd::Backend;
192#[cfg(target_os = "windows")]
193type ActiveBackend = windows::Backend;
194#[cfg(not(any(
195 target_os = "linux",
196 target_os = "macos",
197 target_os = "openbsd",
198 target_os = "windows"
199)))]
200type ActiveBackend = NoopBackend;
201
202#[cfg(not(any(
203 target_os = "linux",
204 target_os = "macos",
205 target_os = "openbsd",
206 target_os = "windows"
207)))]
208pub(crate) struct NoopBackend;
209
210#[cfg(not(any(
211 target_os = "linux",
212 target_os = "macos",
213 target_os = "openbsd",
214 target_os = "windows"
215)))]
216impl SandboxBackend for NoopBackend {
217 fn name() -> &'static str {
218 "noop"
219 }
220 fn available() -> bool {
221 false
222 }
223 fn prepare_std_command(
224 _program: &str,
225 _args: &[String],
226 _command: &mut Command,
227 _policy: &CapabilityPolicy,
228 _profile: SandboxProfile,
229 ) -> Result<PrepareOutcome, VmError> {
230 Ok(PrepareOutcome::Direct)
231 }
232 fn prepare_tokio_command(
233 _program: &str,
234 _args: &[String],
235 _command: &mut tokio::process::Command,
236 _policy: &CapabilityPolicy,
237 _profile: SandboxProfile,
238 ) -> Result<PrepareOutcome, VmError> {
239 Ok(PrepareOutcome::Direct)
240 }
241}
242
243pub(crate) fn reset_sandbox_state() {
244 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
245}
246
247pub fn active_backend_name() -> &'static str {
251 ActiveBackend::name()
252}
253
254pub fn active_backend_available() -> bool {
259 ActiveBackend::available()
260}
261
262pub fn register_sandbox_builtins(vm: &mut Vm) {
266 for def in MODULE_BUILTINS {
267 vm.register_builtin_def(def);
268 }
269}
270
271pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
272 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
273 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
274 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
275];
276
277#[crate::stdlib::macros::harn_builtin(
278 sig = "sandbox_active_backend() -> string",
279 category = "sandbox"
280)]
281fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
282 Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
283}
284
285#[crate::stdlib::macros::harn_builtin(
286 sig = "sandbox_backend_available() -> bool",
287 category = "sandbox"
288)]
289fn sandbox_backend_available_impl(
290 _args: &[VmValue],
291 _out: &mut String,
292) -> Result<VmValue, VmError> {
293 Ok(VmValue::Bool(active_backend_available()))
294}
295
296#[crate::stdlib::macros::harn_builtin(
297 sig = "sandbox_active_profile() -> string",
298 category = "sandbox"
299)]
300fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
301 let profile = crate::orchestration::current_execution_policy()
302 .map(|policy| policy.sandbox_profile)
303 .unwrap_or(SandboxProfile::Unrestricted);
304 Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
305}
306
307#[derive(Clone, Debug)]
314pub struct SandboxViolation {
315 pub attempted: PathBuf,
319 pub roots: Vec<PathBuf>,
322 pub access: FsAccess,
324 pub read_only: bool,
328}
329
330impl SandboxViolation {
331 pub fn message(&self, builtin: &str) -> String {
335 if self.read_only {
336 return format!(
337 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
338 self.access.verb(),
339 self.attempted.display(),
340 );
341 }
342 format!(
343 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
344 self.access.verb(),
345 self.attempted.display(),
346 self.roots
347 .iter()
348 .map(|root| root.display().to_string())
349 .collect::<Vec<_>>()
350 .join(", ")
351 )
352 }
353}
354
355pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
370 let Some(policy) = crate::orchestration::current_execution_policy() else {
371 return Ok(());
372 };
373 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
374 return Ok(());
375 }
376 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
387 return Ok(());
388 }
389 let candidate = normalize_for_policy(path);
390 let roots = normalized_workspace_roots(&policy);
391 if roots.iter().any(|root| path_is_within(&candidate, root)) {
392 return Ok(());
393 }
394 let read_only_roots = normalized_read_only_roots(&policy);
395 let within_read_only = read_only_roots
396 .iter()
397 .any(|root| path_is_within(&candidate, root));
398 if within_read_only && access == FsAccess::Read {
399 return Ok(());
400 }
401 Err(SandboxViolation {
402 attempted: candidate,
403 roots,
404 access,
405 read_only: within_read_only,
406 })
407}
408
409pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
410 check_fs_path_scope(path, access)
411 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
412}
413
414pub(crate) fn atomic_write_scoped_at_open(
415 builtin: &str,
416 path: &Path,
417 contents: &[u8],
418) -> io::Result<()> {
419 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
420 return atomic_write_unscoped(path, contents);
421 };
422 atomic_write_scoped_target(&target, contents)
423}
424
425pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
426 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
427 return append_unscoped(path, contents);
428 };
429 append_scoped_target(&target, contents)
430}
431
432pub(crate) fn append_locked_scoped_at_open(
433 builtin: &str,
434 path: &Path,
435 contents: &[u8],
436 options: AppendLockOptions,
437) -> io::Result<()> {
438 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
439 return locked_append::append_locked_unscoped(path, contents, options);
440 };
441 locked_append::append_locked_scoped_target(&target, contents, options)
442}
443
444pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
445 let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
446 return std::fs::copy(src, dst);
447 };
448 copy_scoped_target(src, &target)
449}
450
451pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
452 let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
453 return std::fs::rename(src, dst);
454 };
455 let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
456 io::Error::new(
457 io::ErrorKind::PermissionDenied,
458 format!(
459 "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
460 dst.display()
461 ),
462 )
463 })?;
464 rename_scoped_targets(&src_target, &dst_target)
465}
466
467pub(crate) fn create_dir_scoped_at_open(
468 builtin: &str,
469 path: &Path,
470 recursive: bool,
471) -> io::Result<()> {
472 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
473 return if recursive {
474 std::fs::create_dir_all(path)
475 } else {
476 std::fs::create_dir(path)
477 };
478 };
479 if recursive {
480 create_dir_all_scoped_target(&target)
481 } else {
482 create_dir_scoped_target(&target)
483 }
484}
485
486#[derive(Clone, Debug)]
487struct ScopedMutationTarget {
488 root: PathBuf,
489 relative: PathBuf,
490}
491
492fn scoped_mutation_target(
493 builtin: &str,
494 path: &Path,
495 access: FsAccess,
496) -> io::Result<Option<ScopedMutationTarget>> {
497 let Some(policy) = crate::orchestration::current_execution_policy() else {
498 return Ok(None);
499 };
500 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
501 return Ok(None);
502 }
503 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
504 return Ok(None);
505 }
506 check_fs_path_scope(path, access).map_err(|violation| {
507 io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
508 })?;
509 let candidate = normalize_for_policy(path);
510 let roots = normalized_workspace_roots(&policy);
511 let Some(root) = roots
512 .into_iter()
513 .find(|root| path_is_within(&candidate, root))
514 else {
515 return Err(io::Error::new(
516 io::ErrorKind::PermissionDenied,
517 format!(
518 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
519 access.verb(),
520 candidate.display()
521 ),
522 ));
523 };
524 let relative = candidate.strip_prefix(&root).map_err(|_| {
525 io::Error::new(
526 io::ErrorKind::PermissionDenied,
527 format!(
528 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
529 access.verb(),
530 candidate.display(),
531 root.display()
532 ),
533 )
534 })?;
535 if relative.as_os_str().is_empty() {
536 return Err(io::Error::new(
537 io::ErrorKind::InvalidInput,
538 format!(
539 "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
540 access.verb(),
541 root.display()
542 ),
543 ));
544 }
545 Ok(Some(ScopedMutationTarget {
546 root,
547 relative: relative.to_path_buf(),
548 }))
549}
550
551fn atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
552 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
553 let dir = parent.unwrap_or_else(|| Path::new("."));
554 if let Some(parent) = parent {
559 std::fs::create_dir_all(parent)?;
560 }
561 let tmp_path = dir.join(scoped_tmp_name(path));
562 let write_result = (|| -> io::Result<()> {
563 let mut file = std::fs::File::create(&tmp_path)?;
564 file.write_all(contents)?;
565 file.flush()?;
566 file.sync_all()?;
567 Ok(())
568 })();
569 if let Err(err) = write_result {
570 let _ = std::fs::remove_file(&tmp_path);
571 return Err(err);
572 }
573 if let Err(err) = std::fs::rename(&tmp_path, path) {
574 let _ = std::fs::remove_file(&tmp_path);
575 return Err(err);
576 }
577 Ok(())
578}
579
580fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
581 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
584 std::fs::create_dir_all(parent)?;
585 }
586 std::fs::OpenOptions::new()
587 .create(true)
588 .append(true)
589 .open(path)
590 .and_then(|mut file| file.write_all(contents))
591}
592
593fn scoped_tmp_name(path: &Path) -> String {
594 use std::sync::atomic::{AtomicU64, Ordering};
595 static COUNTER: AtomicU64 = AtomicU64::new(0);
596 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
597 let file_name = path
598 .file_name()
599 .map(|name| name.to_string_lossy().into_owned())
600 .unwrap_or_else(|| "file".to_string());
601 format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id())
602}
603
604#[cfg(unix)]
605fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
606 use std::os::fd::AsRawFd;
607
608 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
613 let tmp_name = scoped_tmp_name(Path::new(&file_name));
614 let mut file = openat_file(
615 parent.as_raw_fd(),
616 &tmp_name,
617 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
618 0o666,
619 )?;
620 let write_result = (|| -> io::Result<()> {
621 file.write_all(contents)?;
622 file.flush()?;
623 file.sync_all()?;
624 Ok(())
625 })();
626 if let Err(err) = write_result {
627 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
628 return Err(err);
629 }
630 if let Err(err) = renameat_name(
631 parent.as_raw_fd(),
632 &tmp_name,
633 parent.as_raw_fd(),
634 &file_name,
635 ) {
636 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
637 return Err(err);
638 }
639 sync_dir_fd(parent.as_raw_fd());
640 Ok(())
641}
642
643#[cfg(windows)]
644fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
645 let (parent, file_name) = win_scoped_parent(target, true)?;
646 let full = parent.join(&file_name);
647 win_reject_reparse_leaf(&full)?;
648 atomic_write_unscoped(&full, contents)
649}
650
651#[cfg(all(not(unix), not(windows)))]
652fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
653 let full = target.root.join(&target.relative);
654 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
655 std::fs::create_dir_all(parent)?;
656 }
657 atomic_write_unscoped(&full, contents)
658}
659
660#[cfg(unix)]
661fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
662 use std::os::fd::AsRawFd;
663
664 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
667 let mut file = openat_file(
668 parent.as_raw_fd(),
669 &file_name,
670 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
671 0o666,
672 )?;
673 file.write_all(contents)
674}
675
676#[cfg(windows)]
677fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
678 let (parent, file_name) = win_scoped_parent(target, true)?;
679 let full = parent.join(&file_name);
680 win_reject_reparse_leaf(&full)?;
681 append_unscoped(&full, contents)
682}
683
684#[cfg(all(not(unix), not(windows)))]
685fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
686 let full = target.root.join(&target.relative);
687 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
688 std::fs::create_dir_all(parent)?;
689 }
690 append_unscoped(&full, contents)
691}
692
693#[cfg(unix)]
694fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
695 use std::os::fd::AsRawFd;
696
697 let mut source = std::fs::File::open(src)?;
698 let source_metadata = source.metadata().ok();
699 let (parent, file_name) = open_parent_dir_scoped(target)?;
700 let mut destination = openat_file(
701 parent.as_raw_fd(),
702 &file_name,
703 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
704 0o666,
705 )?;
706 let copied = io::copy(&mut source, &mut destination)?;
707 destination.sync_all()?;
708 if let Some(metadata) = source_metadata {
709 let _ = destination.set_permissions(metadata.permissions());
710 }
711 sync_dir_fd(parent.as_raw_fd());
712 Ok(copied)
713}
714
715#[cfg(windows)]
716fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
717 let (parent, file_name) = win_scoped_parent(target, false)?;
721 let full = parent.join(&file_name);
722 win_reject_reparse_leaf(&full)?;
723 std::fs::copy(src, full)
724}
725
726#[cfg(all(not(unix), not(windows)))]
727fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
728 std::fs::copy(src, target.root.join(&target.relative))
729}
730
731#[cfg(unix)]
732fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
733 use std::os::fd::AsRawFd;
734
735 let (src_parent, src_name) = open_parent_dir_scoped(src)?;
736 let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
737 renameat_name(
738 src_parent.as_raw_fd(),
739 &src_name,
740 dst_parent.as_raw_fd(),
741 &dst_name,
742 )?;
743 sync_dir_fd(dst_parent.as_raw_fd());
744 Ok(())
745}
746
747#[cfg(windows)]
748fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
749 let (src_parent, src_name) = win_scoped_parent(src, false)?;
754 let (dst_parent, dst_name) = win_scoped_parent(dst, false)?;
755 std::fs::rename(src_parent.join(&src_name), dst_parent.join(&dst_name))
756}
757
758#[cfg(all(not(unix), not(windows)))]
759fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
760 std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
761}
762
763#[cfg(unix)]
764fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
765 use std::os::fd::AsRawFd;
766
767 let (parent, file_name) = open_parent_dir_scoped(target)?;
768 mkdirat_name(parent.as_raw_fd(), &file_name)?;
769 sync_dir_fd(parent.as_raw_fd());
770 Ok(())
771}
772
773#[cfg(windows)]
774fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
775 let (parent, file_name) = win_scoped_parent(target, false)?;
782 win_create_dir_raw(&parent.join(&file_name))
783}
784
785#[cfg(all(not(unix), not(windows)))]
786fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
787 std::fs::create_dir(target.root.join(&target.relative))
788}
789
790#[cfg(unix)]
791fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
792 use std::os::fd::AsRawFd;
793
794 let root = open_dir_absolute(&target.root)?;
795 let mut current = root;
796 for component in clean_relative_components(&target.relative)? {
797 match open_dir_at(current.as_raw_fd(), &component) {
798 Ok(next) => current = next,
799 Err(error) if error.kind() == io::ErrorKind::NotFound => {
800 mkdirat_name(current.as_raw_fd(), &component)?;
801 let next = open_dir_at(current.as_raw_fd(), &component)?;
802 current = next;
803 }
804 Err(error) => return Err(error),
805 }
806 }
807 Ok(())
808}
809
810#[cfg(windows)]
811fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
812 let components = win_clean_relative_components(&target.relative)?;
815 win_walk_components(&target.root, &components, true)?;
816 Ok(())
817}
818
819#[cfg(all(not(unix), not(windows)))]
820fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
821 std::fs::create_dir_all(target.root.join(&target.relative))
822}
823
824#[cfg(unix)]
825#[cfg(unix)]
844fn ensure_parent_dirs_scoped(
845 target: &ScopedMutationTarget,
846) -> io::Result<(std::os::fd::OwnedFd, String)> {
847 use std::os::fd::AsRawFd;
848
849 let mut components = clean_relative_components(&target.relative)?;
850 let file_name = components.pop().ok_or_else(|| {
851 io::Error::new(
852 io::ErrorKind::InvalidInput,
853 format!(
854 "sandbox scoped open requires a file name: {}",
855 target.relative.display()
856 ),
857 )
858 })?;
859 let root = open_dir_absolute(&target.root)?;
860 let mut current = root;
861 for component in components {
862 match open_dir_at(current.as_raw_fd(), &component) {
863 Ok(next) => current = next,
864 Err(error) if error.kind() == io::ErrorKind::NotFound => {
865 if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
866 if mkerr.kind() != io::ErrorKind::AlreadyExists {
867 return Err(mkerr);
868 }
869 }
870 current = open_dir_at(current.as_raw_fd(), &component)?;
871 }
872 Err(error) => return Err(error),
873 }
874 }
875 Ok((current, file_name))
876}
877
878#[cfg(unix)]
879fn open_parent_dir_scoped(
880 target: &ScopedMutationTarget,
881) -> io::Result<(std::os::fd::OwnedFd, String)> {
882 use std::os::fd::AsRawFd;
883
884 let mut components = clean_relative_components(&target.relative)?;
885 let file_name = components.pop().ok_or_else(|| {
886 io::Error::new(
887 io::ErrorKind::InvalidInput,
888 format!(
889 "sandbox scoped open requires a file name: {}",
890 target.relative.display()
891 ),
892 )
893 })?;
894 let root = open_dir_absolute(&target.root)?;
895 let mut current = root;
896 for component in components {
897 current = open_dir_at(current.as_raw_fd(), &component)?;
898 }
899 Ok((current, file_name))
900}
901
902#[cfg(unix)]
903fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
904 use std::os::unix::ffi::OsStrExt;
905
906 let mut out = Vec::new();
907 for component in path.components() {
908 match component {
909 Component::Normal(value) => {
910 let bytes = value.as_bytes();
911 if bytes.contains(&0) {
912 return Err(io::Error::new(
913 io::ErrorKind::InvalidInput,
914 format!("path component contains NUL: {}", path.display()),
915 ));
916 }
917 out.push(value.to_string_lossy().into_owned());
918 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
919 return Err(io::Error::new(
920 io::ErrorKind::InvalidInput,
921 format!(
922 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
923 path.display()
924 ),
925 ));
926 }
927 }
928 Component::CurDir => {}
929 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
930 return Err(io::Error::new(
931 io::ErrorKind::InvalidInput,
932 format!("sandbox scoped path must stay relative: {}", path.display()),
933 ));
934 }
935 }
936 }
937 Ok(out)
938}
939
940#[cfg(unix)]
941fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
942 use std::os::fd::{FromRawFd, OwnedFd};
943 use std::os::unix::ffi::OsStrExt;
944
945 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
946 io::Error::new(
947 io::ErrorKind::InvalidInput,
948 format!("path contains NUL: {}", path.display()),
949 )
950 })?;
951 let fd = unsafe {
952 libc::open(
953 c_path.as_ptr(),
954 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
955 )
956 };
957 if fd < 0 {
958 return Err(io::Error::last_os_error());
959 }
960 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
961}
962
963#[cfg(unix)]
964fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
965 use std::os::fd::{FromRawFd, OwnedFd};
966
967 let c_name = c_name(name)?;
968 let fd = unsafe {
969 libc::openat(
970 parent_fd,
971 c_name.as_ptr(),
972 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
973 )
974 };
975 if fd < 0 {
976 return Err(io::Error::last_os_error());
977 }
978 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
979}
980
981#[cfg(unix)]
982fn openat_file(
983 parent_fd: libc::c_int,
984 name: &str,
985 flags: libc::c_int,
986 mode: libc::mode_t,
987) -> io::Result<std::fs::File> {
988 use std::os::fd::FromRawFd;
989
990 let c_name = c_name(name)?;
991 let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
992 if fd < 0 {
993 return Err(io::Error::last_os_error());
994 }
995 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
996}
997
998#[cfg(unix)]
999fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
1000 let c_name = c_name(name)?;
1001 let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
1002 if rc != 0 {
1003 return Err(io::Error::last_os_error());
1004 }
1005 Ok(())
1006}
1007
1008#[cfg(unix)]
1009fn renameat_name(
1010 old_parent_fd: libc::c_int,
1011 old_name: &str,
1012 new_parent_fd: libc::c_int,
1013 new_name: &str,
1014) -> io::Result<()> {
1015 let old_name = c_name(old_name)?;
1016 let new_name = c_name(new_name)?;
1017 let rc = unsafe {
1018 libc::renameat(
1019 old_parent_fd,
1020 old_name.as_ptr(),
1021 new_parent_fd,
1022 new_name.as_ptr(),
1023 )
1024 };
1025 if rc != 0 {
1026 return Err(io::Error::last_os_error());
1027 }
1028 Ok(())
1029}
1030
1031#[cfg(unix)]
1032fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
1033 let c_name = c_name(name)?;
1034 let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
1035 if rc != 0 {
1036 return Err(io::Error::last_os_error());
1037 }
1038 Ok(())
1039}
1040
1041#[cfg(unix)]
1042fn sync_dir_fd(fd: libc::c_int) {
1043 let _ = unsafe { libc::fsync(fd) };
1044}
1045
1046#[cfg(unix)]
1047fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1048 std::ffi::CString::new(name).map_err(|_| {
1049 io::Error::new(
1050 io::ErrorKind::InvalidInput,
1051 format!("path component contains NUL: {name:?}"),
1052 )
1053 })
1054}
1055
1056#[cfg(windows)]
1083const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1084#[cfg(windows)]
1085const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1086
1087#[cfg(windows)]
1088fn win_wide(path: &Path) -> Vec<u16> {
1089 use std::os::windows::ffi::OsStrExt;
1090 path.as_os_str()
1091 .encode_wide()
1092 .chain(std::iter::once(0))
1093 .collect()
1094}
1095
1096#[cfg(windows)]
1103fn win_reject_reparse_point(path: &Path) -> io::Result<()> {
1104 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1105 use windows_sys::Win32::Storage::FileSystem::{
1106 CreateFileW, FileAttributeTagInfo, GetFileInformationByHandleEx,
1107 FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS,
1108 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1109 OPEN_EXISTING,
1110 };
1111
1112 const FILE_READ_ATTRIBUTES: u32 = 0x0080;
1114
1115 let wide = win_wide(path);
1116 let handle = unsafe {
1117 CreateFileW(
1118 wide.as_ptr(),
1119 FILE_READ_ATTRIBUTES,
1120 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1121 std::ptr::null(),
1122 OPEN_EXISTING,
1123 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
1124 std::ptr::null_mut(),
1125 )
1126 };
1127 if handle == INVALID_HANDLE_VALUE {
1128 return Err(io::Error::last_os_error());
1129 }
1130 let mut info = FILE_ATTRIBUTE_TAG_INFO::default();
1131 let ok = unsafe {
1132 GetFileInformationByHandleEx(
1133 handle,
1134 FileAttributeTagInfo,
1135 std::ptr::from_mut(&mut info).cast(),
1136 std::mem::size_of::<FILE_ATTRIBUTE_TAG_INFO>() as u32,
1137 )
1138 };
1139 let result = if ok == 0 {
1140 Err(io::Error::last_os_error())
1141 } else if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1142 && matches!(
1143 info.ReparseTag,
1144 IO_REPARSE_TAG_MOUNT_POINT | IO_REPARSE_TAG_SYMLINK
1145 )
1146 {
1147 Err(io::Error::new(
1148 io::ErrorKind::PermissionDenied,
1149 format!(
1150 "sandbox scoped walk refuses reparse-point (junction/symlink) component: {}",
1151 path.display()
1152 ),
1153 ))
1154 } else {
1155 Ok(())
1156 };
1157 unsafe {
1158 CloseHandle(handle);
1159 }
1160 result
1161}
1162
1163#[cfg(windows)]
1166fn win_reject_reparse_leaf(path: &Path) -> io::Result<()> {
1167 match win_reject_reparse_point(path) {
1168 Ok(()) => Ok(()),
1169 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1170 Err(err) => Err(err),
1171 }
1172}
1173
1174#[cfg(windows)]
1178fn win_create_dir_raw(path: &Path) -> io::Result<()> {
1179 use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
1180 let wide = win_wide(path);
1181 let ok = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) };
1182 if ok == 0 {
1183 return Err(io::Error::last_os_error());
1184 }
1185 Ok(())
1186}
1187
1188#[cfg(windows)]
1192fn win_clean_relative_components(path: &Path) -> io::Result<Vec<std::ffi::OsString>> {
1193 use std::os::windows::ffi::OsStrExt;
1194
1195 let mut out: Vec<std::ffi::OsString> = Vec::new();
1196 for component in path.components() {
1197 match component {
1198 Component::Normal(value) => {
1199 if value.encode_wide().any(|unit| unit == 0) {
1200 return Err(io::Error::new(
1201 io::ErrorKind::InvalidInput,
1202 format!("path component contains NUL: {}", path.display()),
1203 ));
1204 }
1205 out.push(value.to_os_string());
1206 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
1207 return Err(io::Error::new(
1208 io::ErrorKind::InvalidInput,
1209 format!(
1210 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
1211 path.display()
1212 ),
1213 ));
1214 }
1215 }
1216 Component::CurDir => {}
1217 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1218 return Err(io::Error::new(
1219 io::ErrorKind::InvalidInput,
1220 format!("sandbox scoped path must stay relative: {}", path.display()),
1221 ));
1222 }
1223 }
1224 }
1225 Ok(out)
1226}
1227
1228#[cfg(windows)]
1233fn win_walk_components(
1234 root: &Path,
1235 components: &[std::ffi::OsString],
1236 create: bool,
1237) -> io::Result<PathBuf> {
1238 win_reject_reparse_point(root)?;
1242 let mut current = root.to_path_buf();
1243 for component in components {
1244 current.push(component);
1245 match win_reject_reparse_point(¤t) {
1246 Ok(()) => {}
1247 Err(err) if create && err.kind() == io::ErrorKind::NotFound => {
1248 match win_create_dir_raw(¤t) {
1249 Ok(()) => {}
1250 Err(mkerr) if mkerr.kind() == io::ErrorKind::AlreadyExists => {}
1252 Err(mkerr) => return Err(mkerr),
1253 }
1254 win_reject_reparse_point(¤t)?;
1255 }
1256 Err(err) => return Err(err),
1257 }
1258 }
1259 Ok(current)
1260}
1261
1262#[cfg(windows)]
1266fn win_scoped_parent(
1267 target: &ScopedMutationTarget,
1268 create_parents: bool,
1269) -> io::Result<(PathBuf, std::ffi::OsString)> {
1270 let mut components = win_clean_relative_components(&target.relative)?;
1271 let file_name = components.pop().ok_or_else(|| {
1272 io::Error::new(
1273 io::ErrorKind::InvalidInput,
1274 format!(
1275 "sandbox scoped open requires a file name: {}",
1276 target.relative.display()
1277 ),
1278 )
1279 })?;
1280 let parent = win_walk_components(&target.root, &components, create_parents)?;
1281 Ok((parent, file_name))
1282}
1283
1284pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
1285 let Some(policy) = crate::orchestration::current_execution_policy() else {
1286 return Ok(());
1287 };
1288 enforce_process_cwd_for_policy(path, &policy)
1289}
1290
1291pub fn push_process_sandbox_scope(
1292 scope: ProcessSandboxScope,
1293) -> Result<ProcessSandboxScopeGuard, VmError> {
1294 let Some(mut policy) = crate::orchestration::current_execution_policy() else {
1295 return Ok(ProcessSandboxScopeGuard { pushed: false });
1296 };
1297 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1298 return Ok(ProcessSandboxScopeGuard { pushed: false });
1299 }
1300
1301 let requested_roots: Vec<PathBuf> = scope
1302 .workspace_roots
1303 .iter()
1304 .filter_map(|root| {
1305 let trimmed = root.trim();
1306 (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1307 })
1308 .collect();
1309 if requested_roots.is_empty() {
1310 return Ok(ProcessSandboxScopeGuard { pushed: false });
1311 }
1312
1313 if !policy.workspace_roots.is_empty() {
1314 let ceiling_roots = normalized_workspace_roots(&policy);
1315 if let Some(rejected) = requested_roots.iter().find(|root| {
1316 !ceiling_roots
1317 .iter()
1318 .any(|ceiling| path_is_within(root, ceiling))
1319 }) {
1320 return Err(sandbox_rejection(format!(
1321 "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1322 rejected.display(),
1323 ceiling_roots
1324 .iter()
1325 .map(|root| root.display().to_string())
1326 .collect::<Vec<_>>()
1327 .join(", ")
1328 )));
1329 }
1330 }
1331
1332 let mut merged_roots = if policy.workspace_roots.is_empty() {
1333 Vec::new()
1334 } else {
1335 normalized_workspace_roots(&policy)
1336 };
1337 for requested in requested_roots {
1338 if !merged_roots
1339 .iter()
1340 .any(|existing| path_is_within(&requested, existing))
1341 {
1342 merged_roots.push(requested);
1343 }
1344 }
1345 policy.workspace_roots = merged_roots
1346 .into_iter()
1347 .map(|root| root.display().to_string())
1348 .collect();
1349 crate::orchestration::push_execution_policy(policy);
1350 Ok(ProcessSandboxScopeGuard { pushed: true })
1351}
1352
1353fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1354 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1355 return Ok(());
1356 }
1357 let candidate = normalize_for_policy(path);
1358 let roots = normalized_workspace_roots(policy);
1359 if roots.iter().any(|root| path_is_within(&candidate, root)) {
1360 return Ok(());
1361 }
1362 Err(sandbox_rejection(format!(
1363 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1364 candidate.display(),
1365 roots
1366 .iter()
1367 .map(|root| root.display().to_string())
1368 .collect::<Vec<_>>()
1369 .join(", ")
1370 )))
1371}
1372
1373pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1374 let (policy, profile) = match active_sandbox_policy() {
1375 Some(value) => value,
1376 None => {
1377 let mut command = Command::new(program);
1378 command.args(args);
1379 return Ok(command);
1380 }
1381 };
1382 build_std_command::<ActiveBackend>(program, args, &policy, profile)
1383}
1384
1385pub fn tokio_command_for(
1386 program: &str,
1387 args: &[String],
1388) -> Result<tokio::process::Command, VmError> {
1389 let (policy, profile) = match active_sandbox_policy() {
1390 Some(value) => value,
1391 None => {
1392 let mut command = tokio::process::Command::new(program);
1393 command.args(args);
1394 return Ok(command);
1395 }
1396 };
1397 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1398}
1399
1400pub fn command_output(
1401 program: &str,
1402 args: &[String],
1403 config: &ProcessCommandConfig,
1404) -> Result<Output, VmError> {
1405 if let Some(intercepted) =
1410 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1411 {
1412 return intercepted.map_err(|message| {
1413 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1414 });
1415 }
1416
1417 let recording =
1418 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1419
1420 let output = match active_sandbox_policy() {
1421 Some((policy, profile)) => {
1422 let config = sandboxed_process_config(config, &policy)?;
1423 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1424 }
1425 None => {
1426 let mut command = Command::new(program);
1427 command.args(args);
1428 apply_process_config(&mut command, config);
1429 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1434 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1435 })?
1436 }
1437 };
1438 if let Some(error) = process_violation_error(&output) {
1439 return Err(error);
1440 }
1441 if let Some(span) = recording {
1442 span.finish(&output);
1443 }
1444 Ok(output)
1445}
1446
1447fn sandboxed_process_config(
1448 config: &ProcessCommandConfig,
1449 policy: &CapabilityPolicy,
1450) -> Result<ProcessCommandConfig, VmError> {
1451 let mut resolved = config.clone();
1452 if let Some(cwd) = resolved.cwd.as_ref() {
1453 enforce_process_cwd_for_policy(cwd, policy)?;
1454 } else {
1455 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1456 }
1457 neutralize_rustc_wrapper(&mut resolved.env);
1458 inject_workspace_tmpdir(&mut resolved.env, policy);
1459 Ok(resolved)
1460}
1461
1462fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1476 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1477 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1478 entry.1.clear();
1479 } else {
1480 env.push((key.to_string(), String::new()));
1481 }
1482 }
1483}
1484
1485pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1491
1492pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1496
1497pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1514 let root = normalized_workspace_roots(policy).into_iter().next()?;
1515 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1516 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1517 warn_once(
1518 "handler_sandbox_workspace_tmpdir",
1519 &format!(
1520 "could not create workspace-local temp dir '{}': {error}; \
1521 leaving the child's inherited temp dir in place",
1522 tmpdir.display()
1523 ),
1524 );
1525 return None;
1526 }
1527 let ignore = tmpdir.join(".gitignore");
1533 if !ignore.exists() {
1534 let _ = std::fs::write(
1535 &ignore,
1536 "# Created by the Harn sandbox; safe to delete.\n*\n",
1537 );
1538 }
1539 Some(tmpdir)
1540}
1541
1542pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1552 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1553 return;
1554 }
1555 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1556 return;
1557 };
1558 let tmpdir = tmpdir.display().to_string();
1559 for key in TMPDIR_ENV_KEYS {
1560 if env.iter().any(|(existing, _)| existing == key) {
1561 continue;
1563 }
1564 env.push((key.to_string(), tmpdir.clone()));
1565 }
1566}
1567
1568pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1583 let Some(policy) = crate::orchestration::current_execution_policy() else {
1584 return Vec::new();
1585 };
1586 let mut env = Vec::new();
1587 inject_workspace_tmpdir(&mut env, &policy);
1588 env
1589}
1590
1591pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1616 vec![
1617 ("LC_MESSAGES".to_string(), "C".to_string()),
1618 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1619 ]
1620}
1621
1622pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1627
1628fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1629 let roots = normalized_workspace_roots(policy);
1630 let current = std::env::current_dir().map_err(|error| {
1631 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1632 format!("process cwd resolution failed: {error}"),
1633 )))
1634 })?;
1635 let current = normalize_for_policy(¤t);
1636 if roots.iter().any(|root| path_is_within(¤t, root)) {
1637 return Ok(current);
1638 }
1639 roots.first().cloned().ok_or_else(|| {
1640 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1641 "process cwd resolution failed: no workspace root available",
1642 )))
1643 })
1644}
1645
1646fn build_std_command<B: SandboxBackend + ?Sized>(
1647 program: &str,
1648 args: &[String],
1649 policy: &CapabilityPolicy,
1650 profile: SandboxProfile,
1651) -> Result<Command, VmError> {
1652 let mut command = Command::new(program);
1653 command.args(args);
1654 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1655 PrepareOutcome::Direct => Ok(command),
1656 PrepareOutcome::WrappedExec { wrapper, args } => {
1657 let mut wrapped = Command::new(wrapper);
1658 wrapped.args(args);
1659 Ok(wrapped)
1660 }
1661 }
1662}
1663
1664fn build_tokio_command<B: SandboxBackend + ?Sized>(
1665 program: &str,
1666 args: &[String],
1667 policy: &CapabilityPolicy,
1668 profile: SandboxProfile,
1669) -> Result<tokio::process::Command, VmError> {
1670 let mut command = tokio::process::Command::new(program);
1671 command.args(args);
1672 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1673 PrepareOutcome::Direct => Ok(command),
1674 PrepareOutcome::WrappedExec { wrapper, args } => {
1675 let mut wrapped = tokio::process::Command::new(wrapper);
1676 wrapped.args(args);
1677 Ok(wrapped)
1678 }
1679 }
1680}
1681
1682pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1683 let policy = crate::orchestration::current_execution_policy()?;
1684 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1685 return None;
1686 }
1687 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1688 || !ActiveBackend::available()
1689 {
1690 return None;
1691 }
1692 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1693 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1694 if !output.status.success()
1695 && (stderr.contains("operation not permitted")
1696 || stderr.contains("permission denied")
1697 || stderr.contains("access is denied")
1698 || stdout.contains("operation not permitted"))
1699 {
1700 return Some(sandbox_rejection(sandbox_process_violation_message(
1701 format!(
1702 "sandbox violation: process was denied by the OS sandbox (status {})",
1703 output.status.code().unwrap_or(-1)
1704 ),
1705 )));
1706 }
1707 if sandbox_signal_status(output) {
1708 return Some(sandbox_rejection(sandbox_process_violation_message(
1709 format!(
1710 "sandbox violation: process was terminated by the OS sandbox (status {})",
1711 output.status
1712 ),
1713 )));
1714 }
1715 None
1716}
1717
1718pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1719 let policy = crate::orchestration::current_execution_policy()?;
1720 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1721 return None;
1722 }
1723 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1724 || !ActiveBackend::available()
1725 {
1726 return None;
1727 }
1728 let message = error.to_string().to_ascii_lowercase();
1729 if error.kind() == std::io::ErrorKind::PermissionDenied
1730 || message.contains("operation not permitted")
1731 || message.contains("permission denied")
1732 || message.contains("access is denied")
1733 {
1734 return Some(sandbox_rejection(sandbox_process_violation_message(
1735 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1736 )));
1737 }
1738 None
1739}
1740
1741#[cfg(unix)]
1742fn sandbox_signal_status(output: &std::process::Output) -> bool {
1743 use std::os::unix::process::ExitStatusExt;
1744
1745 matches!(
1746 output.status.signal(),
1747 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1748 )
1749}
1750
1751#[cfg(not(unix))]
1752fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1753 false
1754}
1755
1756pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1764 let policy = crate::orchestration::current_execution_policy()?;
1765 let profile = policy.sandbox_profile;
1766 match profile {
1767 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1768 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1769 if effective_fallback(profile) == SandboxFallback::Off {
1770 None
1771 } else {
1772 Some((policy, profile))
1773 }
1774 }
1775 }
1776}
1777
1778fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1779 if let Some(cwd) = config.cwd.as_ref() {
1780 command.current_dir(cwd);
1781 }
1782 command.envs(config.env.iter().map(|(key, value)| (key, value)));
1783 if config.stdin_null {
1784 command.stdin(Stdio::null());
1785 }
1786}
1787
1788fn spawn_error(error: std::io::Error) -> VmError {
1789 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1790 format!("process spawn failed: {error}"),
1791 )))
1792}
1793
1794pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1799 if matches!(profile, SandboxProfile::OsHardened) {
1800 return SandboxFallback::Enforce;
1801 }
1802 match std::env::var(HANDLER_SANDBOX_ENV)
1803 .unwrap_or_else(|_| "warn".to_string())
1804 .trim()
1805 .to_ascii_lowercase()
1806 .as_str()
1807 {
1808 "0" | "false" | "off" | "none" => SandboxFallback::Off,
1809 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1810 _ => SandboxFallback::Warn,
1811 }
1812}
1813
1814pub(crate) fn warn_once(key: &str, message: &str) {
1815 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1816 if inserted {
1817 crate::events::log_warn("handler_sandbox", message);
1818 }
1819}
1820
1821pub(crate) fn sandbox_rejection(message: String) -> VmError {
1822 VmError::CategorizedError {
1823 message,
1824 category: ErrorCategory::ToolRejected,
1825 }
1826}
1827
1828fn sandbox_process_violation_message(summary: String) -> String {
1829 format!(
1830 "{summary}; if the command depends on a user-managed toolchain or cache outside the workspace, add that root to process_sandbox.read_roots or process_sandbox.write_roots"
1831 )
1832}
1833
1834#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1844pub(crate) fn unavailable(
1845 message: &str,
1846 profile: SandboxProfile,
1847) -> Result<PrepareOutcome, VmError> {
1848 match effective_fallback(profile) {
1849 SandboxFallback::Off | SandboxFallback::Warn => {
1850 warn_once("handler_sandbox_unavailable", message);
1851 Ok(PrepareOutcome::Direct)
1852 }
1853 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1854 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1855 ))),
1856 }
1857}
1858
1859fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1867 let session_id = crate::agent_sessions::current_session_id()?;
1868 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1869 let mut roots = vec![anchor.primary.clone()];
1870 for mounted in &anchor.additional_roots {
1871 if matches!(
1872 mounted.mount_mode,
1873 crate::workspace_anchor::MountMode::Extend
1874 ) {
1875 roots.push(mounted.path.clone());
1876 }
1877 }
1878 Some(roots)
1879}
1880
1881fn project_root_workspace_root() -> Option<PathBuf> {
1886 crate::stdlib::process::project_root_path().or_else(|| {
1887 std::env::var("HARN_PROJECT_ROOT")
1888 .ok()
1889 .map(|value| value.trim().to_string())
1890 .filter(|value| !value.is_empty())
1891 .map(PathBuf::from)
1892 })
1893}
1894
1895fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1896 let mut roots = base_workspace_roots(policy);
1897 for dir in git_scope_extension_for_roots(&roots).read_write {
1902 if !roots.iter().any(|existing| existing == &dir) {
1903 roots.push(dir);
1904 }
1905 }
1906 roots
1907}
1908
1909fn base_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1914 if policy.workspace_roots.is_empty() {
1915 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1931 return anchor_roots
1932 .iter()
1933 .map(|root| normalize_for_policy(root))
1934 .collect();
1935 }
1936 if let Some(project_root) = project_root_workspace_root() {
1937 return vec![normalize_for_policy(&project_root)];
1938 }
1939 return vec![normalize_for_policy(
1940 &crate::stdlib::process::execution_root_path(),
1941 )];
1942 }
1943 policy
1944 .workspace_roots
1945 .iter()
1946 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1947 .collect()
1948}
1949
1950pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1951 normalized_workspace_roots(policy)
1952}
1953
1954fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1959 let mut roots: Vec<PathBuf> = policy
1960 .read_only_roots
1961 .iter()
1962 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1963 .collect();
1964 for dir in git_scope_extension_for_roots(&base_workspace_roots(policy)).read_only {
1968 if !roots.iter().any(|existing| existing == &dir) {
1969 roots.push(dir);
1970 }
1971 }
1972 roots
1973}
1974
1975fn git_scope_extension_for_roots(
1980 base_roots: &[PathBuf],
1981) -> crate::stdlib::git_topology::GitScopeExtension {
1982 let mut merged = crate::stdlib::git_topology::GitScopeExtension::default();
1983 for root in base_roots {
1984 let ext = crate::stdlib::git_topology::git_scope_extension(root);
1985 for dir in ext.read_write {
1986 let dir = normalize_for_policy(&dir);
1987 if !merged.read_write.iter().any(|existing| existing == &dir) {
1988 merged.read_write.push(dir);
1989 }
1990 }
1991 for dir in ext.read_only {
1992 let dir = normalize_for_policy(&dir);
1993 if !merged.read_only.iter().any(|existing| existing == &dir) {
1994 merged.read_only.push(dir);
1995 }
1996 }
1997 }
1998 merged
1999}
2000
2001#[cfg(any(
2002 target_os = "linux",
2003 target_os = "macos",
2004 target_os = "openbsd",
2005 target_os = "windows"
2006))]
2007pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2008 normalized_read_only_roots(policy)
2009}
2010
2011#[cfg(any(
2012 target_os = "linux",
2013 target_os = "macos",
2014 target_os = "openbsd",
2015 target_os = "windows"
2016))]
2017pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2018 normalized_process_roots(&policy.process_sandbox.read_roots)
2019}
2020
2021#[cfg(any(
2022 target_os = "linux",
2023 target_os = "macos",
2024 target_os = "openbsd",
2025 target_os = "windows"
2026))]
2027pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2028 normalized_process_roots(&policy.process_sandbox.write_roots)
2029}
2030
2031#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2032pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
2033 policy.process_sandbox.effective_presets()
2034}
2035
2036#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2037pub(crate) fn process_sandbox_developer_toolchain_read_roots(
2038 policy: &CapabilityPolicy,
2039) -> Vec<PathBuf> {
2040 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2041 return Vec::new();
2042 }
2043 let Some(home) = sandbox_user_home_dir() else {
2044 return Vec::new();
2045 };
2046 developer_toolchain_read_roots_for_home(&home)
2047}
2048
2049#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2050pub(crate) fn process_sandbox_package_manager_config_read_roots(
2051 policy: &CapabilityPolicy,
2052) -> Vec<PathBuf> {
2053 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
2054 return Vec::new();
2055 }
2056 let Some(home) = sandbox_user_home_dir() else {
2057 return Vec::new();
2058 };
2059 package_manager_config_read_roots_for_home(&home)
2060}
2061
2062#[cfg(any(target_os = "linux", target_os = "macos"))]
2077pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
2078 policy: &CapabilityPolicy,
2079) -> Vec<PathBuf> {
2080 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2081 return Vec::new();
2082 }
2083 let Some(home) = sandbox_user_home_dir() else {
2084 return Vec::new();
2085 };
2086 developer_toolchain_cache_write_roots_for_home(&home)
2087}
2088
2089#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2090fn sandbox_user_home_dir() -> Option<PathBuf> {
2091 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
2094}
2095
2096#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2097pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2098 let mut roots: Vec<_> = [
2099 ".asdf",
2100 ".bun",
2101 ".cargo",
2102 ".fnm",
2103 ".juliaup",
2104 ".local/bin",
2105 ".local/share/mise",
2106 ".local/share/uv",
2107 ".nvm",
2108 ".pyenv",
2109 ".rbenv",
2110 ".rustup",
2111 ".sdkman",
2112 ".swiftly",
2113 ".volta",
2114 "go",
2115 ]
2116 .into_iter()
2117 .map(|entry| normalize_for_policy(&home.join(entry)))
2118 .collect();
2119 #[cfg(target_os = "windows")]
2120 roots.extend(
2121 [
2122 "AppData/Local/Programs/Python",
2123 "AppData/Local/uv",
2124 "AppData/Roaming/uv",
2125 "scoop",
2126 ]
2127 .into_iter()
2128 .map(|entry| normalize_for_policy(&home.join(entry))),
2129 );
2130 roots.sort_unstable();
2131 roots.dedup();
2132 roots
2133}
2134
2135#[cfg(any(target_os = "linux", target_os = "macos"))]
2140pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
2141 let mut roots: Vec<_> = [
2142 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
2148 .into_iter()
2149 .map(|entry| normalize_for_policy(&home.join(entry)))
2150 .collect();
2151 roots.sort_unstable();
2152 roots.dedup();
2153 roots
2154}
2155
2156#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2157pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2158 let mut roots: Vec<_> = [
2159 ".npmrc",
2160 ".gitconfig",
2161 ".netrc",
2162 ".yarnrc.yml",
2163 ".config",
2164 ".npm",
2165 ".cache",
2166 ".pip",
2167 ".pypirc",
2168 ".cargo/config",
2169 ".cargo/config.toml",
2170 ".cargo/credentials",
2171 ".cargo/credentials.toml",
2172 ".cargo/registry",
2173 ".cargo/git",
2174 ]
2175 .into_iter()
2176 .map(|entry| normalize_for_policy(&home.join(entry)))
2177 .collect();
2178 roots.sort_unstable();
2179 roots.dedup();
2180 roots
2181}
2182
2183#[cfg(any(
2184 target_os = "linux",
2185 target_os = "macos",
2186 target_os = "openbsd",
2187 target_os = "windows"
2188))]
2189fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
2190 roots
2191 .iter()
2192 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
2193 .collect()
2194}
2195
2196fn resolve_policy_path(path: &str) -> PathBuf {
2197 let candidate = PathBuf::from(path);
2198 if candidate.is_absolute() {
2199 candidate
2200 } else {
2201 crate::stdlib::process::execution_root_path().join(candidate)
2202 }
2203}
2204
2205fn normalize_for_policy(path: &Path) -> PathBuf {
2206 let absolute = if path.is_absolute() {
2207 path.to_path_buf()
2208 } else {
2209 crate::stdlib::process::execution_root_path().join(path)
2210 };
2211 let absolute = normalize_lexically(&absolute);
2212 if let Ok(canonical) = absolute.canonicalize() {
2213 return canonical;
2214 }
2215
2216 let mut existing = absolute.as_path();
2217 let mut suffix = Vec::new();
2218 while !existing.exists() {
2219 let Some(parent) = existing.parent() else {
2220 return normalize_lexically(&absolute);
2221 };
2222 if let Some(name) = existing.file_name() {
2223 suffix.push(name.to_os_string());
2224 }
2225 existing = parent;
2226 }
2227
2228 let mut normalized = existing
2229 .canonicalize()
2230 .unwrap_or_else(|_| normalize_lexically(existing));
2231 for component in suffix.iter().rev() {
2232 normalized.push(component);
2233 }
2234 normalize_lexically(&normalized)
2235}
2236
2237fn normalize_lexically(path: &Path) -> PathBuf {
2238 let mut normalized = PathBuf::new();
2239 for component in path.components() {
2240 match component {
2241 Component::CurDir => {}
2242 Component::ParentDir => {
2243 normalized.pop();
2244 }
2245 other => normalized.push(other.as_os_str()),
2246 }
2247 }
2248 normalized
2249}
2250
2251fn path_is_within(path: &Path, root: &Path) -> bool {
2252 path == root || path.starts_with(root)
2253}
2254
2255fn normalize_io_device_path(path: &Path) -> PathBuf {
2260 let absolute = if path.is_absolute() {
2261 path.to_path_buf()
2262 } else {
2263 crate::stdlib::process::execution_root_path().join(path)
2264 };
2265 normalize_lexically(&absolute)
2266}
2267
2268fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
2273 match access {
2274 FsAccess::Read => {
2275 matches!(
2276 path.to_str(),
2277 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
2278 ) || is_dev_fd_descriptor(path)
2279 }
2280 FsAccess::Write => {
2281 matches!(
2282 path.to_str(),
2283 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
2284 ) || is_dev_fd_descriptor(path)
2285 }
2286 FsAccess::Delete => false,
2287 }
2288}
2289
2290fn is_dev_fd_descriptor(path: &Path) -> bool {
2293 let Some(text) = path.to_str() else {
2294 return false;
2295 };
2296 let Some(fd) = text.strip_prefix("/dev/fd/") else {
2297 return false;
2298 };
2299 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
2300}
2301
2302#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
2303pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
2304 use crate::tool_annotations::SideEffectLevel;
2305 policy
2306 .side_effect_level
2307 .as_ref()
2308 .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
2309 .unwrap_or(true)
2310}
2311
2312#[cfg(any(
2313 target_os = "linux",
2314 target_os = "macos",
2315 target_os = "openbsd",
2316 target_os = "windows"
2317))]
2318pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
2319 policy.capabilities.is_empty()
2320 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
2321}
2322
2323#[cfg(any(
2324 target_os = "linux",
2325 target_os = "macos",
2326 target_os = "openbsd",
2327 target_os = "windows"
2328))]
2329pub(crate) fn policy_allows_capability(
2330 policy: &CapabilityPolicy,
2331 capability: &str,
2332 ops: &[&str],
2333) -> bool {
2334 policy
2335 .capabilities
2336 .get(capability)
2337 .map(|allowed| {
2338 ops.iter()
2339 .any(|op| allowed.iter().any(|candidate| candidate == op))
2340 })
2341 .unwrap_or(false)
2342}
2343
2344impl FsAccess {
2345 fn verb(self) -> &'static str {
2346 match self {
2347 FsAccess::Read => "read",
2348 FsAccess::Write => "write",
2349 FsAccess::Delete => "delete",
2350 }
2351 }
2352}
2353
2354#[cfg(test)]
2355mod tests;