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;
52#[cfg(target_os = "macos")]
53mod macos;
54#[cfg(target_os = "openbsd")]
55mod openbsd;
56#[cfg(target_os = "windows")]
57mod windows;
58
59const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
60#[cfg(any(unix, windows))]
61const MAX_SCOPED_PATH_COMPONENTS: usize = 256;
62
63thread_local! {
64 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum FsAccess {
72 Read,
73 Write,
74 Delete,
75}
76
77#[derive(Clone, Debug, Default)]
78pub struct ProcessCommandConfig {
79 pub cwd: Option<PathBuf>,
80 pub env: Vec<(String, String)>,
81 pub stdin_null: bool,
82}
83
84#[derive(Clone, Debug, Default)]
85pub struct ProcessSandboxScope {
86 pub workspace_roots: Vec<String>,
87}
88
89#[must_use]
90pub struct ProcessSandboxScopeGuard {
91 pushed: bool,
92}
93
94impl Drop for ProcessSandboxScopeGuard {
95 fn drop(&mut self) {
96 if self.pushed {
97 crate::orchestration::pop_execution_policy();
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub(crate) enum SandboxFallback {
104 Off,
105 Warn,
106 Enforce,
107}
108
109pub(crate) trait SandboxBackend {
120 fn name() -> &'static str;
122
123 fn available() -> bool;
127
128 fn prepare_std_command(
134 program: &str,
135 args: &[String],
136 command: &mut Command,
137 policy: &CapabilityPolicy,
138 profile: SandboxProfile,
139 ) -> Result<PrepareOutcome, VmError>;
140
141 fn prepare_tokio_command(
143 program: &str,
144 args: &[String],
145 command: &mut tokio::process::Command,
146 policy: &CapabilityPolicy,
147 profile: SandboxProfile,
148 ) -> Result<PrepareOutcome, VmError>;
149
150 fn run_to_output(
155 program: &str,
156 args: &[String],
157 config: &ProcessCommandConfig,
158 policy: &CapabilityPolicy,
159 profile: SandboxProfile,
160 ) -> Result<Output, VmError> {
161 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
162 apply_process_config(&mut command, config);
163 crate::op_interrupt::capture_output_interruptible(&mut command)
164 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
165 }
166}
167
168pub(crate) enum PrepareOutcome {
172 Direct,
174 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
180 WrappedExec { wrapper: String, args: Vec<String> },
181}
182
183#[cfg(target_os = "linux")]
184type ActiveBackend = linux::Backend;
185#[cfg(target_os = "macos")]
186type ActiveBackend = macos::Backend;
187#[cfg(target_os = "openbsd")]
188type ActiveBackend = openbsd::Backend;
189#[cfg(target_os = "windows")]
190type ActiveBackend = windows::Backend;
191#[cfg(not(any(
192 target_os = "linux",
193 target_os = "macos",
194 target_os = "openbsd",
195 target_os = "windows"
196)))]
197type ActiveBackend = NoopBackend;
198
199#[cfg(not(any(
200 target_os = "linux",
201 target_os = "macos",
202 target_os = "openbsd",
203 target_os = "windows"
204)))]
205pub(crate) struct NoopBackend;
206
207#[cfg(not(any(
208 target_os = "linux",
209 target_os = "macos",
210 target_os = "openbsd",
211 target_os = "windows"
212)))]
213impl SandboxBackend for NoopBackend {
214 fn name() -> &'static str {
215 "noop"
216 }
217 fn available() -> bool {
218 false
219 }
220 fn prepare_std_command(
221 _program: &str,
222 _args: &[String],
223 _command: &mut Command,
224 _policy: &CapabilityPolicy,
225 _profile: SandboxProfile,
226 ) -> Result<PrepareOutcome, VmError> {
227 Ok(PrepareOutcome::Direct)
228 }
229 fn prepare_tokio_command(
230 _program: &str,
231 _args: &[String],
232 _command: &mut tokio::process::Command,
233 _policy: &CapabilityPolicy,
234 _profile: SandboxProfile,
235 ) -> Result<PrepareOutcome, VmError> {
236 Ok(PrepareOutcome::Direct)
237 }
238}
239
240pub(crate) fn reset_sandbox_state() {
241 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
242}
243
244pub fn active_backend_name() -> &'static str {
248 ActiveBackend::name()
249}
250
251pub fn active_backend_available() -> bool {
256 ActiveBackend::available()
257}
258
259pub fn register_sandbox_builtins(vm: &mut Vm) {
263 for def in MODULE_BUILTINS {
264 vm.register_builtin_def(def);
265 }
266}
267
268pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
269 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
270 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
271 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
272];
273
274#[crate::stdlib::macros::harn_builtin(
275 sig = "sandbox_active_backend() -> string",
276 category = "sandbox"
277)]
278fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
279 Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
280}
281
282#[crate::stdlib::macros::harn_builtin(
283 sig = "sandbox_backend_available() -> bool",
284 category = "sandbox"
285)]
286fn sandbox_backend_available_impl(
287 _args: &[VmValue],
288 _out: &mut String,
289) -> Result<VmValue, VmError> {
290 Ok(VmValue::Bool(active_backend_available()))
291}
292
293#[crate::stdlib::macros::harn_builtin(
294 sig = "sandbox_active_profile() -> string",
295 category = "sandbox"
296)]
297fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
298 let profile = crate::orchestration::current_execution_policy()
299 .map(|policy| policy.sandbox_profile)
300 .unwrap_or(SandboxProfile::Unrestricted);
301 Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
302}
303
304#[derive(Clone, Debug)]
311pub struct SandboxViolation {
312 pub attempted: PathBuf,
316 pub roots: Vec<PathBuf>,
319 pub access: FsAccess,
321 pub read_only: bool,
325}
326
327impl SandboxViolation {
328 pub fn message(&self, builtin: &str) -> String {
332 if self.read_only {
333 return format!(
334 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
335 self.access.verb(),
336 self.attempted.display(),
337 );
338 }
339 format!(
340 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
341 self.access.verb(),
342 self.attempted.display(),
343 self.roots
344 .iter()
345 .map(|root| root.display().to_string())
346 .collect::<Vec<_>>()
347 .join(", ")
348 )
349 }
350}
351
352pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
367 let Some(policy) = crate::orchestration::current_execution_policy() else {
368 return Ok(());
369 };
370 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
371 return Ok(());
372 }
373 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
384 return Ok(());
385 }
386 let candidate = normalize_for_policy(path);
387 let roots = normalized_workspace_roots(&policy);
388 if roots.iter().any(|root| path_is_within(&candidate, root)) {
389 return Ok(());
390 }
391 let read_only_roots = normalized_read_only_roots(&policy);
392 let within_read_only = read_only_roots
393 .iter()
394 .any(|root| path_is_within(&candidate, root));
395 if within_read_only && access == FsAccess::Read {
396 return Ok(());
397 }
398 Err(SandboxViolation {
399 attempted: candidate,
400 roots,
401 access,
402 read_only: within_read_only,
403 })
404}
405
406pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
407 check_fs_path_scope(path, access)
408 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
409}
410
411pub(crate) fn atomic_write_scoped_at_open(
412 builtin: &str,
413 path: &Path,
414 contents: &[u8],
415) -> io::Result<()> {
416 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
417 return atomic_write_unscoped(path, contents);
418 };
419 atomic_write_scoped_target(&target, contents)
420}
421
422pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
423 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
424 return append_unscoped(path, contents);
425 };
426 append_scoped_target(&target, contents)
427}
428
429pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
430 let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
431 return std::fs::copy(src, dst);
432 };
433 copy_scoped_target(src, &target)
434}
435
436pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
437 let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
438 return std::fs::rename(src, dst);
439 };
440 let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
441 io::Error::new(
442 io::ErrorKind::PermissionDenied,
443 format!(
444 "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
445 dst.display()
446 ),
447 )
448 })?;
449 rename_scoped_targets(&src_target, &dst_target)
450}
451
452pub(crate) fn create_dir_scoped_at_open(
453 builtin: &str,
454 path: &Path,
455 recursive: bool,
456) -> io::Result<()> {
457 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
458 return if recursive {
459 std::fs::create_dir_all(path)
460 } else {
461 std::fs::create_dir(path)
462 };
463 };
464 if recursive {
465 create_dir_all_scoped_target(&target)
466 } else {
467 create_dir_scoped_target(&target)
468 }
469}
470
471#[derive(Clone, Debug)]
472struct ScopedMutationTarget {
473 root: PathBuf,
474 relative: PathBuf,
475}
476
477fn scoped_mutation_target(
478 builtin: &str,
479 path: &Path,
480 access: FsAccess,
481) -> io::Result<Option<ScopedMutationTarget>> {
482 let Some(policy) = crate::orchestration::current_execution_policy() else {
483 return Ok(None);
484 };
485 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
486 return Ok(None);
487 }
488 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
489 return Ok(None);
490 }
491 check_fs_path_scope(path, access).map_err(|violation| {
492 io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
493 })?;
494 let candidate = normalize_for_policy(path);
495 let roots = normalized_workspace_roots(&policy);
496 let Some(root) = roots
497 .into_iter()
498 .find(|root| path_is_within(&candidate, root))
499 else {
500 return Err(io::Error::new(
501 io::ErrorKind::PermissionDenied,
502 format!(
503 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
504 access.verb(),
505 candidate.display()
506 ),
507 ));
508 };
509 let relative = candidate.strip_prefix(&root).map_err(|_| {
510 io::Error::new(
511 io::ErrorKind::PermissionDenied,
512 format!(
513 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
514 access.verb(),
515 candidate.display(),
516 root.display()
517 ),
518 )
519 })?;
520 if relative.as_os_str().is_empty() {
521 return Err(io::Error::new(
522 io::ErrorKind::InvalidInput,
523 format!(
524 "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
525 access.verb(),
526 root.display()
527 ),
528 ));
529 }
530 Ok(Some(ScopedMutationTarget {
531 root,
532 relative: relative.to_path_buf(),
533 }))
534}
535
536fn atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
537 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
538 let dir = parent.unwrap_or_else(|| Path::new("."));
539 if let Some(parent) = parent {
544 std::fs::create_dir_all(parent)?;
545 }
546 let tmp_path = dir.join(scoped_tmp_name(path));
547 let write_result = (|| -> io::Result<()> {
548 let mut file = std::fs::File::create(&tmp_path)?;
549 file.write_all(contents)?;
550 file.flush()?;
551 file.sync_all()?;
552 Ok(())
553 })();
554 if let Err(err) = write_result {
555 let _ = std::fs::remove_file(&tmp_path);
556 return Err(err);
557 }
558 if let Err(err) = std::fs::rename(&tmp_path, path) {
559 let _ = std::fs::remove_file(&tmp_path);
560 return Err(err);
561 }
562 Ok(())
563}
564
565fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
566 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
569 std::fs::create_dir_all(parent)?;
570 }
571 std::fs::OpenOptions::new()
572 .create(true)
573 .append(true)
574 .open(path)
575 .and_then(|mut file| file.write_all(contents))
576}
577
578fn scoped_tmp_name(path: &Path) -> String {
579 use std::sync::atomic::{AtomicU64, Ordering};
580 static COUNTER: AtomicU64 = AtomicU64::new(0);
581 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
582 let file_name = path
583 .file_name()
584 .map(|name| name.to_string_lossy().into_owned())
585 .unwrap_or_else(|| "file".to_string());
586 format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id())
587}
588
589#[cfg(unix)]
590fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
591 use std::os::fd::AsRawFd;
592
593 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
598 let tmp_name = scoped_tmp_name(Path::new(&file_name));
599 let mut file = openat_file(
600 parent.as_raw_fd(),
601 &tmp_name,
602 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
603 0o666,
604 )?;
605 let write_result = (|| -> io::Result<()> {
606 file.write_all(contents)?;
607 file.flush()?;
608 file.sync_all()?;
609 Ok(())
610 })();
611 if let Err(err) = write_result {
612 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
613 return Err(err);
614 }
615 if let Err(err) = renameat_name(
616 parent.as_raw_fd(),
617 &tmp_name,
618 parent.as_raw_fd(),
619 &file_name,
620 ) {
621 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
622 return Err(err);
623 }
624 sync_dir_fd(parent.as_raw_fd());
625 Ok(())
626}
627
628#[cfg(windows)]
629fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
630 let (parent, file_name) = win_scoped_parent(target, true)?;
631 let full = parent.join(&file_name);
632 win_reject_reparse_leaf(&full)?;
633 atomic_write_unscoped(&full, contents)
634}
635
636#[cfg(all(not(unix), not(windows)))]
637fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
638 let full = target.root.join(&target.relative);
639 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
640 std::fs::create_dir_all(parent)?;
641 }
642 atomic_write_unscoped(&full, contents)
643}
644
645#[cfg(unix)]
646fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
647 use std::os::fd::AsRawFd;
648
649 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
652 let mut file = openat_file(
653 parent.as_raw_fd(),
654 &file_name,
655 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
656 0o666,
657 )?;
658 file.write_all(contents)
659}
660
661#[cfg(windows)]
662fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
663 let (parent, file_name) = win_scoped_parent(target, true)?;
664 let full = parent.join(&file_name);
665 win_reject_reparse_leaf(&full)?;
666 append_unscoped(&full, contents)
667}
668
669#[cfg(all(not(unix), not(windows)))]
670fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
671 let full = target.root.join(&target.relative);
672 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
673 std::fs::create_dir_all(parent)?;
674 }
675 append_unscoped(&full, contents)
676}
677
678#[cfg(unix)]
679fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
680 use std::os::fd::AsRawFd;
681
682 let mut source = std::fs::File::open(src)?;
683 let source_metadata = source.metadata().ok();
684 let (parent, file_name) = open_parent_dir_scoped(target)?;
685 let mut destination = openat_file(
686 parent.as_raw_fd(),
687 &file_name,
688 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
689 0o666,
690 )?;
691 let copied = io::copy(&mut source, &mut destination)?;
692 destination.sync_all()?;
693 if let Some(metadata) = source_metadata {
694 let _ = destination.set_permissions(metadata.permissions());
695 }
696 sync_dir_fd(parent.as_raw_fd());
697 Ok(copied)
698}
699
700#[cfg(windows)]
701fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
702 let (parent, file_name) = win_scoped_parent(target, false)?;
706 let full = parent.join(&file_name);
707 win_reject_reparse_leaf(&full)?;
708 std::fs::copy(src, full)
709}
710
711#[cfg(all(not(unix), not(windows)))]
712fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
713 std::fs::copy(src, target.root.join(&target.relative))
714}
715
716#[cfg(unix)]
717fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
718 use std::os::fd::AsRawFd;
719
720 let (src_parent, src_name) = open_parent_dir_scoped(src)?;
721 let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
722 renameat_name(
723 src_parent.as_raw_fd(),
724 &src_name,
725 dst_parent.as_raw_fd(),
726 &dst_name,
727 )?;
728 sync_dir_fd(dst_parent.as_raw_fd());
729 Ok(())
730}
731
732#[cfg(windows)]
733fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
734 let (src_parent, src_name) = win_scoped_parent(src, false)?;
739 let (dst_parent, dst_name) = win_scoped_parent(dst, false)?;
740 std::fs::rename(src_parent.join(&src_name), dst_parent.join(&dst_name))
741}
742
743#[cfg(all(not(unix), not(windows)))]
744fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
745 std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
746}
747
748#[cfg(unix)]
749fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
750 use std::os::fd::AsRawFd;
751
752 let (parent, file_name) = open_parent_dir_scoped(target)?;
753 mkdirat_name(parent.as_raw_fd(), &file_name)?;
754 sync_dir_fd(parent.as_raw_fd());
755 Ok(())
756}
757
758#[cfg(windows)]
759fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
760 let (parent, file_name) = win_scoped_parent(target, false)?;
767 win_create_dir_raw(&parent.join(&file_name))
768}
769
770#[cfg(all(not(unix), not(windows)))]
771fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
772 std::fs::create_dir(target.root.join(&target.relative))
773}
774
775#[cfg(unix)]
776fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
777 use std::os::fd::AsRawFd;
778
779 let root = open_dir_absolute(&target.root)?;
780 let mut current = root;
781 for component in clean_relative_components(&target.relative)? {
782 match open_dir_at(current.as_raw_fd(), &component) {
783 Ok(next) => current = next,
784 Err(error) if error.kind() == io::ErrorKind::NotFound => {
785 mkdirat_name(current.as_raw_fd(), &component)?;
786 let next = open_dir_at(current.as_raw_fd(), &component)?;
787 current = next;
788 }
789 Err(error) => return Err(error),
790 }
791 }
792 Ok(())
793}
794
795#[cfg(windows)]
796fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
797 let components = win_clean_relative_components(&target.relative)?;
800 win_walk_components(&target.root, &components, true)?;
801 Ok(())
802}
803
804#[cfg(all(not(unix), not(windows)))]
805fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
806 std::fs::create_dir_all(target.root.join(&target.relative))
807}
808
809#[cfg(unix)]
810#[cfg(unix)]
828fn ensure_parent_dirs_scoped(
829 target: &ScopedMutationTarget,
830) -> io::Result<(std::os::fd::OwnedFd, String)> {
831 use std::os::fd::AsRawFd;
832
833 let mut components = clean_relative_components(&target.relative)?;
834 let file_name = components.pop().ok_or_else(|| {
835 io::Error::new(
836 io::ErrorKind::InvalidInput,
837 format!(
838 "sandbox scoped open requires a file name: {}",
839 target.relative.display()
840 ),
841 )
842 })?;
843 let root = open_dir_absolute(&target.root)?;
844 let mut current = root;
845 for component in components {
846 match open_dir_at(current.as_raw_fd(), &component) {
847 Ok(next) => current = next,
848 Err(error) if error.kind() == io::ErrorKind::NotFound => {
849 if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
850 if mkerr.kind() != io::ErrorKind::AlreadyExists {
851 return Err(mkerr);
852 }
853 }
854 current = open_dir_at(current.as_raw_fd(), &component)?;
855 }
856 Err(error) => return Err(error),
857 }
858 }
859 Ok((current, file_name))
860}
861
862#[cfg(unix)]
863fn open_parent_dir_scoped(
864 target: &ScopedMutationTarget,
865) -> io::Result<(std::os::fd::OwnedFd, String)> {
866 use std::os::fd::AsRawFd;
867
868 let mut components = clean_relative_components(&target.relative)?;
869 let file_name = components.pop().ok_or_else(|| {
870 io::Error::new(
871 io::ErrorKind::InvalidInput,
872 format!(
873 "sandbox scoped open requires a file name: {}",
874 target.relative.display()
875 ),
876 )
877 })?;
878 let root = open_dir_absolute(&target.root)?;
879 let mut current = root;
880 for component in components {
881 current = open_dir_at(current.as_raw_fd(), &component)?;
882 }
883 Ok((current, file_name))
884}
885
886#[cfg(unix)]
887fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
888 use std::os::unix::ffi::OsStrExt;
889
890 let mut out = Vec::new();
891 for component in path.components() {
892 match component {
893 Component::Normal(value) => {
894 let bytes = value.as_bytes();
895 if bytes.contains(&0) {
896 return Err(io::Error::new(
897 io::ErrorKind::InvalidInput,
898 format!("path component contains NUL: {}", path.display()),
899 ));
900 }
901 out.push(value.to_string_lossy().into_owned());
902 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
903 return Err(io::Error::new(
904 io::ErrorKind::InvalidInput,
905 format!(
906 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
907 path.display()
908 ),
909 ));
910 }
911 }
912 Component::CurDir => {}
913 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
914 return Err(io::Error::new(
915 io::ErrorKind::InvalidInput,
916 format!("sandbox scoped path must stay relative: {}", path.display()),
917 ));
918 }
919 }
920 }
921 Ok(out)
922}
923
924#[cfg(unix)]
925fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
926 use std::os::fd::{FromRawFd, OwnedFd};
927 use std::os::unix::ffi::OsStrExt;
928
929 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
930 io::Error::new(
931 io::ErrorKind::InvalidInput,
932 format!("path contains NUL: {}", path.display()),
933 )
934 })?;
935 let fd = unsafe {
936 libc::open(
937 c_path.as_ptr(),
938 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
939 )
940 };
941 if fd < 0 {
942 return Err(io::Error::last_os_error());
943 }
944 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
945}
946
947#[cfg(unix)]
948fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
949 use std::os::fd::{FromRawFd, OwnedFd};
950
951 let c_name = c_name(name)?;
952 let fd = unsafe {
953 libc::openat(
954 parent_fd,
955 c_name.as_ptr(),
956 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
957 )
958 };
959 if fd < 0 {
960 return Err(io::Error::last_os_error());
961 }
962 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
963}
964
965#[cfg(unix)]
966fn openat_file(
967 parent_fd: libc::c_int,
968 name: &str,
969 flags: libc::c_int,
970 mode: libc::mode_t,
971) -> io::Result<std::fs::File> {
972 use std::os::fd::FromRawFd;
973
974 let c_name = c_name(name)?;
975 let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
976 if fd < 0 {
977 return Err(io::Error::last_os_error());
978 }
979 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
980}
981
982#[cfg(unix)]
983fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
984 let c_name = c_name(name)?;
985 let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
986 if rc != 0 {
987 return Err(io::Error::last_os_error());
988 }
989 Ok(())
990}
991
992#[cfg(unix)]
993fn renameat_name(
994 old_parent_fd: libc::c_int,
995 old_name: &str,
996 new_parent_fd: libc::c_int,
997 new_name: &str,
998) -> io::Result<()> {
999 let old_name = c_name(old_name)?;
1000 let new_name = c_name(new_name)?;
1001 let rc = unsafe {
1002 libc::renameat(
1003 old_parent_fd,
1004 old_name.as_ptr(),
1005 new_parent_fd,
1006 new_name.as_ptr(),
1007 )
1008 };
1009 if rc != 0 {
1010 return Err(io::Error::last_os_error());
1011 }
1012 Ok(())
1013}
1014
1015#[cfg(unix)]
1016fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
1017 let c_name = c_name(name)?;
1018 let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
1019 if rc != 0 {
1020 return Err(io::Error::last_os_error());
1021 }
1022 Ok(())
1023}
1024
1025#[cfg(unix)]
1026fn sync_dir_fd(fd: libc::c_int) {
1027 let _ = unsafe { libc::fsync(fd) };
1028}
1029
1030#[cfg(unix)]
1031fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1032 std::ffi::CString::new(name).map_err(|_| {
1033 io::Error::new(
1034 io::ErrorKind::InvalidInput,
1035 format!("path component contains NUL: {name:?}"),
1036 )
1037 })
1038}
1039
1040#[cfg(windows)]
1067const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1068#[cfg(windows)]
1069const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1070
1071#[cfg(windows)]
1072fn win_wide(path: &Path) -> Vec<u16> {
1073 use std::os::windows::ffi::OsStrExt;
1074 path.as_os_str()
1075 .encode_wide()
1076 .chain(std::iter::once(0))
1077 .collect()
1078}
1079
1080#[cfg(windows)]
1087fn win_reject_reparse_point(path: &Path) -> io::Result<()> {
1088 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1089 use windows_sys::Win32::Storage::FileSystem::{
1090 CreateFileW, FileAttributeTagInfo, GetFileInformationByHandleEx,
1091 FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS,
1092 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1093 OPEN_EXISTING,
1094 };
1095
1096 const FILE_READ_ATTRIBUTES: u32 = 0x0080;
1098
1099 let wide = win_wide(path);
1100 let handle = unsafe {
1101 CreateFileW(
1102 wide.as_ptr(),
1103 FILE_READ_ATTRIBUTES,
1104 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1105 std::ptr::null(),
1106 OPEN_EXISTING,
1107 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
1108 std::ptr::null_mut(),
1109 )
1110 };
1111 if handle == INVALID_HANDLE_VALUE {
1112 return Err(io::Error::last_os_error());
1113 }
1114 let mut info = FILE_ATTRIBUTE_TAG_INFO::default();
1115 let ok = unsafe {
1116 GetFileInformationByHandleEx(
1117 handle,
1118 FileAttributeTagInfo,
1119 std::ptr::from_mut(&mut info).cast(),
1120 std::mem::size_of::<FILE_ATTRIBUTE_TAG_INFO>() as u32,
1121 )
1122 };
1123 let result = if ok == 0 {
1124 Err(io::Error::last_os_error())
1125 } else if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1126 && matches!(
1127 info.ReparseTag,
1128 IO_REPARSE_TAG_MOUNT_POINT | IO_REPARSE_TAG_SYMLINK
1129 )
1130 {
1131 Err(io::Error::new(
1132 io::ErrorKind::PermissionDenied,
1133 format!(
1134 "sandbox scoped walk refuses reparse-point (junction/symlink) component: {}",
1135 path.display()
1136 ),
1137 ))
1138 } else {
1139 Ok(())
1140 };
1141 unsafe {
1142 CloseHandle(handle);
1143 }
1144 result
1145}
1146
1147#[cfg(windows)]
1150fn win_reject_reparse_leaf(path: &Path) -> io::Result<()> {
1151 match win_reject_reparse_point(path) {
1152 Ok(()) => Ok(()),
1153 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1154 Err(err) => Err(err),
1155 }
1156}
1157
1158#[cfg(windows)]
1162fn win_create_dir_raw(path: &Path) -> io::Result<()> {
1163 use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
1164 let wide = win_wide(path);
1165 let ok = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) };
1166 if ok == 0 {
1167 return Err(io::Error::last_os_error());
1168 }
1169 Ok(())
1170}
1171
1172#[cfg(windows)]
1176fn win_clean_relative_components(path: &Path) -> io::Result<Vec<std::ffi::OsString>> {
1177 use std::os::windows::ffi::OsStrExt;
1178
1179 let mut out: Vec<std::ffi::OsString> = Vec::new();
1180 for component in path.components() {
1181 match component {
1182 Component::Normal(value) => {
1183 if value.encode_wide().any(|unit| unit == 0) {
1184 return Err(io::Error::new(
1185 io::ErrorKind::InvalidInput,
1186 format!("path component contains NUL: {}", path.display()),
1187 ));
1188 }
1189 out.push(value.to_os_string());
1190 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
1191 return Err(io::Error::new(
1192 io::ErrorKind::InvalidInput,
1193 format!(
1194 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
1195 path.display()
1196 ),
1197 ));
1198 }
1199 }
1200 Component::CurDir => {}
1201 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1202 return Err(io::Error::new(
1203 io::ErrorKind::InvalidInput,
1204 format!("sandbox scoped path must stay relative: {}", path.display()),
1205 ));
1206 }
1207 }
1208 }
1209 Ok(out)
1210}
1211
1212#[cfg(windows)]
1217fn win_walk_components(
1218 root: &Path,
1219 components: &[std::ffi::OsString],
1220 create: bool,
1221) -> io::Result<PathBuf> {
1222 win_reject_reparse_point(root)?;
1226 let mut current = root.to_path_buf();
1227 for component in components {
1228 current.push(component);
1229 match win_reject_reparse_point(¤t) {
1230 Ok(()) => {}
1231 Err(err) if create && err.kind() == io::ErrorKind::NotFound => {
1232 match win_create_dir_raw(¤t) {
1233 Ok(()) => {}
1234 Err(mkerr) if mkerr.kind() == io::ErrorKind::AlreadyExists => {}
1236 Err(mkerr) => return Err(mkerr),
1237 }
1238 win_reject_reparse_point(¤t)?;
1239 }
1240 Err(err) => return Err(err),
1241 }
1242 }
1243 Ok(current)
1244}
1245
1246#[cfg(windows)]
1250fn win_scoped_parent(
1251 target: &ScopedMutationTarget,
1252 create_parents: bool,
1253) -> io::Result<(PathBuf, std::ffi::OsString)> {
1254 let mut components = win_clean_relative_components(&target.relative)?;
1255 let file_name = components.pop().ok_or_else(|| {
1256 io::Error::new(
1257 io::ErrorKind::InvalidInput,
1258 format!(
1259 "sandbox scoped open requires a file name: {}",
1260 target.relative.display()
1261 ),
1262 )
1263 })?;
1264 let parent = win_walk_components(&target.root, &components, create_parents)?;
1265 Ok((parent, file_name))
1266}
1267
1268pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
1269 let Some(policy) = crate::orchestration::current_execution_policy() else {
1270 return Ok(());
1271 };
1272 enforce_process_cwd_for_policy(path, &policy)
1273}
1274
1275pub fn push_process_sandbox_scope(
1276 scope: ProcessSandboxScope,
1277) -> Result<ProcessSandboxScopeGuard, VmError> {
1278 let Some(mut policy) = crate::orchestration::current_execution_policy() else {
1279 return Ok(ProcessSandboxScopeGuard { pushed: false });
1280 };
1281 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1282 return Ok(ProcessSandboxScopeGuard { pushed: false });
1283 }
1284
1285 let requested_roots: Vec<PathBuf> = scope
1286 .workspace_roots
1287 .iter()
1288 .filter_map(|root| {
1289 let trimmed = root.trim();
1290 (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1291 })
1292 .collect();
1293 if requested_roots.is_empty() {
1294 return Ok(ProcessSandboxScopeGuard { pushed: false });
1295 }
1296
1297 if !policy.workspace_roots.is_empty() {
1298 let ceiling_roots = normalized_workspace_roots(&policy);
1299 if let Some(rejected) = requested_roots.iter().find(|root| {
1300 !ceiling_roots
1301 .iter()
1302 .any(|ceiling| path_is_within(root, ceiling))
1303 }) {
1304 return Err(sandbox_rejection(format!(
1305 "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1306 rejected.display(),
1307 ceiling_roots
1308 .iter()
1309 .map(|root| root.display().to_string())
1310 .collect::<Vec<_>>()
1311 .join(", ")
1312 )));
1313 }
1314 }
1315
1316 let mut merged_roots = if policy.workspace_roots.is_empty() {
1317 Vec::new()
1318 } else {
1319 normalized_workspace_roots(&policy)
1320 };
1321 for requested in requested_roots {
1322 if !merged_roots
1323 .iter()
1324 .any(|existing| path_is_within(&requested, existing))
1325 {
1326 merged_roots.push(requested);
1327 }
1328 }
1329 policy.workspace_roots = merged_roots
1330 .into_iter()
1331 .map(|root| root.display().to_string())
1332 .collect();
1333 crate::orchestration::push_execution_policy(policy);
1334 Ok(ProcessSandboxScopeGuard { pushed: true })
1335}
1336
1337fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1338 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1339 return Ok(());
1340 }
1341 let candidate = normalize_for_policy(path);
1342 let roots = normalized_workspace_roots(policy);
1343 if roots.iter().any(|root| path_is_within(&candidate, root)) {
1344 return Ok(());
1345 }
1346 Err(sandbox_rejection(format!(
1347 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1348 candidate.display(),
1349 roots
1350 .iter()
1351 .map(|root| root.display().to_string())
1352 .collect::<Vec<_>>()
1353 .join(", ")
1354 )))
1355}
1356
1357pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1358 let (policy, profile) = match active_sandbox_policy() {
1359 Some(value) => value,
1360 None => {
1361 let mut command = Command::new(program);
1362 command.args(args);
1363 return Ok(command);
1364 }
1365 };
1366 build_std_command::<ActiveBackend>(program, args, &policy, profile)
1367}
1368
1369pub fn tokio_command_for(
1370 program: &str,
1371 args: &[String],
1372) -> Result<tokio::process::Command, VmError> {
1373 let (policy, profile) = match active_sandbox_policy() {
1374 Some(value) => value,
1375 None => {
1376 let mut command = tokio::process::Command::new(program);
1377 command.args(args);
1378 return Ok(command);
1379 }
1380 };
1381 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1382}
1383
1384pub fn command_output(
1385 program: &str,
1386 args: &[String],
1387 config: &ProcessCommandConfig,
1388) -> Result<Output, VmError> {
1389 if let Some(intercepted) =
1394 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1395 {
1396 return intercepted.map_err(|message| {
1397 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1398 });
1399 }
1400
1401 let recording =
1402 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1403
1404 let output = match active_sandbox_policy() {
1405 Some((policy, profile)) => {
1406 let config = sandboxed_process_config(config, &policy)?;
1407 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1408 }
1409 None => {
1410 let mut command = Command::new(program);
1411 command.args(args);
1412 apply_process_config(&mut command, config);
1413 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1418 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1419 })?
1420 }
1421 };
1422 if let Some(error) = process_violation_error(&output) {
1423 return Err(error);
1424 }
1425 if let Some(span) = recording {
1426 span.finish(&output);
1427 }
1428 Ok(output)
1429}
1430
1431fn sandboxed_process_config(
1432 config: &ProcessCommandConfig,
1433 policy: &CapabilityPolicy,
1434) -> Result<ProcessCommandConfig, VmError> {
1435 let mut resolved = config.clone();
1436 if let Some(cwd) = resolved.cwd.as_ref() {
1437 enforce_process_cwd_for_policy(cwd, policy)?;
1438 } else {
1439 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1440 }
1441 neutralize_rustc_wrapper(&mut resolved.env);
1442 inject_workspace_tmpdir(&mut resolved.env, policy);
1443 Ok(resolved)
1444}
1445
1446fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1460 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1461 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1462 entry.1.clear();
1463 } else {
1464 env.push((key.to_string(), String::new()));
1465 }
1466 }
1467}
1468
1469pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1475
1476pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1480
1481pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1498 let root = normalized_workspace_roots(policy).into_iter().next()?;
1499 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1500 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1501 warn_once(
1502 "handler_sandbox_workspace_tmpdir",
1503 &format!(
1504 "could not create workspace-local temp dir '{}': {error}; \
1505 leaving the child's inherited temp dir in place",
1506 tmpdir.display()
1507 ),
1508 );
1509 return None;
1510 }
1511 let ignore = tmpdir.join(".gitignore");
1517 if !ignore.exists() {
1518 let _ = std::fs::write(
1519 &ignore,
1520 "# Created by the Harn sandbox; safe to delete.\n*\n",
1521 );
1522 }
1523 Some(tmpdir)
1524}
1525
1526pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1536 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1537 return;
1538 }
1539 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1540 return;
1541 };
1542 let tmpdir = tmpdir.display().to_string();
1543 for key in TMPDIR_ENV_KEYS {
1544 if env.iter().any(|(existing, _)| existing == key) {
1545 continue;
1547 }
1548 env.push((key.to_string(), tmpdir.clone()));
1549 }
1550}
1551
1552pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1567 let Some(policy) = crate::orchestration::current_execution_policy() else {
1568 return Vec::new();
1569 };
1570 let mut env = Vec::new();
1571 inject_workspace_tmpdir(&mut env, &policy);
1572 env
1573}
1574
1575pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1600 vec![
1601 ("LC_MESSAGES".to_string(), "C".to_string()),
1602 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1603 ]
1604}
1605
1606pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1611
1612fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1613 let roots = normalized_workspace_roots(policy);
1614 let current = std::env::current_dir().map_err(|error| {
1615 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1616 format!("process cwd resolution failed: {error}"),
1617 )))
1618 })?;
1619 let current = normalize_for_policy(¤t);
1620 if roots.iter().any(|root| path_is_within(¤t, root)) {
1621 return Ok(current);
1622 }
1623 roots.first().cloned().ok_or_else(|| {
1624 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1625 "process cwd resolution failed: no workspace root available",
1626 )))
1627 })
1628}
1629
1630fn build_std_command<B: SandboxBackend + ?Sized>(
1631 program: &str,
1632 args: &[String],
1633 policy: &CapabilityPolicy,
1634 profile: SandboxProfile,
1635) -> Result<Command, VmError> {
1636 let mut command = Command::new(program);
1637 command.args(args);
1638 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1639 PrepareOutcome::Direct => Ok(command),
1640 PrepareOutcome::WrappedExec { wrapper, args } => {
1641 let mut wrapped = Command::new(wrapper);
1642 wrapped.args(args);
1643 Ok(wrapped)
1644 }
1645 }
1646}
1647
1648fn build_tokio_command<B: SandboxBackend + ?Sized>(
1649 program: &str,
1650 args: &[String],
1651 policy: &CapabilityPolicy,
1652 profile: SandboxProfile,
1653) -> Result<tokio::process::Command, VmError> {
1654 let mut command = tokio::process::Command::new(program);
1655 command.args(args);
1656 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1657 PrepareOutcome::Direct => Ok(command),
1658 PrepareOutcome::WrappedExec { wrapper, args } => {
1659 let mut wrapped = tokio::process::Command::new(wrapper);
1660 wrapped.args(args);
1661 Ok(wrapped)
1662 }
1663 }
1664}
1665
1666pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1667 let policy = crate::orchestration::current_execution_policy()?;
1668 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1669 return None;
1670 }
1671 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1672 || !ActiveBackend::available()
1673 {
1674 return None;
1675 }
1676 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1677 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1678 if !output.status.success()
1679 && (stderr.contains("operation not permitted")
1680 || stderr.contains("permission denied")
1681 || stderr.contains("access is denied")
1682 || stdout.contains("operation not permitted"))
1683 {
1684 return Some(sandbox_rejection(sandbox_process_violation_message(
1685 format!(
1686 "sandbox violation: process was denied by the OS sandbox (status {})",
1687 output.status.code().unwrap_or(-1)
1688 ),
1689 )));
1690 }
1691 if sandbox_signal_status(output) {
1692 return Some(sandbox_rejection(sandbox_process_violation_message(
1693 format!(
1694 "sandbox violation: process was terminated by the OS sandbox (status {})",
1695 output.status
1696 ),
1697 )));
1698 }
1699 None
1700}
1701
1702pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1703 let policy = crate::orchestration::current_execution_policy()?;
1704 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1705 return None;
1706 }
1707 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1708 || !ActiveBackend::available()
1709 {
1710 return None;
1711 }
1712 let message = error.to_string().to_ascii_lowercase();
1713 if error.kind() == std::io::ErrorKind::PermissionDenied
1714 || message.contains("operation not permitted")
1715 || message.contains("permission denied")
1716 || message.contains("access is denied")
1717 {
1718 return Some(sandbox_rejection(sandbox_process_violation_message(
1719 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1720 )));
1721 }
1722 None
1723}
1724
1725#[cfg(unix)]
1726fn sandbox_signal_status(output: &std::process::Output) -> bool {
1727 use std::os::unix::process::ExitStatusExt;
1728
1729 matches!(
1730 output.status.signal(),
1731 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1732 )
1733}
1734
1735#[cfg(not(unix))]
1736fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1737 false
1738}
1739
1740pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1748 let policy = crate::orchestration::current_execution_policy()?;
1749 let profile = policy.sandbox_profile;
1750 match profile {
1751 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1752 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1753 if effective_fallback(profile) == SandboxFallback::Off {
1754 None
1755 } else {
1756 Some((policy, profile))
1757 }
1758 }
1759 }
1760}
1761
1762fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1763 if let Some(cwd) = config.cwd.as_ref() {
1764 command.current_dir(cwd);
1765 }
1766 command.envs(config.env.iter().map(|(key, value)| (key, value)));
1767 if config.stdin_null {
1768 command.stdin(Stdio::null());
1769 }
1770}
1771
1772fn spawn_error(error: std::io::Error) -> VmError {
1773 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1774 format!("process spawn failed: {error}"),
1775 )))
1776}
1777
1778pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1783 if matches!(profile, SandboxProfile::OsHardened) {
1784 return SandboxFallback::Enforce;
1785 }
1786 match std::env::var(HANDLER_SANDBOX_ENV)
1787 .unwrap_or_else(|_| "warn".to_string())
1788 .trim()
1789 .to_ascii_lowercase()
1790 .as_str()
1791 {
1792 "0" | "false" | "off" | "none" => SandboxFallback::Off,
1793 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1794 _ => SandboxFallback::Warn,
1795 }
1796}
1797
1798pub(crate) fn warn_once(key: &str, message: &str) {
1799 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1800 if inserted {
1801 crate::events::log_warn("handler_sandbox", message);
1802 }
1803}
1804
1805pub(crate) fn sandbox_rejection(message: String) -> VmError {
1806 VmError::CategorizedError {
1807 message,
1808 category: ErrorCategory::ToolRejected,
1809 }
1810}
1811
1812fn sandbox_process_violation_message(summary: String) -> String {
1813 format!(
1814 "{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"
1815 )
1816}
1817
1818#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1828pub(crate) fn unavailable(
1829 message: &str,
1830 profile: SandboxProfile,
1831) -> Result<PrepareOutcome, VmError> {
1832 match effective_fallback(profile) {
1833 SandboxFallback::Off | SandboxFallback::Warn => {
1834 warn_once("handler_sandbox_unavailable", message);
1835 Ok(PrepareOutcome::Direct)
1836 }
1837 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1838 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1839 ))),
1840 }
1841}
1842
1843fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1851 let session_id = crate::agent_sessions::current_session_id()?;
1852 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1853 let mut roots = vec![anchor.primary.clone()];
1854 for mounted in &anchor.additional_roots {
1855 if matches!(
1856 mounted.mount_mode,
1857 crate::workspace_anchor::MountMode::Extend
1858 ) {
1859 roots.push(mounted.path.clone());
1860 }
1861 }
1862 Some(roots)
1863}
1864
1865fn project_root_workspace_root() -> Option<PathBuf> {
1870 crate::stdlib::process::project_root_path().or_else(|| {
1871 std::env::var("HARN_PROJECT_ROOT")
1872 .ok()
1873 .map(|value| value.trim().to_string())
1874 .filter(|value| !value.is_empty())
1875 .map(PathBuf::from)
1876 })
1877}
1878
1879fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1880 if policy.workspace_roots.is_empty() {
1881 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1897 return anchor_roots
1898 .iter()
1899 .map(|root| normalize_for_policy(root))
1900 .collect();
1901 }
1902 if let Some(project_root) = project_root_workspace_root() {
1903 return vec![normalize_for_policy(&project_root)];
1904 }
1905 return vec![normalize_for_policy(
1906 &crate::stdlib::process::execution_root_path(),
1907 )];
1908 }
1909 policy
1910 .workspace_roots
1911 .iter()
1912 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1913 .collect()
1914}
1915
1916pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1917 normalized_workspace_roots(policy)
1918}
1919
1920fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1925 policy
1926 .read_only_roots
1927 .iter()
1928 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1929 .collect()
1930}
1931
1932#[cfg(any(
1933 target_os = "linux",
1934 target_os = "macos",
1935 target_os = "openbsd",
1936 target_os = "windows"
1937))]
1938pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1939 normalized_read_only_roots(policy)
1940}
1941
1942#[cfg(any(
1943 target_os = "linux",
1944 target_os = "macos",
1945 target_os = "openbsd",
1946 target_os = "windows"
1947))]
1948pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1949 normalized_process_roots(&policy.process_sandbox.read_roots)
1950}
1951
1952#[cfg(any(
1953 target_os = "linux",
1954 target_os = "macos",
1955 target_os = "openbsd",
1956 target_os = "windows"
1957))]
1958pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1959 normalized_process_roots(&policy.process_sandbox.write_roots)
1960}
1961
1962#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1963pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1964 policy.process_sandbox.effective_presets()
1965}
1966
1967#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1968pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1969 policy: &CapabilityPolicy,
1970) -> Vec<PathBuf> {
1971 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1972 return Vec::new();
1973 }
1974 let Some(home) = sandbox_user_home_dir() else {
1975 return Vec::new();
1976 };
1977 developer_toolchain_read_roots_for_home(&home)
1978}
1979
1980#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1981pub(crate) fn process_sandbox_package_manager_config_read_roots(
1982 policy: &CapabilityPolicy,
1983) -> Vec<PathBuf> {
1984 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1985 return Vec::new();
1986 }
1987 let Some(home) = sandbox_user_home_dir() else {
1988 return Vec::new();
1989 };
1990 package_manager_config_read_roots_for_home(&home)
1991}
1992
1993#[cfg(any(target_os = "linux", target_os = "macos"))]
2008pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
2009 policy: &CapabilityPolicy,
2010) -> Vec<PathBuf> {
2011 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2012 return Vec::new();
2013 }
2014 let Some(home) = sandbox_user_home_dir() else {
2015 return Vec::new();
2016 };
2017 developer_toolchain_cache_write_roots_for_home(&home)
2018}
2019
2020#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2021fn sandbox_user_home_dir() -> Option<PathBuf> {
2022 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
2025}
2026
2027#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2028pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2029 let mut roots: Vec<_> = [
2030 ".asdf",
2031 ".bun",
2032 ".cargo",
2033 ".fnm",
2034 ".juliaup",
2035 ".local/bin",
2036 ".local/share/mise",
2037 ".local/share/uv",
2038 ".nvm",
2039 ".pyenv",
2040 ".rbenv",
2041 ".rustup",
2042 ".sdkman",
2043 ".swiftly",
2044 ".volta",
2045 "go",
2046 ]
2047 .into_iter()
2048 .map(|entry| normalize_for_policy(&home.join(entry)))
2049 .collect();
2050 #[cfg(target_os = "windows")]
2051 roots.extend(
2052 [
2053 "AppData/Local/Programs/Python",
2054 "AppData/Local/uv",
2055 "AppData/Roaming/uv",
2056 "scoop",
2057 ]
2058 .into_iter()
2059 .map(|entry| normalize_for_policy(&home.join(entry))),
2060 );
2061 roots.sort_unstable();
2062 roots.dedup();
2063 roots
2064}
2065
2066#[cfg(any(target_os = "linux", target_os = "macos"))]
2071pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
2072 let mut roots: Vec<_> = [
2073 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
2079 .into_iter()
2080 .map(|entry| normalize_for_policy(&home.join(entry)))
2081 .collect();
2082 roots.sort_unstable();
2083 roots.dedup();
2084 roots
2085}
2086
2087#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2088pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2089 let mut roots: Vec<_> = [
2090 ".npmrc",
2091 ".gitconfig",
2092 ".netrc",
2093 ".yarnrc.yml",
2094 ".config",
2095 ".npm",
2096 ".cache",
2097 ".pip",
2098 ".pypirc",
2099 ".cargo/config",
2100 ".cargo/config.toml",
2101 ".cargo/credentials",
2102 ".cargo/credentials.toml",
2103 ".cargo/registry",
2104 ".cargo/git",
2105 ]
2106 .into_iter()
2107 .map(|entry| normalize_for_policy(&home.join(entry)))
2108 .collect();
2109 roots.sort_unstable();
2110 roots.dedup();
2111 roots
2112}
2113
2114#[cfg(any(
2115 target_os = "linux",
2116 target_os = "macos",
2117 target_os = "openbsd",
2118 target_os = "windows"
2119))]
2120fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
2121 roots
2122 .iter()
2123 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
2124 .collect()
2125}
2126
2127fn resolve_policy_path(path: &str) -> PathBuf {
2128 let candidate = PathBuf::from(path);
2129 if candidate.is_absolute() {
2130 candidate
2131 } else {
2132 crate::stdlib::process::execution_root_path().join(candidate)
2133 }
2134}
2135
2136fn normalize_for_policy(path: &Path) -> PathBuf {
2137 let absolute = if path.is_absolute() {
2138 path.to_path_buf()
2139 } else {
2140 crate::stdlib::process::execution_root_path().join(path)
2141 };
2142 let absolute = normalize_lexically(&absolute);
2143 if let Ok(canonical) = absolute.canonicalize() {
2144 return canonical;
2145 }
2146
2147 let mut existing = absolute.as_path();
2148 let mut suffix = Vec::new();
2149 while !existing.exists() {
2150 let Some(parent) = existing.parent() else {
2151 return normalize_lexically(&absolute);
2152 };
2153 if let Some(name) = existing.file_name() {
2154 suffix.push(name.to_os_string());
2155 }
2156 existing = parent;
2157 }
2158
2159 let mut normalized = existing
2160 .canonicalize()
2161 .unwrap_or_else(|_| normalize_lexically(existing));
2162 for component in suffix.iter().rev() {
2163 normalized.push(component);
2164 }
2165 normalize_lexically(&normalized)
2166}
2167
2168fn normalize_lexically(path: &Path) -> PathBuf {
2169 let mut normalized = PathBuf::new();
2170 for component in path.components() {
2171 match component {
2172 Component::CurDir => {}
2173 Component::ParentDir => {
2174 normalized.pop();
2175 }
2176 other => normalized.push(other.as_os_str()),
2177 }
2178 }
2179 normalized
2180}
2181
2182fn path_is_within(path: &Path, root: &Path) -> bool {
2183 path == root || path.starts_with(root)
2184}
2185
2186fn normalize_io_device_path(path: &Path) -> PathBuf {
2191 let absolute = if path.is_absolute() {
2192 path.to_path_buf()
2193 } else {
2194 crate::stdlib::process::execution_root_path().join(path)
2195 };
2196 normalize_lexically(&absolute)
2197}
2198
2199fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
2204 match access {
2205 FsAccess::Read => {
2206 matches!(
2207 path.to_str(),
2208 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
2209 ) || is_dev_fd_descriptor(path)
2210 }
2211 FsAccess::Write => {
2212 matches!(
2213 path.to_str(),
2214 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
2215 ) || is_dev_fd_descriptor(path)
2216 }
2217 FsAccess::Delete => false,
2218 }
2219}
2220
2221fn is_dev_fd_descriptor(path: &Path) -> bool {
2224 let Some(text) = path.to_str() else {
2225 return false;
2226 };
2227 let Some(fd) = text.strip_prefix("/dev/fd/") else {
2228 return false;
2229 };
2230 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
2231}
2232
2233#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
2234pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
2235 use crate::tool_annotations::SideEffectLevel;
2236 policy
2237 .side_effect_level
2238 .as_ref()
2239 .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
2240 .unwrap_or(true)
2241}
2242
2243#[cfg(any(
2244 target_os = "linux",
2245 target_os = "macos",
2246 target_os = "openbsd",
2247 target_os = "windows"
2248))]
2249pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
2250 policy.capabilities.is_empty()
2251 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
2252}
2253
2254#[cfg(any(
2255 target_os = "linux",
2256 target_os = "macos",
2257 target_os = "openbsd",
2258 target_os = "windows"
2259))]
2260pub(crate) fn policy_allows_capability(
2261 policy: &CapabilityPolicy,
2262 capability: &str,
2263 ops: &[&str],
2264) -> bool {
2265 policy
2266 .capabilities
2267 .get(capability)
2268 .map(|allowed| {
2269 ops.iter()
2270 .any(|op| allowed.iter().any(|candidate| candidate == op))
2271 })
2272 .unwrap_or(false)
2273}
2274
2275impl FsAccess {
2276 fn verb(self) -> &'static str {
2277 match self {
2278 FsAccess::Read => "read",
2279 FsAccess::Write => "write",
2280 FsAccess::Delete => "delete",
2281 }
2282 }
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287 use super::*;
2288 use crate::orchestration::{pop_execution_policy, push_execution_policy};
2289
2290 #[test]
2291 fn missing_create_path_normalizes_against_existing_parent() {
2292 let dir = tempfile::tempdir().unwrap();
2293 let nested = dir.path().join("a/../new.txt");
2294 let normalized = normalize_for_policy(&nested);
2295 assert_eq!(
2296 normalized,
2297 normalize_for_policy(&dir.path().join("new.txt"))
2298 );
2299 }
2300
2301 #[test]
2302 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
2303 let _env_lock = crate::runtime_paths::test_env_lock()
2307 .lock()
2308 .unwrap_or_else(|poisoned| poisoned.into_inner());
2309 std::env::remove_var("HARN_PROJECT_ROOT");
2310 let dir = tempfile::tempdir().unwrap();
2311 crate::stdlib::process::set_thread_execution_context(Some(
2312 crate::orchestration::RunExecutionRecord {
2313 cwd: Some(dir.path().to_string_lossy().into_owned()),
2314 project_root: None,
2315 source_dir: None,
2316 env: Default::default(),
2317 adapter: None,
2318 repo_path: None,
2319 worktree_path: None,
2320 branch: None,
2321 base_ref: None,
2322 cleanup: None,
2323 },
2324 ));
2325 push_execution_policy(CapabilityPolicy {
2326 sandbox_profile: SandboxProfile::Worktree,
2327 ..CapabilityPolicy::default()
2328 });
2329
2330 assert!(
2331 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2332 );
2333 let outside = tempfile::tempdir().unwrap();
2334 assert!(enforce_fs_path(
2335 "read_file",
2336 &outside.path().join("outside.txt"),
2337 FsAccess::Read
2338 )
2339 .is_err());
2340
2341 pop_execution_policy();
2342 crate::stdlib::process::set_thread_execution_context(None);
2343 }
2344
2345 #[test]
2349 fn empty_workspace_roots_prefer_execution_project_root_over_env_and_execution_root() {
2350 let _env_lock = crate::runtime_paths::test_env_lock()
2351 .lock()
2352 .unwrap_or_else(|poisoned| poisoned.into_inner());
2353 let project = tempfile::tempdir().unwrap();
2354 let env_project = tempfile::tempdir().unwrap();
2355 let execution_cwd = tempfile::tempdir().unwrap();
2356 std::env::set_var("HARN_PROJECT_ROOT", env_project.path());
2357 crate::stdlib::process::set_thread_execution_context(Some(
2358 crate::orchestration::RunExecutionRecord {
2359 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2360 project_root: Some(project.path().to_string_lossy().into_owned()),
2361 source_dir: None,
2362 env: Default::default(),
2363 adapter: None,
2364 repo_path: None,
2365 worktree_path: None,
2366 branch: None,
2367 base_ref: None,
2368 cleanup: None,
2369 },
2370 ));
2371 push_execution_policy(CapabilityPolicy {
2372 sandbox_profile: SandboxProfile::Worktree,
2373 ..CapabilityPolicy::default()
2374 });
2375
2376 assert!(
2377 enforce_fs_path(
2378 "write_file",
2379 &project.path().join("test/created.ts"),
2380 FsAccess::Write,
2381 )
2382 .is_ok(),
2383 "write into typed project_root must be allowed"
2384 );
2385 assert!(
2386 enforce_fs_path(
2387 "write_file",
2388 &env_project.path().join("escape.ts"),
2389 FsAccess::Write,
2390 )
2391 .is_err(),
2392 "legacy HARN_PROJECT_ROOT must not widen an explicit execution project_root"
2393 );
2394 assert!(
2395 enforce_fs_path(
2396 "write_file",
2397 &execution_cwd.path().join("escape.ts"),
2398 FsAccess::Write,
2399 )
2400 .is_err(),
2401 "execution cwd outside the project must be rejected"
2402 );
2403
2404 pop_execution_policy();
2405 crate::stdlib::process::set_thread_execution_context(None);
2406 std::env::remove_var("HARN_PROJECT_ROOT");
2407 }
2408
2409 #[test]
2419 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2420 let _env_lock = crate::runtime_paths::test_env_lock()
2421 .lock()
2422 .unwrap_or_else(|poisoned| poisoned.into_inner());
2423 let project = tempfile::tempdir().unwrap();
2424 let execution_cwd = tempfile::tempdir().unwrap();
2425 std::env::set_var("HARN_PROJECT_ROOT", project.path());
2426 crate::stdlib::process::set_thread_execution_context(Some(
2427 crate::orchestration::RunExecutionRecord {
2428 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2429 project_root: None,
2430 source_dir: None,
2431 env: Default::default(),
2432 adapter: None,
2433 repo_path: None,
2434 worktree_path: None,
2435 branch: None,
2436 base_ref: None,
2437 cleanup: None,
2438 },
2439 ));
2440 push_execution_policy(CapabilityPolicy {
2441 sandbox_profile: SandboxProfile::Worktree,
2442 ..CapabilityPolicy::default()
2443 });
2444
2445 assert!(
2448 enforce_fs_path(
2449 "write_file",
2450 &project.path().join("test/created.ts"),
2451 FsAccess::Write,
2452 )
2453 .is_ok(),
2454 "write into HARN_PROJECT_ROOT must be allowed"
2455 );
2456 assert!(
2460 enforce_fs_path(
2461 "write_file",
2462 &execution_cwd.path().join("escape.ts"),
2463 FsAccess::Write,
2464 )
2465 .is_err(),
2466 "write under the execution cwd (outside the project) must be rejected"
2467 );
2468
2469 pop_execution_policy();
2470 crate::stdlib::process::set_thread_execution_context(None);
2471 std::env::remove_var("HARN_PROJECT_ROOT");
2472 }
2473
2474 #[test]
2475 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2476 let dir = tempfile::tempdir().unwrap();
2477 crate::stdlib::process::set_thread_execution_context(Some(
2478 crate::orchestration::RunExecutionRecord {
2479 cwd: Some(dir.path().to_string_lossy().into_owned()),
2480 project_root: None,
2481 source_dir: None,
2482 env: Default::default(),
2483 adapter: None,
2484 repo_path: None,
2485 worktree_path: None,
2486 branch: None,
2487 base_ref: None,
2488 cleanup: None,
2489 },
2490 ));
2491 push_execution_policy(CapabilityPolicy {
2492 sandbox_profile: SandboxProfile::Worktree,
2493 ..CapabilityPolicy::default()
2494 });
2495
2496 assert!(enforce_process_cwd(dir.path()).is_ok());
2497 let outside = tempfile::tempdir().unwrap();
2498 assert!(enforce_process_cwd(outside.path()).is_err());
2499
2500 pop_execution_policy();
2501 crate::stdlib::process::set_thread_execution_context(None);
2502 }
2503
2504 #[test]
2505 fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2506 let _env_lock = crate::runtime_paths::test_env_lock()
2507 .lock()
2508 .unwrap_or_else(|poisoned| poisoned.into_inner());
2509 std::env::remove_var("HARN_PROJECT_ROOT");
2510 let execution_root = tempfile::tempdir().unwrap();
2511 let command_root = tempfile::tempdir().unwrap();
2512 crate::stdlib::process::set_thread_execution_context(Some(
2513 crate::orchestration::RunExecutionRecord {
2514 cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2515 project_root: None,
2516 source_dir: None,
2517 env: Default::default(),
2518 adapter: None,
2519 repo_path: None,
2520 worktree_path: None,
2521 branch: None,
2522 base_ref: None,
2523 cleanup: None,
2524 },
2525 ));
2526 push_execution_policy(CapabilityPolicy {
2527 sandbox_profile: SandboxProfile::Worktree,
2528 ..CapabilityPolicy::default()
2529 });
2530
2531 assert!(
2532 enforce_process_cwd(command_root.path()).is_err(),
2533 "before the scoped overlay the command temp root is outside the execution-root fallback",
2534 );
2535 {
2536 let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2537 workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2538 })
2539 .unwrap();
2540 assert!(
2541 enforce_process_cwd(command_root.path()).is_ok(),
2542 "scoped command root must be usable as the process cwd"
2543 );
2544 assert!(
2545 enforce_process_cwd(execution_root.path()).is_err(),
2546 "the scoped root must narrow the concrete spawn jail instead of widening it"
2547 );
2548 }
2549 assert!(
2550 enforce_process_cwd(command_root.path()).is_err(),
2551 "the scoped command root must pop after the command spawn"
2552 );
2553
2554 pop_execution_policy();
2555 crate::stdlib::process::set_thread_execution_context(None);
2556 }
2557
2558 #[test]
2559 fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2560 let workspace = tempfile::tempdir().unwrap();
2561 let inside = workspace.path().join("subdir");
2562 std::fs::create_dir(&inside).unwrap();
2563 let outside = tempfile::tempdir().unwrap();
2564 push_execution_policy(CapabilityPolicy {
2565 sandbox_profile: SandboxProfile::Worktree,
2566 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2567 ..CapabilityPolicy::default()
2568 });
2569
2570 assert!(
2571 push_process_sandbox_scope(ProcessSandboxScope {
2572 workspace_roots: vec![inside.to_string_lossy().into_owned()],
2573 })
2574 .is_ok(),
2575 "a command subroot inside the explicit workspace ceiling is allowed"
2576 );
2577 assert!(
2578 push_process_sandbox_scope(ProcessSandboxScope {
2579 workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2580 })
2581 .is_err(),
2582 "a command root outside the explicit workspace ceiling must be rejected"
2583 );
2584
2585 pop_execution_policy();
2586 }
2587
2588 #[cfg(unix)]
2589 #[test]
2590 fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2591 let workspace = tempfile::tempdir().unwrap();
2592 let outside = tempfile::tempdir().unwrap();
2593 let safe_parent = workspace.path().join("safe");
2594 std::fs::create_dir(&safe_parent).unwrap();
2595 let path = safe_parent.join("state.json");
2596 push_execution_policy(CapabilityPolicy {
2597 sandbox_profile: SandboxProfile::Worktree,
2598 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2599 ..CapabilityPolicy::default()
2600 });
2601 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2602 .unwrap()
2603 .expect("restricted policy yields scoped target");
2604
2605 std::fs::remove_dir(&safe_parent).unwrap();
2606 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2607 let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2608 pop_execution_policy();
2609
2610 assert!(
2611 !outside.path().join("state.json").exists(),
2612 "scoped write must not follow swapped parent symlink; error={error}"
2613 );
2614 }
2615
2616 #[cfg(unix)]
2617 #[test]
2618 fn scoped_write_creates_missing_parent_dirs() {
2619 let workspace = tempfile::tempdir().unwrap();
2625 let path = workspace.path().join("a/b/c/plan.json");
2626 push_execution_policy(CapabilityPolicy {
2627 sandbox_profile: SandboxProfile::Worktree,
2628 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2629 ..CapabilityPolicy::default()
2630 });
2631 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2632 .unwrap()
2633 .expect("restricted policy yields scoped target");
2634 let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2635 pop_execution_policy();
2636
2637 assert!(
2638 result.is_ok(),
2639 "write must create missing parents: {result:?}"
2640 );
2641 assert_eq!(
2642 std::fs::read(&path).unwrap(),
2643 b"{\"plan\":\"Redis-backed\"}".to_vec()
2644 );
2645 }
2646
2647 #[cfg(unix)]
2648 #[test]
2649 fn scoped_parent_autocreate_refuses_preexisting_symlink_component() {
2650 let workspace = tempfile::tempdir().unwrap();
2651 let outside = tempfile::tempdir().unwrap();
2652 std::fs::create_dir(workspace.path().join("a")).unwrap();
2653 std::os::unix::fs::symlink(outside.path(), workspace.path().join("a/b")).unwrap();
2654 let target = ScopedMutationTarget {
2655 root: workspace.path().to_path_buf(),
2656 relative: PathBuf::from("a/b/c/plan.json"),
2657 };
2658
2659 let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2660
2661 assert!(
2662 !outside.path().join("c/plan.json").exists(),
2663 "parent creation must not follow a symlinked component; error={error}"
2664 );
2665 assert!(
2666 !workspace.path().join("a/b/c").exists(),
2667 "symlinked components must not be treated as satisfied parents"
2668 );
2669 }
2670
2671 #[test]
2672 fn scoped_read_check_does_not_create_missing_parent_dirs() {
2673 let workspace = tempfile::tempdir().unwrap();
2674 let path = workspace.path().join("a/b/c/plan.json");
2675 push_execution_policy(CapabilityPolicy {
2676 sandbox_profile: SandboxProfile::Worktree,
2677 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2678 ..CapabilityPolicy::default()
2679 });
2680 let result = enforce_fs_path("read_file", &path, FsAccess::Read);
2681 pop_execution_policy();
2682
2683 assert!(
2684 result.is_ok(),
2685 "read path inside workspace should be in scope"
2686 );
2687 assert!(
2688 !workspace.path().join("a").exists(),
2689 "read/list/stat/delete scope checks must not create ancestors"
2690 );
2691 }
2692
2693 #[cfg(unix)]
2694 #[test]
2695 fn scoped_paths_refuse_excessive_component_depth() {
2696 let mut relative = PathBuf::new();
2697 for index in 0..=MAX_SCOPED_PATH_COMPONENTS {
2698 relative.push(format!("d{index}"));
2699 }
2700
2701 let error = clean_relative_components(&relative).unwrap_err();
2702
2703 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2704 assert!(
2705 error
2706 .to_string()
2707 .contains("sandbox scoped path exceeds 256 components"),
2708 "unexpected error: {error}"
2709 );
2710 }
2711
2712 #[cfg(windows)]
2719 #[test]
2720 fn scoped_walk_refuses_junction_intermediate_component() {
2721 let workspace = tempfile::tempdir().unwrap();
2722 let outside = tempfile::tempdir().unwrap();
2723 std::fs::create_dir(workspace.path().join("a")).unwrap();
2724 let link = workspace.path().join("a").join("b");
2725 let status = std::process::Command::new("cmd")
2726 .arg("/C")
2727 .arg("mklink")
2728 .arg("/J")
2729 .arg(&link)
2730 .arg(outside.path())
2731 .status()
2732 .expect("spawn mklink /J");
2733 assert!(status.success(), "mklink /J failed to plant a junction");
2734
2735 let target = ScopedMutationTarget {
2736 root: workspace.path().to_path_buf(),
2737 relative: PathBuf::from("a/b/c/plan.json"),
2738 };
2739 let error = win_scoped_parent(&target, true).unwrap_err();
2740
2741 assert_eq!(
2742 error.kind(),
2743 io::ErrorKind::PermissionDenied,
2744 "junction intermediate must be refused, got {error}"
2745 );
2746 assert!(
2747 !outside.path().join("c").exists(),
2748 "walk must not create through a junction; error={error}"
2749 );
2750 }
2751
2752 #[test]
2781 fn scoped_walk_forbids_raw_path_filesystem_calls() {
2782 let src = include_str!("mod.rs");
2783 let production = &src[..src.find("mod tests {").expect("test module marker present")];
2787
2788 fn fn_body<'a>(src: &'a str, sig: &str) -> &'a str {
2792 let start = src
2793 .find(sig)
2794 .unwrap_or_else(|| panic!("scoped-walk fn not found: {sig}"));
2795 let open = start
2796 + src[start..]
2797 .find('{')
2798 .unwrap_or_else(|| panic!("no body brace for {sig}"));
2799 let bytes = src.as_bytes();
2800 let mut depth = 0usize;
2801 for (offset, byte) in bytes[open..].iter().enumerate() {
2802 match byte {
2803 b'{' => depth += 1,
2804 b'}' => {
2805 depth -= 1;
2806 if depth == 0 {
2807 return &src[open..=open + offset];
2808 }
2809 }
2810 _ => {}
2811 }
2812 }
2813 panic!("unbalanced braces scanning {sig}");
2814 }
2815
2816 const FORBIDDEN: [&str; 10] = [
2824 "create_dir_all(",
2825 "create_dir(",
2826 "File::create(",
2827 "OpenOptions",
2828 "canonicalize(",
2829 "rename(",
2830 "remove_dir_all(",
2831 "std::fs::write(",
2832 "std::fs::copy(",
2833 "libc::open(", ];
2835 for sig in [
2836 "fn ensure_parent_dirs_scoped(",
2838 "fn open_parent_dir_scoped(",
2839 "fn create_dir_all_scoped_target(",
2840 "fn create_dir_scoped_target(",
2841 "fn atomic_write_scoped_target(",
2844 "fn append_scoped_target(",
2845 "fn copy_scoped_target(",
2846 "fn rename_scoped_targets(",
2847 ] {
2848 let body = fn_body(production, sig);
2849 for needle in FORBIDDEN {
2850 assert!(
2851 !body.contains(needle),
2852 "{sig} must not use raw `{needle}`; stay on the fd-carried *at path"
2853 );
2854 }
2855 }
2856
2857 for (idx, _) in production.match_indices("openat_file(") {
2861 if production[..idx].ends_with("fn ") {
2862 continue;
2863 }
2864 let window = &production[idx..(idx + 300).min(production.len())];
2865 assert!(
2866 window.contains("O_NOFOLLOW"),
2867 "openat_file call site near byte {idx} must pass O_NOFOLLOW"
2868 );
2869 }
2870 for sig in ["fn open_dir_at(", "fn open_dir_absolute("] {
2872 assert!(
2873 fn_body(production, sig).contains("O_NOFOLLOW"),
2874 "{sig} must open directories with O_NOFOLLOW"
2875 );
2876 }
2877
2878 assert!(
2881 fn_body(production, "fn win_walk_components(").contains("win_reject_reparse_point"),
2882 "the Windows scoped walk must reject reparse-point components"
2883 );
2884 }
2885
2886 #[cfg(unix)]
2887 #[test]
2888 fn scoped_append_creates_missing_parent_dirs() {
2889 let workspace = tempfile::tempdir().unwrap();
2890 let path = workspace.path().join("logs/deep/qa.jsonl");
2891 push_execution_policy(CapabilityPolicy {
2892 sandbox_profile: SandboxProfile::Worktree,
2893 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2894 ..CapabilityPolicy::default()
2895 });
2896 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2897 .unwrap()
2898 .expect("restricted policy yields scoped target");
2899 append_scoped_target(&target, b"line1\n").unwrap();
2900 append_scoped_target(&target, b"line2\n").unwrap();
2901 pop_execution_policy();
2902
2903 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2904 }
2905
2906 #[cfg(unix)]
2913 #[test]
2914 fn scoped_parent_autocreate_tolerates_concurrent_creators() {
2915 let workspace = tempfile::tempdir().unwrap();
2916 let root = workspace.path().to_path_buf();
2917 let target = ScopedMutationTarget {
2918 root: root.clone(),
2919 relative: PathBuf::from("race/deep/nested/tree/plan.json"),
2920 };
2921
2922 let threads: Vec<_> = (0..4)
2923 .map(|_| {
2924 let target = target.clone();
2925 std::thread::spawn(move || {
2926 for _ in 0..64 {
2927 ensure_parent_dirs_scoped(&target)
2930 .expect("concurrent ensure must tolerate EEXIST");
2931 }
2932 })
2933 })
2934 .collect();
2935 for thread in threads {
2936 thread.join().expect("worker thread panicked");
2937 }
2938
2939 assert!(
2940 root.join("race/deep/nested/tree").is_dir(),
2941 "the raced parent chain must exist inside the root"
2942 );
2943 let (_parent, leaf) = ensure_parent_dirs_scoped(&target).unwrap();
2945 assert_eq!(leaf, "plan.json");
2946 }
2947
2948 #[cfg(unix)]
2952 #[test]
2953 fn scoped_parent_autocreate_refuses_file_as_intermediate_component() {
2954 let workspace = tempfile::tempdir().unwrap();
2955 std::fs::create_dir(workspace.path().join("a")).unwrap();
2956 std::fs::write(workspace.path().join("a/b"), b"i am a file").unwrap();
2957 let target = ScopedMutationTarget {
2958 root: workspace.path().to_path_buf(),
2959 relative: PathBuf::from("a/b/c/plan.json"),
2960 };
2961
2962 let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2963
2964 assert_eq!(
2965 error.raw_os_error(),
2966 Some(libc::ENOTDIR),
2967 "a regular-file intermediate must fail with ENOTDIR, got {error:?}"
2968 );
2969 assert!(
2970 !workspace.path().join("a/b/c").exists(),
2971 "a non-directory intermediate must not be traversed or created through"
2972 );
2973 }
2974
2975 #[test]
2976 fn unscoped_write_creates_missing_parent_dirs() {
2977 let dir = tempfile::tempdir().unwrap();
2982 let path = dir.path().join("x/y/z/plan.json");
2983 atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2984 assert_eq!(
2985 std::fs::read(&path).unwrap(),
2986 b"{\"plan\":\"Redis-backed\"}".to_vec()
2987 );
2988 }
2989
2990 #[test]
2991 fn unscoped_append_creates_missing_parent_dirs() {
2992 let dir = tempfile::tempdir().unwrap();
2993 let path = dir.path().join("logs/deep/qa.jsonl");
2994 append_unscoped(&path, b"line1\n").unwrap();
2995 append_unscoped(&path, b"line2\n").unwrap();
2996 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2997 }
2998
2999 #[cfg(unix)]
3000 #[test]
3001 fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
3002 let workspace = tempfile::tempdir().unwrap();
3006 let outside = tempfile::tempdir().unwrap();
3007 std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
3008 let path = workspace.path().join("escape/sub/plan.json");
3009 push_execution_policy(CapabilityPolicy {
3010 sandbox_profile: SandboxProfile::Worktree,
3011 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3012 ..CapabilityPolicy::default()
3013 });
3014 let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
3019 Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
3020 Ok(None) | Err(_) => false,
3021 };
3022 pop_execution_policy();
3023
3024 assert!(
3025 !escaped,
3026 "must not write through a symlinked intermediate dir"
3027 );
3028 assert!(
3029 !outside.path().join("sub/plan.json").exists(),
3030 "scoped write escaped the workspace via a symlinked parent"
3031 );
3032 }
3033
3034 #[cfg(unix)]
3035 #[test]
3036 fn scoped_append_rejects_final_symlink_created_after_policy_match() {
3037 let workspace = tempfile::tempdir().unwrap();
3038 let outside = tempfile::tempdir().unwrap();
3039 let safe_parent = workspace.path().join("safe");
3040 std::fs::create_dir(&safe_parent).unwrap();
3041 let outside_file = outside.path().join("state.log");
3042 std::fs::write(&outside_file, b"outside").unwrap();
3043 let path = safe_parent.join("state.log");
3044 push_execution_policy(CapabilityPolicy {
3045 sandbox_profile: SandboxProfile::Worktree,
3046 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3047 ..CapabilityPolicy::default()
3048 });
3049 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
3050 .unwrap()
3051 .expect("restricted policy yields scoped target");
3052
3053 std::os::unix::fs::symlink(&outside_file, &path).unwrap();
3054 let error = append_scoped_target(&target, b"\nescape").unwrap_err();
3055 pop_execution_policy();
3056
3057 assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
3058 assert!(
3059 error.raw_os_error() == Some(libc::ELOOP)
3060 || error.kind() == io::ErrorKind::PermissionDenied
3061 || error.kind() == io::ErrorKind::Other,
3062 "expected symlink refusal, got {error:?}"
3063 );
3064 }
3065
3066 #[cfg(unix)]
3067 #[test]
3068 fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
3069 let workspace = tempfile::tempdir().unwrap();
3070 let outside = tempfile::tempdir().unwrap();
3071 let safe_parent = workspace.path().join("safe");
3072 std::fs::create_dir(&safe_parent).unwrap();
3073 let path = safe_parent.join("nested/deeper");
3074 push_execution_policy(CapabilityPolicy {
3075 sandbox_profile: SandboxProfile::Worktree,
3076 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3077 ..CapabilityPolicy::default()
3078 });
3079 let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
3080 .unwrap()
3081 .expect("restricted policy yields scoped target");
3082
3083 std::fs::remove_dir(&safe_parent).unwrap();
3084 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
3085 let error = create_dir_all_scoped_target(&target).unwrap_err();
3086 pop_execution_policy();
3087
3088 assert!(
3089 !outside.path().join("nested").exists(),
3090 "scoped mkdir must not follow swapped parent symlink; error={error}"
3091 );
3092 }
3093
3094 #[test]
3095 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
3096 let cwd = std::env::current_dir().unwrap();
3097 let policy = CapabilityPolicy {
3098 sandbox_profile: SandboxProfile::Worktree,
3099 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3100 ..CapabilityPolicy::default()
3101 };
3102
3103 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3104
3105 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
3106 }
3107
3108 #[test]
3109 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
3110 let workspace = tempfile::tempdir().unwrap();
3111 let policy = CapabilityPolicy {
3112 sandbox_profile: SandboxProfile::Worktree,
3113 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3114 ..CapabilityPolicy::default()
3115 };
3116
3117 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3118
3119 assert_eq!(
3120 resolved.cwd.unwrap(),
3121 normalize_for_policy(workspace.path())
3122 );
3123 }
3124
3125 #[test]
3126 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
3127 let workspace = tempfile::tempdir().unwrap();
3128 let outside = tempfile::tempdir().unwrap();
3129 let policy = CapabilityPolicy {
3130 sandbox_profile: SandboxProfile::Worktree,
3131 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3132 ..CapabilityPolicy::default()
3133 };
3134 let config = ProcessCommandConfig {
3135 cwd: Some(outside.path().to_path_buf()),
3136 ..ProcessCommandConfig::default()
3137 };
3138
3139 assert!(sandboxed_process_config(&config, &policy).is_err());
3140 }
3141
3142 #[test]
3143 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
3144 let cwd = std::env::current_dir().unwrap();
3145 let policy = CapabilityPolicy {
3146 sandbox_profile: SandboxProfile::Worktree,
3147 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3148 ..CapabilityPolicy::default()
3149 };
3150
3151 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3154 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3155 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3156 assert_eq!(
3157 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
3158 Some("")
3159 );
3160 }
3161
3162 #[test]
3163 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
3164 let mut env = vec![
3167 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
3168 ("PATH".to_string(), "/usr/bin".to_string()),
3169 ];
3170 neutralize_rustc_wrapper(&mut env);
3171 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3172 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3173 assert_eq!(
3174 collected
3175 .get("CARGO_BUILD_RUSTC_WRAPPER")
3176 .map(String::as_str),
3177 Some("")
3178 );
3179 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
3180 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
3182 }
3183
3184 #[test]
3185 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
3186 let workspace = tempfile::tempdir().unwrap();
3187 let policy = CapabilityPolicy {
3188 sandbox_profile: SandboxProfile::Worktree,
3189 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3190 ..CapabilityPolicy::default()
3191 };
3192
3193 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
3194
3195 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
3198 assert!(
3199 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
3200 "temp dir {tmpdir:?} must be inside the writable workspace root"
3201 );
3202 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
3203 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
3205 assert!(
3206 ignore.lines().any(|line| line.trim() == "*"),
3207 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
3208 );
3209 push_execution_policy(policy);
3212 assert!(
3213 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
3214 "writes under the workspace-local temp dir must be in sandbox scope"
3215 );
3216 pop_execution_policy();
3217 }
3218
3219 #[test]
3220 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
3221 let policy = CapabilityPolicy {
3224 sandbox_profile: SandboxProfile::Unrestricted,
3225 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
3226 ..CapabilityPolicy::default()
3227 };
3228 let mut env = Vec::new();
3229 inject_workspace_tmpdir(&mut env, &policy);
3230 assert!(
3231 env.is_empty(),
3232 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
3233 );
3234 }
3235
3236 #[test]
3237 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
3238 let workspace = tempfile::tempdir().unwrap();
3239 let policy = CapabilityPolicy {
3240 sandbox_profile: SandboxProfile::Worktree,
3241 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3242 ..CapabilityPolicy::default()
3243 };
3244 let mut env = Vec::new();
3245 inject_workspace_tmpdir(&mut env, &policy);
3246
3247 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
3248 let expected = workspace_local_tmpdir(&policy)
3249 .unwrap()
3250 .display()
3251 .to_string();
3252 for key in TMPDIR_ENV_KEYS {
3253 assert_eq!(
3254 collected.get(key).map(String::as_str),
3255 Some(expected.as_str()),
3256 "{key} must point at the workspace-local temp dir"
3257 );
3258 }
3259 }
3260
3261 #[test]
3262 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
3263 let env: std::collections::BTreeMap<_, _> =
3264 deterministic_message_locale_env().into_iter().collect();
3265 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
3268 assert_eq!(
3270 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
3271 Some("en")
3272 );
3273 assert!(
3276 !env.contains_key("LC_ALL"),
3277 "must not force LC_ALL (would clobber UTF-8 ctype)"
3278 );
3279 assert!(!env.contains_key("LC_CTYPE"));
3280 assert!(!env.contains_key("LANG"));
3281 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
3284 }
3285
3286 #[test]
3287 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
3288 let workspace = tempfile::tempdir().unwrap();
3289 let policy = CapabilityPolicy {
3290 sandbox_profile: SandboxProfile::Worktree,
3291 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3292 ..CapabilityPolicy::default()
3293 };
3294 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
3296 inject_workspace_tmpdir(&mut env, &policy);
3297
3298 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3299 assert_eq!(
3300 collected.get("TMPDIR").map(String::as_str),
3301 Some("/caller/explicit/tmp"),
3302 "an explicit caller TMPDIR must be preserved untouched"
3303 );
3304 let expected = workspace_local_tmpdir(&policy)
3305 .unwrap()
3306 .display()
3307 .to_string();
3308 assert_eq!(
3309 collected.get("TMP").map(String::as_str),
3310 Some(expected.as_str())
3311 );
3312 assert_eq!(
3313 collected.get("TEMP").map(String::as_str),
3314 Some(expected.as_str())
3315 );
3316 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
3318 }
3319
3320 #[test]
3321 fn sandboxed_process_config_injects_workspace_tmpdir() {
3322 let workspace = tempfile::tempdir().unwrap();
3323 let policy = CapabilityPolicy {
3324 sandbox_profile: SandboxProfile::Worktree,
3325 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3326 ..CapabilityPolicy::default()
3327 };
3328 let config = ProcessCommandConfig {
3329 cwd: Some(workspace.path().to_path_buf()),
3330 ..ProcessCommandConfig::default()
3331 };
3332 let resolved = sandboxed_process_config(&config, &policy).unwrap();
3333 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3334 let expected = workspace_local_tmpdir(&policy)
3335 .unwrap()
3336 .display()
3337 .to_string();
3338 assert_eq!(
3339 env.get("TMPDIR").map(String::as_str),
3340 Some(expected.as_str()),
3341 "the command_output path must inject a workspace-local TMPDIR"
3342 );
3343 }
3344
3345 #[test]
3346 fn read_only_root_outside_workspace_allows_read_denies_write() {
3347 let workspace = tempfile::tempdir().unwrap();
3352 let read_only = tempfile::tempdir().unwrap();
3353 push_execution_policy(CapabilityPolicy {
3354 sandbox_profile: SandboxProfile::Worktree,
3355 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3356 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
3357 ..CapabilityPolicy::default()
3358 });
3359
3360 let asset = read_only
3361 .path()
3362 .join("partials/agent-web-tools.harn.prompt");
3363 assert!(
3365 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
3366 "read under a configured read-only root must be allowed"
3367 );
3368
3369 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
3371 .expect_err("write under a read-only root must be denied");
3372 assert!(write_err.read_only, "write rejection must set read_only");
3373
3374 assert!(
3376 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
3377 "delete under a read-only root must be denied"
3378 );
3379
3380 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
3382
3383 let stranger = tempfile::tempdir().unwrap();
3386 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
3387 .expect_err("read outside all roots must be denied");
3388 assert!(
3389 !outside_err.read_only,
3390 "out-of-scope rejection must not be flagged read_only"
3391 );
3392
3393 pop_execution_policy();
3394 }
3395
3396 #[cfg(unix)]
3397 #[test]
3398 fn standard_io_device_files_allowed_under_restricted_profile() {
3399 let workspace = tempfile::tempdir().unwrap();
3404 push_execution_policy(CapabilityPolicy {
3405 sandbox_profile: SandboxProfile::Worktree,
3406 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3407 ..CapabilityPolicy::default()
3408 });
3409
3410 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
3411 assert!(
3412 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
3413 "write to standard device {device} must be allowed"
3414 );
3415 assert!(
3417 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
3418 "read of standard device {device} must be allowed"
3419 );
3420 }
3421 assert!(
3422 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
3423 "read of standard device /dev/stdin must be allowed"
3424 );
3425 assert!(
3426 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
3427 "write to /dev/stdin is not a standard output stream"
3428 );
3429 assert!(
3430 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
3431 "standard devices must not bypass delete scoping"
3432 );
3433 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
3435 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
3436
3437 let stranger = tempfile::tempdir().unwrap();
3439 assert!(
3440 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
3441 "a real out-of-root write must still be rejected"
3442 );
3443 assert!(
3445 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
3446 "/dev/sda must not be allowed by the standard-device allowlist"
3447 );
3448 assert!(
3449 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
3450 "non-numeric /dev/fd/<x> must not be allowed"
3451 );
3452
3453 pop_execution_policy();
3454 }
3455
3456 #[test]
3457 fn is_standard_io_device_matches_only_known_streams() {
3458 assert!(is_standard_io_device_for_access(
3459 Path::new("/dev/stdin"),
3460 FsAccess::Read
3461 ));
3462 assert!(!is_standard_io_device_for_access(
3463 Path::new("/dev/stdin"),
3464 FsAccess::Write
3465 ));
3466 assert!(is_standard_io_device_for_access(
3467 Path::new("/dev/stdout"),
3468 FsAccess::Write
3469 ));
3470 assert!(is_standard_io_device_for_access(
3471 Path::new("/dev/stderr"),
3472 FsAccess::Write
3473 ));
3474 assert!(is_standard_io_device_for_access(
3475 Path::new("/dev/null"),
3476 FsAccess::Write
3477 ));
3478 assert!(is_standard_io_device_for_access(
3479 Path::new("/dev/fd/0"),
3480 FsAccess::Read
3481 ));
3482 assert!(is_standard_io_device_for_access(
3483 Path::new("/dev/fd/12"),
3484 FsAccess::Write
3485 ));
3486 assert!(!is_standard_io_device_for_access(
3487 Path::new("/dev/null"),
3488 FsAccess::Delete
3489 ));
3490 assert!(!is_standard_io_device_for_access(
3491 Path::new("/dev/fd/"),
3492 FsAccess::Write
3493 ));
3494 assert!(!is_standard_io_device_for_access(
3495 Path::new("/dev/fd/1a"),
3496 FsAccess::Write
3497 ));
3498 assert!(!is_standard_io_device_for_access(
3499 Path::new("/dev/stdoutx"),
3500 FsAccess::Write
3501 ));
3502 assert!(!is_standard_io_device_for_access(
3503 Path::new("/dev/random"),
3504 FsAccess::Read
3505 ));
3506 assert!(!is_standard_io_device_for_access(
3507 Path::new("/tmp/dev/null"),
3508 FsAccess::Write
3509 ));
3510 }
3511
3512 #[test]
3513 fn path_within_root_accepts_root_and_children() {
3514 let root = Path::new("/tmp/harn-root");
3515 assert!(path_is_within(root, root));
3516 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
3517 assert!(!path_is_within(
3518 Path::new("/tmp/harn-root-other/file"),
3519 root
3520 ));
3521 }
3522
3523 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
3524 #[test]
3525 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
3526 let temp_home = tempfile::tempdir().expect("temp home");
3527 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
3528 let normalized_home = normalize_for_policy(temp_home.path());
3529
3530 for suffix in [
3531 Path::new(".cargo"),
3532 Path::new(".rustup"),
3533 Path::new(".pyenv"),
3534 Path::new(".nvm"),
3535 Path::new(".volta"),
3536 Path::new(".local/share/uv"),
3537 Path::new("go"),
3538 ] {
3539 assert!(
3540 roots.iter().any(|path| path.ends_with(suffix)),
3541 "expected a developer-toolchain grant for {}",
3542 suffix.display()
3543 );
3544 }
3545 assert!(
3546 roots.iter().all(|path| path.starts_with(&normalized_home)),
3547 "developer-toolchain roots must stay under HOME"
3548 );
3549 }
3550
3551 #[cfg(any(target_os = "linux", target_os = "macos"))]
3552 #[test]
3553 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
3554 let temp_home = tempfile::tempdir().expect("temp home");
3555 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
3556 let normalized_home = normalize_for_policy(temp_home.path());
3557
3558 for suffix in [
3559 Path::new(".gradle"),
3560 Path::new(".m2"),
3561 Path::new(".konan"),
3562 Path::new("Library/Caches/CocoaPods"),
3563 Path::new("Library/Developer/Xcode/DerivedData"),
3564 ] {
3565 assert!(
3566 roots.iter().any(|path| path.ends_with(suffix)),
3567 "expected a JVM/iOS toolchain cache grant for {}",
3568 suffix.display()
3569 );
3570 }
3571 assert!(
3572 roots.iter().all(|path| path.starts_with(&normalized_home)),
3573 "toolchain cache roots must stay under HOME"
3574 );
3575 }
3576
3577 #[cfg(any(target_os = "linux", target_os = "macos"))]
3578 #[test]
3579 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
3580 let mut policy = CapabilityPolicy {
3581 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
3582 ..CapabilityPolicy::default()
3583 };
3584 if sandbox_user_home_dir().is_some() {
3587 assert!(
3588 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3589 "default presets should render JVM/iOS cache roots"
3590 );
3591 }
3592 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
3594 assert!(
3595 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3596 "cache roots must be gated on the DeveloperToolchains preset"
3597 );
3598 }
3599
3600 #[test]
3601 fn os_hardened_profile_overrides_fallback_env() {
3602 assert_eq!(
3607 effective_fallback(SandboxProfile::OsHardened),
3608 SandboxFallback::Enforce
3609 );
3610 }
3611
3612 #[test]
3613 fn unrestricted_profile_skips_active_sandbox() {
3614 let policy = CapabilityPolicy {
3615 sandbox_profile: SandboxProfile::Unrestricted,
3616 workspace_roots: vec!["/tmp".to_string()],
3617 ..Default::default()
3618 };
3619 crate::orchestration::push_execution_policy(policy);
3620 let result = active_sandbox_policy();
3621 crate::orchestration::pop_execution_policy();
3622 assert!(
3623 result.is_none(),
3624 "Unrestricted profile must short-circuit sandbox dispatch"
3625 );
3626 }
3627
3628 #[test]
3629 fn worktree_profile_engages_active_sandbox() {
3630 let policy = CapabilityPolicy {
3631 sandbox_profile: SandboxProfile::Worktree,
3632 workspace_roots: vec!["/tmp".to_string()],
3633 ..Default::default()
3634 };
3635 crate::orchestration::push_execution_policy(policy);
3636 let result = active_sandbox_policy();
3637 crate::orchestration::pop_execution_policy();
3638 assert!(
3639 result.is_some(),
3640 "Worktree profile must keep sandbox dispatch active"
3641 );
3642 }
3643}