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_env_workspace_root() -> Option<PathBuf> {
1873 std::env::var("HARN_PROJECT_ROOT")
1874 .ok()
1875 .map(|value| value.trim().to_string())
1876 .filter(|value| !value.is_empty())
1877 .map(PathBuf::from)
1878}
1879
1880fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1881 if policy.workspace_roots.is_empty() {
1882 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_env_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 source_dir: None,
2315 env: Default::default(),
2316 adapter: None,
2317 repo_path: None,
2318 worktree_path: None,
2319 branch: None,
2320 base_ref: None,
2321 cleanup: None,
2322 },
2323 ));
2324 push_execution_policy(CapabilityPolicy {
2325 sandbox_profile: SandboxProfile::Worktree,
2326 ..CapabilityPolicy::default()
2327 });
2328
2329 assert!(
2330 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2331 );
2332 let outside = tempfile::tempdir().unwrap();
2333 assert!(enforce_fs_path(
2334 "read_file",
2335 &outside.path().join("outside.txt"),
2336 FsAccess::Read
2337 )
2338 .is_err());
2339
2340 pop_execution_policy();
2341 crate::stdlib::process::set_thread_execution_context(None);
2342 }
2343
2344 #[test]
2354 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2355 let _env_lock = crate::runtime_paths::test_env_lock()
2356 .lock()
2357 .unwrap_or_else(|poisoned| poisoned.into_inner());
2358 let project = tempfile::tempdir().unwrap();
2359 let execution_cwd = tempfile::tempdir().unwrap();
2360 std::env::set_var("HARN_PROJECT_ROOT", project.path());
2361 crate::stdlib::process::set_thread_execution_context(Some(
2362 crate::orchestration::RunExecutionRecord {
2363 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2364 source_dir: None,
2365 env: Default::default(),
2366 adapter: None,
2367 repo_path: None,
2368 worktree_path: None,
2369 branch: None,
2370 base_ref: None,
2371 cleanup: None,
2372 },
2373 ));
2374 push_execution_policy(CapabilityPolicy {
2375 sandbox_profile: SandboxProfile::Worktree,
2376 ..CapabilityPolicy::default()
2377 });
2378
2379 assert!(
2382 enforce_fs_path(
2383 "write_file",
2384 &project.path().join("test/created.ts"),
2385 FsAccess::Write,
2386 )
2387 .is_ok(),
2388 "write into HARN_PROJECT_ROOT must be allowed"
2389 );
2390 assert!(
2394 enforce_fs_path(
2395 "write_file",
2396 &execution_cwd.path().join("escape.ts"),
2397 FsAccess::Write,
2398 )
2399 .is_err(),
2400 "write under the execution cwd (outside the project) must be rejected"
2401 );
2402
2403 pop_execution_policy();
2404 crate::stdlib::process::set_thread_execution_context(None);
2405 std::env::remove_var("HARN_PROJECT_ROOT");
2406 }
2407
2408 #[test]
2409 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2410 let dir = tempfile::tempdir().unwrap();
2411 crate::stdlib::process::set_thread_execution_context(Some(
2412 crate::orchestration::RunExecutionRecord {
2413 cwd: Some(dir.path().to_string_lossy().into_owned()),
2414 source_dir: None,
2415 env: Default::default(),
2416 adapter: None,
2417 repo_path: None,
2418 worktree_path: None,
2419 branch: None,
2420 base_ref: None,
2421 cleanup: None,
2422 },
2423 ));
2424 push_execution_policy(CapabilityPolicy {
2425 sandbox_profile: SandboxProfile::Worktree,
2426 ..CapabilityPolicy::default()
2427 });
2428
2429 assert!(enforce_process_cwd(dir.path()).is_ok());
2430 let outside = tempfile::tempdir().unwrap();
2431 assert!(enforce_process_cwd(outside.path()).is_err());
2432
2433 pop_execution_policy();
2434 crate::stdlib::process::set_thread_execution_context(None);
2435 }
2436
2437 #[test]
2438 fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2439 let _env_lock = crate::runtime_paths::test_env_lock()
2440 .lock()
2441 .unwrap_or_else(|poisoned| poisoned.into_inner());
2442 std::env::remove_var("HARN_PROJECT_ROOT");
2443 let execution_root = tempfile::tempdir().unwrap();
2444 let command_root = tempfile::tempdir().unwrap();
2445 crate::stdlib::process::set_thread_execution_context(Some(
2446 crate::orchestration::RunExecutionRecord {
2447 cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2448 source_dir: None,
2449 env: Default::default(),
2450 adapter: None,
2451 repo_path: None,
2452 worktree_path: None,
2453 branch: None,
2454 base_ref: None,
2455 cleanup: None,
2456 },
2457 ));
2458 push_execution_policy(CapabilityPolicy {
2459 sandbox_profile: SandboxProfile::Worktree,
2460 ..CapabilityPolicy::default()
2461 });
2462
2463 assert!(
2464 enforce_process_cwd(command_root.path()).is_err(),
2465 "before the scoped overlay the command temp root is outside the execution-root fallback",
2466 );
2467 {
2468 let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2469 workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2470 })
2471 .unwrap();
2472 assert!(
2473 enforce_process_cwd(command_root.path()).is_ok(),
2474 "scoped command root must be usable as the process cwd"
2475 );
2476 assert!(
2477 enforce_process_cwd(execution_root.path()).is_err(),
2478 "the scoped root must narrow the concrete spawn jail instead of widening it"
2479 );
2480 }
2481 assert!(
2482 enforce_process_cwd(command_root.path()).is_err(),
2483 "the scoped command root must pop after the command spawn"
2484 );
2485
2486 pop_execution_policy();
2487 crate::stdlib::process::set_thread_execution_context(None);
2488 }
2489
2490 #[test]
2491 fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2492 let workspace = tempfile::tempdir().unwrap();
2493 let inside = workspace.path().join("subdir");
2494 std::fs::create_dir(&inside).unwrap();
2495 let outside = tempfile::tempdir().unwrap();
2496 push_execution_policy(CapabilityPolicy {
2497 sandbox_profile: SandboxProfile::Worktree,
2498 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2499 ..CapabilityPolicy::default()
2500 });
2501
2502 assert!(
2503 push_process_sandbox_scope(ProcessSandboxScope {
2504 workspace_roots: vec![inside.to_string_lossy().into_owned()],
2505 })
2506 .is_ok(),
2507 "a command subroot inside the explicit workspace ceiling is allowed"
2508 );
2509 assert!(
2510 push_process_sandbox_scope(ProcessSandboxScope {
2511 workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2512 })
2513 .is_err(),
2514 "a command root outside the explicit workspace ceiling must be rejected"
2515 );
2516
2517 pop_execution_policy();
2518 }
2519
2520 #[cfg(unix)]
2521 #[test]
2522 fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2523 let workspace = tempfile::tempdir().unwrap();
2524 let outside = tempfile::tempdir().unwrap();
2525 let safe_parent = workspace.path().join("safe");
2526 std::fs::create_dir(&safe_parent).unwrap();
2527 let path = safe_parent.join("state.json");
2528 push_execution_policy(CapabilityPolicy {
2529 sandbox_profile: SandboxProfile::Worktree,
2530 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2531 ..CapabilityPolicy::default()
2532 });
2533 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2534 .unwrap()
2535 .expect("restricted policy yields scoped target");
2536
2537 std::fs::remove_dir(&safe_parent).unwrap();
2538 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2539 let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2540 pop_execution_policy();
2541
2542 assert!(
2543 !outside.path().join("state.json").exists(),
2544 "scoped write must not follow swapped parent symlink; error={error}"
2545 );
2546 }
2547
2548 #[cfg(unix)]
2549 #[test]
2550 fn scoped_write_creates_missing_parent_dirs() {
2551 let workspace = tempfile::tempdir().unwrap();
2557 let path = workspace.path().join("a/b/c/plan.json");
2558 push_execution_policy(CapabilityPolicy {
2559 sandbox_profile: SandboxProfile::Worktree,
2560 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2561 ..CapabilityPolicy::default()
2562 });
2563 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2564 .unwrap()
2565 .expect("restricted policy yields scoped target");
2566 let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2567 pop_execution_policy();
2568
2569 assert!(
2570 result.is_ok(),
2571 "write must create missing parents: {result:?}"
2572 );
2573 assert_eq!(
2574 std::fs::read(&path).unwrap(),
2575 b"{\"plan\":\"Redis-backed\"}".to_vec()
2576 );
2577 }
2578
2579 #[cfg(unix)]
2580 #[test]
2581 fn scoped_parent_autocreate_refuses_preexisting_symlink_component() {
2582 let workspace = tempfile::tempdir().unwrap();
2583 let outside = tempfile::tempdir().unwrap();
2584 std::fs::create_dir(workspace.path().join("a")).unwrap();
2585 std::os::unix::fs::symlink(outside.path(), workspace.path().join("a/b")).unwrap();
2586 let target = ScopedMutationTarget {
2587 root: workspace.path().to_path_buf(),
2588 relative: PathBuf::from("a/b/c/plan.json"),
2589 };
2590
2591 let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2592
2593 assert!(
2594 !outside.path().join("c/plan.json").exists(),
2595 "parent creation must not follow a symlinked component; error={error}"
2596 );
2597 assert!(
2598 !workspace.path().join("a/b/c").exists(),
2599 "symlinked components must not be treated as satisfied parents"
2600 );
2601 }
2602
2603 #[test]
2604 fn scoped_read_check_does_not_create_missing_parent_dirs() {
2605 let workspace = tempfile::tempdir().unwrap();
2606 let path = workspace.path().join("a/b/c/plan.json");
2607 push_execution_policy(CapabilityPolicy {
2608 sandbox_profile: SandboxProfile::Worktree,
2609 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2610 ..CapabilityPolicy::default()
2611 });
2612 let result = enforce_fs_path("read_file", &path, FsAccess::Read);
2613 pop_execution_policy();
2614
2615 assert!(
2616 result.is_ok(),
2617 "read path inside workspace should be in scope"
2618 );
2619 assert!(
2620 !workspace.path().join("a").exists(),
2621 "read/list/stat/delete scope checks must not create ancestors"
2622 );
2623 }
2624
2625 #[cfg(unix)]
2626 #[test]
2627 fn scoped_paths_refuse_excessive_component_depth() {
2628 let mut relative = PathBuf::new();
2629 for index in 0..=MAX_SCOPED_PATH_COMPONENTS {
2630 relative.push(format!("d{index}"));
2631 }
2632
2633 let error = clean_relative_components(&relative).unwrap_err();
2634
2635 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2636 assert!(
2637 error
2638 .to_string()
2639 .contains("sandbox scoped path exceeds 256 components"),
2640 "unexpected error: {error}"
2641 );
2642 }
2643
2644 #[cfg(windows)]
2651 #[test]
2652 fn scoped_walk_refuses_junction_intermediate_component() {
2653 let workspace = tempfile::tempdir().unwrap();
2654 let outside = tempfile::tempdir().unwrap();
2655 std::fs::create_dir(workspace.path().join("a")).unwrap();
2656 let link = workspace.path().join("a").join("b");
2657 let status = std::process::Command::new("cmd")
2658 .arg("/C")
2659 .arg("mklink")
2660 .arg("/J")
2661 .arg(&link)
2662 .arg(outside.path())
2663 .status()
2664 .expect("spawn mklink /J");
2665 assert!(status.success(), "mklink /J failed to plant a junction");
2666
2667 let target = ScopedMutationTarget {
2668 root: workspace.path().to_path_buf(),
2669 relative: PathBuf::from("a/b/c/plan.json"),
2670 };
2671 let error = win_scoped_parent(&target, true).unwrap_err();
2672
2673 assert_eq!(
2674 error.kind(),
2675 io::ErrorKind::PermissionDenied,
2676 "junction intermediate must be refused, got {error}"
2677 );
2678 assert!(
2679 !outside.path().join("c").exists(),
2680 "walk must not create through a junction; error={error}"
2681 );
2682 }
2683
2684 #[test]
2713 fn scoped_walk_forbids_raw_path_filesystem_calls() {
2714 let src = include_str!("mod.rs");
2715 let production = &src[..src.find("mod tests {").expect("test module marker present")];
2719
2720 fn fn_body<'a>(src: &'a str, sig: &str) -> &'a str {
2724 let start = src
2725 .find(sig)
2726 .unwrap_or_else(|| panic!("scoped-walk fn not found: {sig}"));
2727 let open = start
2728 + src[start..]
2729 .find('{')
2730 .unwrap_or_else(|| panic!("no body brace for {sig}"));
2731 let bytes = src.as_bytes();
2732 let mut depth = 0usize;
2733 for (offset, byte) in bytes[open..].iter().enumerate() {
2734 match byte {
2735 b'{' => depth += 1,
2736 b'}' => {
2737 depth -= 1;
2738 if depth == 0 {
2739 return &src[open..=open + offset];
2740 }
2741 }
2742 _ => {}
2743 }
2744 }
2745 panic!("unbalanced braces scanning {sig}");
2746 }
2747
2748 const FORBIDDEN: [&str; 10] = [
2756 "create_dir_all(",
2757 "create_dir(",
2758 "File::create(",
2759 "OpenOptions",
2760 "canonicalize(",
2761 "rename(",
2762 "remove_dir_all(",
2763 "std::fs::write(",
2764 "std::fs::copy(",
2765 "libc::open(", ];
2767 for sig in [
2768 "fn ensure_parent_dirs_scoped(",
2770 "fn open_parent_dir_scoped(",
2771 "fn create_dir_all_scoped_target(",
2772 "fn create_dir_scoped_target(",
2773 "fn atomic_write_scoped_target(",
2776 "fn append_scoped_target(",
2777 "fn copy_scoped_target(",
2778 "fn rename_scoped_targets(",
2779 ] {
2780 let body = fn_body(production, sig);
2781 for needle in FORBIDDEN {
2782 assert!(
2783 !body.contains(needle),
2784 "{sig} must not use raw `{needle}`; stay on the fd-carried *at path"
2785 );
2786 }
2787 }
2788
2789 for (idx, _) in production.match_indices("openat_file(") {
2793 if production[..idx].ends_with("fn ") {
2794 continue;
2795 }
2796 let window = &production[idx..(idx + 300).min(production.len())];
2797 assert!(
2798 window.contains("O_NOFOLLOW"),
2799 "openat_file call site near byte {idx} must pass O_NOFOLLOW"
2800 );
2801 }
2802 for sig in ["fn open_dir_at(", "fn open_dir_absolute("] {
2804 assert!(
2805 fn_body(production, sig).contains("O_NOFOLLOW"),
2806 "{sig} must open directories with O_NOFOLLOW"
2807 );
2808 }
2809
2810 assert!(
2813 fn_body(production, "fn win_walk_components(").contains("win_reject_reparse_point"),
2814 "the Windows scoped walk must reject reparse-point components"
2815 );
2816 }
2817
2818 #[cfg(unix)]
2819 #[test]
2820 fn scoped_append_creates_missing_parent_dirs() {
2821 let workspace = tempfile::tempdir().unwrap();
2822 let path = workspace.path().join("logs/deep/qa.jsonl");
2823 push_execution_policy(CapabilityPolicy {
2824 sandbox_profile: SandboxProfile::Worktree,
2825 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2826 ..CapabilityPolicy::default()
2827 });
2828 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2829 .unwrap()
2830 .expect("restricted policy yields scoped target");
2831 append_scoped_target(&target, b"line1\n").unwrap();
2832 append_scoped_target(&target, b"line2\n").unwrap();
2833 pop_execution_policy();
2834
2835 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2836 }
2837
2838 #[cfg(unix)]
2845 #[test]
2846 fn scoped_parent_autocreate_tolerates_concurrent_creators() {
2847 let workspace = tempfile::tempdir().unwrap();
2848 let root = workspace.path().to_path_buf();
2849 let target = ScopedMutationTarget {
2850 root: root.clone(),
2851 relative: PathBuf::from("race/deep/nested/tree/plan.json"),
2852 };
2853
2854 let threads: Vec<_> = (0..4)
2855 .map(|_| {
2856 let target = target.clone();
2857 std::thread::spawn(move || {
2858 for _ in 0..64 {
2859 ensure_parent_dirs_scoped(&target)
2862 .expect("concurrent ensure must tolerate EEXIST");
2863 }
2864 })
2865 })
2866 .collect();
2867 for thread in threads {
2868 thread.join().expect("worker thread panicked");
2869 }
2870
2871 assert!(
2872 root.join("race/deep/nested/tree").is_dir(),
2873 "the raced parent chain must exist inside the root"
2874 );
2875 let (_parent, leaf) = ensure_parent_dirs_scoped(&target).unwrap();
2877 assert_eq!(leaf, "plan.json");
2878 }
2879
2880 #[cfg(unix)]
2884 #[test]
2885 fn scoped_parent_autocreate_refuses_file_as_intermediate_component() {
2886 let workspace = tempfile::tempdir().unwrap();
2887 std::fs::create_dir(workspace.path().join("a")).unwrap();
2888 std::fs::write(workspace.path().join("a/b"), b"i am a file").unwrap();
2889 let target = ScopedMutationTarget {
2890 root: workspace.path().to_path_buf(),
2891 relative: PathBuf::from("a/b/c/plan.json"),
2892 };
2893
2894 let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2895
2896 assert_eq!(
2897 error.raw_os_error(),
2898 Some(libc::ENOTDIR),
2899 "a regular-file intermediate must fail with ENOTDIR, got {error:?}"
2900 );
2901 assert!(
2902 !workspace.path().join("a/b/c").exists(),
2903 "a non-directory intermediate must not be traversed or created through"
2904 );
2905 }
2906
2907 #[test]
2908 fn unscoped_write_creates_missing_parent_dirs() {
2909 let dir = tempfile::tempdir().unwrap();
2914 let path = dir.path().join("x/y/z/plan.json");
2915 atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2916 assert_eq!(
2917 std::fs::read(&path).unwrap(),
2918 b"{\"plan\":\"Redis-backed\"}".to_vec()
2919 );
2920 }
2921
2922 #[test]
2923 fn unscoped_append_creates_missing_parent_dirs() {
2924 let dir = tempfile::tempdir().unwrap();
2925 let path = dir.path().join("logs/deep/qa.jsonl");
2926 append_unscoped(&path, b"line1\n").unwrap();
2927 append_unscoped(&path, b"line2\n").unwrap();
2928 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2929 }
2930
2931 #[cfg(unix)]
2932 #[test]
2933 fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
2934 let workspace = tempfile::tempdir().unwrap();
2938 let outside = tempfile::tempdir().unwrap();
2939 std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
2940 let path = workspace.path().join("escape/sub/plan.json");
2941 push_execution_policy(CapabilityPolicy {
2942 sandbox_profile: SandboxProfile::Worktree,
2943 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2944 ..CapabilityPolicy::default()
2945 });
2946 let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
2951 Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
2952 Ok(None) | Err(_) => false,
2953 };
2954 pop_execution_policy();
2955
2956 assert!(
2957 !escaped,
2958 "must not write through a symlinked intermediate dir"
2959 );
2960 assert!(
2961 !outside.path().join("sub/plan.json").exists(),
2962 "scoped write escaped the workspace via a symlinked parent"
2963 );
2964 }
2965
2966 #[cfg(unix)]
2967 #[test]
2968 fn scoped_append_rejects_final_symlink_created_after_policy_match() {
2969 let workspace = tempfile::tempdir().unwrap();
2970 let outside = tempfile::tempdir().unwrap();
2971 let safe_parent = workspace.path().join("safe");
2972 std::fs::create_dir(&safe_parent).unwrap();
2973 let outside_file = outside.path().join("state.log");
2974 std::fs::write(&outside_file, b"outside").unwrap();
2975 let path = safe_parent.join("state.log");
2976 push_execution_policy(CapabilityPolicy {
2977 sandbox_profile: SandboxProfile::Worktree,
2978 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2979 ..CapabilityPolicy::default()
2980 });
2981 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2982 .unwrap()
2983 .expect("restricted policy yields scoped target");
2984
2985 std::os::unix::fs::symlink(&outside_file, &path).unwrap();
2986 let error = append_scoped_target(&target, b"\nescape").unwrap_err();
2987 pop_execution_policy();
2988
2989 assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
2990 assert!(
2991 error.raw_os_error() == Some(libc::ELOOP)
2992 || error.kind() == io::ErrorKind::PermissionDenied
2993 || error.kind() == io::ErrorKind::Other,
2994 "expected symlink refusal, got {error:?}"
2995 );
2996 }
2997
2998 #[cfg(unix)]
2999 #[test]
3000 fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
3001 let workspace = tempfile::tempdir().unwrap();
3002 let outside = tempfile::tempdir().unwrap();
3003 let safe_parent = workspace.path().join("safe");
3004 std::fs::create_dir(&safe_parent).unwrap();
3005 let path = safe_parent.join("nested/deeper");
3006 push_execution_policy(CapabilityPolicy {
3007 sandbox_profile: SandboxProfile::Worktree,
3008 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3009 ..CapabilityPolicy::default()
3010 });
3011 let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
3012 .unwrap()
3013 .expect("restricted policy yields scoped target");
3014
3015 std::fs::remove_dir(&safe_parent).unwrap();
3016 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
3017 let error = create_dir_all_scoped_target(&target).unwrap_err();
3018 pop_execution_policy();
3019
3020 assert!(
3021 !outside.path().join("nested").exists(),
3022 "scoped mkdir must not follow swapped parent symlink; error={error}"
3023 );
3024 }
3025
3026 #[test]
3027 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
3028 let cwd = std::env::current_dir().unwrap();
3029 let policy = CapabilityPolicy {
3030 sandbox_profile: SandboxProfile::Worktree,
3031 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3032 ..CapabilityPolicy::default()
3033 };
3034
3035 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3036
3037 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
3038 }
3039
3040 #[test]
3041 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
3042 let workspace = tempfile::tempdir().unwrap();
3043 let policy = CapabilityPolicy {
3044 sandbox_profile: SandboxProfile::Worktree,
3045 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3046 ..CapabilityPolicy::default()
3047 };
3048
3049 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3050
3051 assert_eq!(
3052 resolved.cwd.unwrap(),
3053 normalize_for_policy(workspace.path())
3054 );
3055 }
3056
3057 #[test]
3058 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
3059 let workspace = tempfile::tempdir().unwrap();
3060 let outside = tempfile::tempdir().unwrap();
3061 let policy = CapabilityPolicy {
3062 sandbox_profile: SandboxProfile::Worktree,
3063 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3064 ..CapabilityPolicy::default()
3065 };
3066 let config = ProcessCommandConfig {
3067 cwd: Some(outside.path().to_path_buf()),
3068 ..ProcessCommandConfig::default()
3069 };
3070
3071 assert!(sandboxed_process_config(&config, &policy).is_err());
3072 }
3073
3074 #[test]
3075 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
3076 let cwd = std::env::current_dir().unwrap();
3077 let policy = CapabilityPolicy {
3078 sandbox_profile: SandboxProfile::Worktree,
3079 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3080 ..CapabilityPolicy::default()
3081 };
3082
3083 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3086 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3087 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3088 assert_eq!(
3089 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
3090 Some("")
3091 );
3092 }
3093
3094 #[test]
3095 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
3096 let mut env = vec![
3099 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
3100 ("PATH".to_string(), "/usr/bin".to_string()),
3101 ];
3102 neutralize_rustc_wrapper(&mut env);
3103 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3104 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3105 assert_eq!(
3106 collected
3107 .get("CARGO_BUILD_RUSTC_WRAPPER")
3108 .map(String::as_str),
3109 Some("")
3110 );
3111 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
3112 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
3114 }
3115
3116 #[test]
3117 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
3118 let workspace = tempfile::tempdir().unwrap();
3119 let policy = CapabilityPolicy {
3120 sandbox_profile: SandboxProfile::Worktree,
3121 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3122 ..CapabilityPolicy::default()
3123 };
3124
3125 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
3126
3127 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
3130 assert!(
3131 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
3132 "temp dir {tmpdir:?} must be inside the writable workspace root"
3133 );
3134 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
3135 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
3137 assert!(
3138 ignore.lines().any(|line| line.trim() == "*"),
3139 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
3140 );
3141 push_execution_policy(policy);
3144 assert!(
3145 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
3146 "writes under the workspace-local temp dir must be in sandbox scope"
3147 );
3148 pop_execution_policy();
3149 }
3150
3151 #[test]
3152 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
3153 let policy = CapabilityPolicy {
3156 sandbox_profile: SandboxProfile::Unrestricted,
3157 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
3158 ..CapabilityPolicy::default()
3159 };
3160 let mut env = Vec::new();
3161 inject_workspace_tmpdir(&mut env, &policy);
3162 assert!(
3163 env.is_empty(),
3164 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
3165 );
3166 }
3167
3168 #[test]
3169 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
3170 let workspace = tempfile::tempdir().unwrap();
3171 let policy = CapabilityPolicy {
3172 sandbox_profile: SandboxProfile::Worktree,
3173 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3174 ..CapabilityPolicy::default()
3175 };
3176 let mut env = Vec::new();
3177 inject_workspace_tmpdir(&mut env, &policy);
3178
3179 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
3180 let expected = workspace_local_tmpdir(&policy)
3181 .unwrap()
3182 .display()
3183 .to_string();
3184 for key in TMPDIR_ENV_KEYS {
3185 assert_eq!(
3186 collected.get(key).map(String::as_str),
3187 Some(expected.as_str()),
3188 "{key} must point at the workspace-local temp dir"
3189 );
3190 }
3191 }
3192
3193 #[test]
3194 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
3195 let env: std::collections::BTreeMap<_, _> =
3196 deterministic_message_locale_env().into_iter().collect();
3197 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
3200 assert_eq!(
3202 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
3203 Some("en")
3204 );
3205 assert!(
3208 !env.contains_key("LC_ALL"),
3209 "must not force LC_ALL (would clobber UTF-8 ctype)"
3210 );
3211 assert!(!env.contains_key("LC_CTYPE"));
3212 assert!(!env.contains_key("LANG"));
3213 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
3216 }
3217
3218 #[test]
3219 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
3220 let workspace = tempfile::tempdir().unwrap();
3221 let policy = CapabilityPolicy {
3222 sandbox_profile: SandboxProfile::Worktree,
3223 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3224 ..CapabilityPolicy::default()
3225 };
3226 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
3228 inject_workspace_tmpdir(&mut env, &policy);
3229
3230 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3231 assert_eq!(
3232 collected.get("TMPDIR").map(String::as_str),
3233 Some("/caller/explicit/tmp"),
3234 "an explicit caller TMPDIR must be preserved untouched"
3235 );
3236 let expected = workspace_local_tmpdir(&policy)
3237 .unwrap()
3238 .display()
3239 .to_string();
3240 assert_eq!(
3241 collected.get("TMP").map(String::as_str),
3242 Some(expected.as_str())
3243 );
3244 assert_eq!(
3245 collected.get("TEMP").map(String::as_str),
3246 Some(expected.as_str())
3247 );
3248 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
3250 }
3251
3252 #[test]
3253 fn sandboxed_process_config_injects_workspace_tmpdir() {
3254 let workspace = tempfile::tempdir().unwrap();
3255 let policy = CapabilityPolicy {
3256 sandbox_profile: SandboxProfile::Worktree,
3257 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3258 ..CapabilityPolicy::default()
3259 };
3260 let config = ProcessCommandConfig {
3261 cwd: Some(workspace.path().to_path_buf()),
3262 ..ProcessCommandConfig::default()
3263 };
3264 let resolved = sandboxed_process_config(&config, &policy).unwrap();
3265 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3266 let expected = workspace_local_tmpdir(&policy)
3267 .unwrap()
3268 .display()
3269 .to_string();
3270 assert_eq!(
3271 env.get("TMPDIR").map(String::as_str),
3272 Some(expected.as_str()),
3273 "the command_output path must inject a workspace-local TMPDIR"
3274 );
3275 }
3276
3277 #[test]
3278 fn read_only_root_outside_workspace_allows_read_denies_write() {
3279 let workspace = tempfile::tempdir().unwrap();
3284 let read_only = tempfile::tempdir().unwrap();
3285 push_execution_policy(CapabilityPolicy {
3286 sandbox_profile: SandboxProfile::Worktree,
3287 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3288 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
3289 ..CapabilityPolicy::default()
3290 });
3291
3292 let asset = read_only
3293 .path()
3294 .join("partials/agent-web-tools.harn.prompt");
3295 assert!(
3297 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
3298 "read under a configured read-only root must be allowed"
3299 );
3300
3301 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
3303 .expect_err("write under a read-only root must be denied");
3304 assert!(write_err.read_only, "write rejection must set read_only");
3305
3306 assert!(
3308 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
3309 "delete under a read-only root must be denied"
3310 );
3311
3312 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
3314
3315 let stranger = tempfile::tempdir().unwrap();
3318 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
3319 .expect_err("read outside all roots must be denied");
3320 assert!(
3321 !outside_err.read_only,
3322 "out-of-scope rejection must not be flagged read_only"
3323 );
3324
3325 pop_execution_policy();
3326 }
3327
3328 #[cfg(unix)]
3329 #[test]
3330 fn standard_io_device_files_allowed_under_restricted_profile() {
3331 let workspace = tempfile::tempdir().unwrap();
3336 push_execution_policy(CapabilityPolicy {
3337 sandbox_profile: SandboxProfile::Worktree,
3338 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3339 ..CapabilityPolicy::default()
3340 });
3341
3342 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
3343 assert!(
3344 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
3345 "write to standard device {device} must be allowed"
3346 );
3347 assert!(
3349 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
3350 "read of standard device {device} must be allowed"
3351 );
3352 }
3353 assert!(
3354 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
3355 "read of standard device /dev/stdin must be allowed"
3356 );
3357 assert!(
3358 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
3359 "write to /dev/stdin is not a standard output stream"
3360 );
3361 assert!(
3362 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
3363 "standard devices must not bypass delete scoping"
3364 );
3365 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
3367 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
3368
3369 let stranger = tempfile::tempdir().unwrap();
3371 assert!(
3372 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
3373 "a real out-of-root write must still be rejected"
3374 );
3375 assert!(
3377 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
3378 "/dev/sda must not be allowed by the standard-device allowlist"
3379 );
3380 assert!(
3381 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
3382 "non-numeric /dev/fd/<x> must not be allowed"
3383 );
3384
3385 pop_execution_policy();
3386 }
3387
3388 #[test]
3389 fn is_standard_io_device_matches_only_known_streams() {
3390 assert!(is_standard_io_device_for_access(
3391 Path::new("/dev/stdin"),
3392 FsAccess::Read
3393 ));
3394 assert!(!is_standard_io_device_for_access(
3395 Path::new("/dev/stdin"),
3396 FsAccess::Write
3397 ));
3398 assert!(is_standard_io_device_for_access(
3399 Path::new("/dev/stdout"),
3400 FsAccess::Write
3401 ));
3402 assert!(is_standard_io_device_for_access(
3403 Path::new("/dev/stderr"),
3404 FsAccess::Write
3405 ));
3406 assert!(is_standard_io_device_for_access(
3407 Path::new("/dev/null"),
3408 FsAccess::Write
3409 ));
3410 assert!(is_standard_io_device_for_access(
3411 Path::new("/dev/fd/0"),
3412 FsAccess::Read
3413 ));
3414 assert!(is_standard_io_device_for_access(
3415 Path::new("/dev/fd/12"),
3416 FsAccess::Write
3417 ));
3418 assert!(!is_standard_io_device_for_access(
3419 Path::new("/dev/null"),
3420 FsAccess::Delete
3421 ));
3422 assert!(!is_standard_io_device_for_access(
3423 Path::new("/dev/fd/"),
3424 FsAccess::Write
3425 ));
3426 assert!(!is_standard_io_device_for_access(
3427 Path::new("/dev/fd/1a"),
3428 FsAccess::Write
3429 ));
3430 assert!(!is_standard_io_device_for_access(
3431 Path::new("/dev/stdoutx"),
3432 FsAccess::Write
3433 ));
3434 assert!(!is_standard_io_device_for_access(
3435 Path::new("/dev/random"),
3436 FsAccess::Read
3437 ));
3438 assert!(!is_standard_io_device_for_access(
3439 Path::new("/tmp/dev/null"),
3440 FsAccess::Write
3441 ));
3442 }
3443
3444 #[test]
3445 fn path_within_root_accepts_root_and_children() {
3446 let root = Path::new("/tmp/harn-root");
3447 assert!(path_is_within(root, root));
3448 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
3449 assert!(!path_is_within(
3450 Path::new("/tmp/harn-root-other/file"),
3451 root
3452 ));
3453 }
3454
3455 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
3456 #[test]
3457 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
3458 let temp_home = tempfile::tempdir().expect("temp home");
3459 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
3460 let normalized_home = normalize_for_policy(temp_home.path());
3461
3462 for suffix in [
3463 Path::new(".cargo"),
3464 Path::new(".rustup"),
3465 Path::new(".pyenv"),
3466 Path::new(".nvm"),
3467 Path::new(".volta"),
3468 Path::new(".local/share/uv"),
3469 Path::new("go"),
3470 ] {
3471 assert!(
3472 roots.iter().any(|path| path.ends_with(suffix)),
3473 "expected a developer-toolchain grant for {}",
3474 suffix.display()
3475 );
3476 }
3477 assert!(
3478 roots.iter().all(|path| path.starts_with(&normalized_home)),
3479 "developer-toolchain roots must stay under HOME"
3480 );
3481 }
3482
3483 #[cfg(any(target_os = "linux", target_os = "macos"))]
3484 #[test]
3485 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
3486 let temp_home = tempfile::tempdir().expect("temp home");
3487 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
3488 let normalized_home = normalize_for_policy(temp_home.path());
3489
3490 for suffix in [
3491 Path::new(".gradle"),
3492 Path::new(".m2"),
3493 Path::new(".konan"),
3494 Path::new("Library/Caches/CocoaPods"),
3495 Path::new("Library/Developer/Xcode/DerivedData"),
3496 ] {
3497 assert!(
3498 roots.iter().any(|path| path.ends_with(suffix)),
3499 "expected a JVM/iOS toolchain cache grant for {}",
3500 suffix.display()
3501 );
3502 }
3503 assert!(
3504 roots.iter().all(|path| path.starts_with(&normalized_home)),
3505 "toolchain cache roots must stay under HOME"
3506 );
3507 }
3508
3509 #[cfg(any(target_os = "linux", target_os = "macos"))]
3510 #[test]
3511 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
3512 let mut policy = CapabilityPolicy {
3513 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
3514 ..CapabilityPolicy::default()
3515 };
3516 if sandbox_user_home_dir().is_some() {
3519 assert!(
3520 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3521 "default presets should render JVM/iOS cache roots"
3522 );
3523 }
3524 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
3526 assert!(
3527 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3528 "cache roots must be gated on the DeveloperToolchains preset"
3529 );
3530 }
3531
3532 #[test]
3533 fn os_hardened_profile_overrides_fallback_env() {
3534 assert_eq!(
3539 effective_fallback(SandboxProfile::OsHardened),
3540 SandboxFallback::Enforce
3541 );
3542 }
3543
3544 #[test]
3545 fn unrestricted_profile_skips_active_sandbox() {
3546 let policy = CapabilityPolicy {
3547 sandbox_profile: SandboxProfile::Unrestricted,
3548 workspace_roots: vec!["/tmp".to_string()],
3549 ..Default::default()
3550 };
3551 crate::orchestration::push_execution_policy(policy);
3552 let result = active_sandbox_policy();
3553 crate::orchestration::pop_execution_policy();
3554 assert!(
3555 result.is_none(),
3556 "Unrestricted profile must short-circuit sandbox dispatch"
3557 );
3558 }
3559
3560 #[test]
3561 fn worktree_profile_engages_active_sandbox() {
3562 let policy = CapabilityPolicy {
3563 sandbox_profile: SandboxProfile::Worktree,
3564 workspace_roots: vec!["/tmp".to_string()],
3565 ..Default::default()
3566 };
3567 crate::orchestration::push_execution_policy(policy);
3568 let result = active_sandbox_policy();
3569 crate::orchestration::pop_execution_policy();
3570 assert!(
3571 result.is_some(),
3572 "Worktree profile must keep sandbox dispatch active"
3573 );
3574 }
3575}