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(unix)]
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(not(unix))]
629fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
630 let full = target.root.join(&target.relative);
631 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
632 std::fs::create_dir_all(parent)?;
633 }
634 atomic_write_unscoped(&full, contents)
635}
636
637#[cfg(unix)]
638fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
639 use std::os::fd::AsRawFd;
640
641 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
644 let mut file = openat_file(
645 parent.as_raw_fd(),
646 &file_name,
647 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
648 0o666,
649 )?;
650 file.write_all(contents)
651}
652
653#[cfg(not(unix))]
654fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
655 let full = target.root.join(&target.relative);
656 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
657 std::fs::create_dir_all(parent)?;
658 }
659 append_unscoped(&full, contents)
660}
661
662#[cfg(unix)]
663fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
664 use std::os::fd::AsRawFd;
665
666 let mut source = std::fs::File::open(src)?;
667 let source_metadata = source.metadata().ok();
668 let (parent, file_name) = open_parent_dir_scoped(target)?;
669 let mut destination = openat_file(
670 parent.as_raw_fd(),
671 &file_name,
672 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
673 0o666,
674 )?;
675 let copied = io::copy(&mut source, &mut destination)?;
676 destination.sync_all()?;
677 if let Some(metadata) = source_metadata {
678 let _ = destination.set_permissions(metadata.permissions());
679 }
680 sync_dir_fd(parent.as_raw_fd());
681 Ok(copied)
682}
683
684#[cfg(not(unix))]
685fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
686 std::fs::copy(src, target.root.join(&target.relative))
687}
688
689#[cfg(unix)]
690fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
691 use std::os::fd::AsRawFd;
692
693 let (src_parent, src_name) = open_parent_dir_scoped(src)?;
694 let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
695 renameat_name(
696 src_parent.as_raw_fd(),
697 &src_name,
698 dst_parent.as_raw_fd(),
699 &dst_name,
700 )?;
701 sync_dir_fd(dst_parent.as_raw_fd());
702 Ok(())
703}
704
705#[cfg(not(unix))]
706fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
707 std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
708}
709
710#[cfg(unix)]
711fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
712 use std::os::fd::AsRawFd;
713
714 let (parent, file_name) = open_parent_dir_scoped(target)?;
715 mkdirat_name(parent.as_raw_fd(), &file_name)?;
716 sync_dir_fd(parent.as_raw_fd());
717 Ok(())
718}
719
720#[cfg(not(unix))]
721fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
722 std::fs::create_dir(target.root.join(&target.relative))
723}
724
725#[cfg(unix)]
726fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
727 use std::os::fd::AsRawFd;
728
729 let root = open_dir_absolute(&target.root)?;
730 let mut current = root;
731 for component in clean_relative_components(&target.relative)? {
732 match open_dir_at(current.as_raw_fd(), &component) {
733 Ok(next) => current = next,
734 Err(error) if error.kind() == io::ErrorKind::NotFound => {
735 mkdirat_name(current.as_raw_fd(), &component)?;
736 let next = open_dir_at(current.as_raw_fd(), &component)?;
737 current = next;
738 }
739 Err(error) => return Err(error),
740 }
741 }
742 Ok(())
743}
744
745#[cfg(not(unix))]
746fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
747 std::fs::create_dir_all(target.root.join(&target.relative))
748}
749
750#[cfg(unix)]
751#[cfg(unix)]
769fn ensure_parent_dirs_scoped(
770 target: &ScopedMutationTarget,
771) -> io::Result<(std::os::fd::OwnedFd, String)> {
772 use std::os::fd::AsRawFd;
773
774 let mut components = clean_relative_components(&target.relative)?;
775 let file_name = components.pop().ok_or_else(|| {
776 io::Error::new(
777 io::ErrorKind::InvalidInput,
778 format!(
779 "sandbox scoped open requires a file name: {}",
780 target.relative.display()
781 ),
782 )
783 })?;
784 let root = open_dir_absolute(&target.root)?;
785 let mut current = root;
786 for component in components {
787 match open_dir_at(current.as_raw_fd(), &component) {
788 Ok(next) => current = next,
789 Err(error) if error.kind() == io::ErrorKind::NotFound => {
790 if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
791 if mkerr.kind() != io::ErrorKind::AlreadyExists {
792 return Err(mkerr);
793 }
794 }
795 current = open_dir_at(current.as_raw_fd(), &component)?;
796 }
797 Err(error) => return Err(error),
798 }
799 }
800 Ok((current, file_name))
801}
802
803#[cfg(unix)]
804fn open_parent_dir_scoped(
805 target: &ScopedMutationTarget,
806) -> io::Result<(std::os::fd::OwnedFd, String)> {
807 use std::os::fd::AsRawFd;
808
809 let mut components = clean_relative_components(&target.relative)?;
810 let file_name = components.pop().ok_or_else(|| {
811 io::Error::new(
812 io::ErrorKind::InvalidInput,
813 format!(
814 "sandbox scoped open requires a file name: {}",
815 target.relative.display()
816 ),
817 )
818 })?;
819 let root = open_dir_absolute(&target.root)?;
820 let mut current = root;
821 for component in components {
822 current = open_dir_at(current.as_raw_fd(), &component)?;
823 }
824 Ok((current, file_name))
825}
826
827#[cfg(unix)]
828fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
829 use std::os::unix::ffi::OsStrExt;
830
831 let mut out = Vec::new();
832 for component in path.components() {
833 match component {
834 Component::Normal(value) => {
835 let bytes = value.as_bytes();
836 if bytes.contains(&0) {
837 return Err(io::Error::new(
838 io::ErrorKind::InvalidInput,
839 format!("path component contains NUL: {}", path.display()),
840 ));
841 }
842 out.push(value.to_string_lossy().into_owned());
843 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
844 return Err(io::Error::new(
845 io::ErrorKind::InvalidInput,
846 format!(
847 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
848 path.display()
849 ),
850 ));
851 }
852 }
853 Component::CurDir => {}
854 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
855 return Err(io::Error::new(
856 io::ErrorKind::InvalidInput,
857 format!("sandbox scoped path must stay relative: {}", path.display()),
858 ));
859 }
860 }
861 }
862 Ok(out)
863}
864
865#[cfg(unix)]
866fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
867 use std::os::fd::{FromRawFd, OwnedFd};
868 use std::os::unix::ffi::OsStrExt;
869
870 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
871 io::Error::new(
872 io::ErrorKind::InvalidInput,
873 format!("path contains NUL: {}", path.display()),
874 )
875 })?;
876 let fd = unsafe {
877 libc::open(
878 c_path.as_ptr(),
879 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
880 )
881 };
882 if fd < 0 {
883 return Err(io::Error::last_os_error());
884 }
885 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
886}
887
888#[cfg(unix)]
889fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
890 use std::os::fd::{FromRawFd, OwnedFd};
891
892 let c_name = c_name(name)?;
893 let fd = unsafe {
894 libc::openat(
895 parent_fd,
896 c_name.as_ptr(),
897 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
898 )
899 };
900 if fd < 0 {
901 return Err(io::Error::last_os_error());
902 }
903 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
904}
905
906#[cfg(unix)]
907fn openat_file(
908 parent_fd: libc::c_int,
909 name: &str,
910 flags: libc::c_int,
911 mode: libc::mode_t,
912) -> io::Result<std::fs::File> {
913 use std::os::fd::FromRawFd;
914
915 let c_name = c_name(name)?;
916 let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
917 if fd < 0 {
918 return Err(io::Error::last_os_error());
919 }
920 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
921}
922
923#[cfg(unix)]
924fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
925 let c_name = c_name(name)?;
926 let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
927 if rc != 0 {
928 return Err(io::Error::last_os_error());
929 }
930 Ok(())
931}
932
933#[cfg(unix)]
934fn renameat_name(
935 old_parent_fd: libc::c_int,
936 old_name: &str,
937 new_parent_fd: libc::c_int,
938 new_name: &str,
939) -> io::Result<()> {
940 let old_name = c_name(old_name)?;
941 let new_name = c_name(new_name)?;
942 let rc = unsafe {
943 libc::renameat(
944 old_parent_fd,
945 old_name.as_ptr(),
946 new_parent_fd,
947 new_name.as_ptr(),
948 )
949 };
950 if rc != 0 {
951 return Err(io::Error::last_os_error());
952 }
953 Ok(())
954}
955
956#[cfg(unix)]
957fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
958 let c_name = c_name(name)?;
959 let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
960 if rc != 0 {
961 return Err(io::Error::last_os_error());
962 }
963 Ok(())
964}
965
966#[cfg(unix)]
967fn sync_dir_fd(fd: libc::c_int) {
968 let _ = unsafe { libc::fsync(fd) };
969}
970
971#[cfg(unix)]
972fn c_name(name: &str) -> io::Result<std::ffi::CString> {
973 std::ffi::CString::new(name).map_err(|_| {
974 io::Error::new(
975 io::ErrorKind::InvalidInput,
976 format!("path component contains NUL: {name:?}"),
977 )
978 })
979}
980
981pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
982 let Some(policy) = crate::orchestration::current_execution_policy() else {
983 return Ok(());
984 };
985 enforce_process_cwd_for_policy(path, &policy)
986}
987
988pub fn push_process_sandbox_scope(
989 scope: ProcessSandboxScope,
990) -> Result<ProcessSandboxScopeGuard, VmError> {
991 let Some(mut policy) = crate::orchestration::current_execution_policy() else {
992 return Ok(ProcessSandboxScopeGuard { pushed: false });
993 };
994 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
995 return Ok(ProcessSandboxScopeGuard { pushed: false });
996 }
997
998 let requested_roots: Vec<PathBuf> = scope
999 .workspace_roots
1000 .iter()
1001 .filter_map(|root| {
1002 let trimmed = root.trim();
1003 (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1004 })
1005 .collect();
1006 if requested_roots.is_empty() {
1007 return Ok(ProcessSandboxScopeGuard { pushed: false });
1008 }
1009
1010 if !policy.workspace_roots.is_empty() {
1011 let ceiling_roots = normalized_workspace_roots(&policy);
1012 if let Some(rejected) = requested_roots.iter().find(|root| {
1013 !ceiling_roots
1014 .iter()
1015 .any(|ceiling| path_is_within(root, ceiling))
1016 }) {
1017 return Err(sandbox_rejection(format!(
1018 "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1019 rejected.display(),
1020 ceiling_roots
1021 .iter()
1022 .map(|root| root.display().to_string())
1023 .collect::<Vec<_>>()
1024 .join(", ")
1025 )));
1026 }
1027 }
1028
1029 let mut merged_roots = if policy.workspace_roots.is_empty() {
1030 Vec::new()
1031 } else {
1032 normalized_workspace_roots(&policy)
1033 };
1034 for requested in requested_roots {
1035 if !merged_roots
1036 .iter()
1037 .any(|existing| path_is_within(&requested, existing))
1038 {
1039 merged_roots.push(requested);
1040 }
1041 }
1042 policy.workspace_roots = merged_roots
1043 .into_iter()
1044 .map(|root| root.display().to_string())
1045 .collect();
1046 crate::orchestration::push_execution_policy(policy);
1047 Ok(ProcessSandboxScopeGuard { pushed: true })
1048}
1049
1050fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1051 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1052 return Ok(());
1053 }
1054 let candidate = normalize_for_policy(path);
1055 let roots = normalized_workspace_roots(policy);
1056 if roots.iter().any(|root| path_is_within(&candidate, root)) {
1057 return Ok(());
1058 }
1059 Err(sandbox_rejection(format!(
1060 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1061 candidate.display(),
1062 roots
1063 .iter()
1064 .map(|root| root.display().to_string())
1065 .collect::<Vec<_>>()
1066 .join(", ")
1067 )))
1068}
1069
1070pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1071 let (policy, profile) = match active_sandbox_policy() {
1072 Some(value) => value,
1073 None => {
1074 let mut command = Command::new(program);
1075 command.args(args);
1076 return Ok(command);
1077 }
1078 };
1079 build_std_command::<ActiveBackend>(program, args, &policy, profile)
1080}
1081
1082pub fn tokio_command_for(
1083 program: &str,
1084 args: &[String],
1085) -> Result<tokio::process::Command, VmError> {
1086 let (policy, profile) = match active_sandbox_policy() {
1087 Some(value) => value,
1088 None => {
1089 let mut command = tokio::process::Command::new(program);
1090 command.args(args);
1091 return Ok(command);
1092 }
1093 };
1094 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1095}
1096
1097pub fn command_output(
1098 program: &str,
1099 args: &[String],
1100 config: &ProcessCommandConfig,
1101) -> Result<Output, VmError> {
1102 if let Some(intercepted) =
1107 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1108 {
1109 return intercepted.map_err(|message| {
1110 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1111 });
1112 }
1113
1114 let recording =
1115 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1116
1117 let output = match active_sandbox_policy() {
1118 Some((policy, profile)) => {
1119 let config = sandboxed_process_config(config, &policy)?;
1120 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1121 }
1122 None => {
1123 let mut command = Command::new(program);
1124 command.args(args);
1125 apply_process_config(&mut command, config);
1126 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1131 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1132 })?
1133 }
1134 };
1135 if let Some(error) = process_violation_error(&output) {
1136 return Err(error);
1137 }
1138 if let Some(span) = recording {
1139 span.finish(&output);
1140 }
1141 Ok(output)
1142}
1143
1144fn sandboxed_process_config(
1145 config: &ProcessCommandConfig,
1146 policy: &CapabilityPolicy,
1147) -> Result<ProcessCommandConfig, VmError> {
1148 let mut resolved = config.clone();
1149 if let Some(cwd) = resolved.cwd.as_ref() {
1150 enforce_process_cwd_for_policy(cwd, policy)?;
1151 } else {
1152 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1153 }
1154 neutralize_rustc_wrapper(&mut resolved.env);
1155 inject_workspace_tmpdir(&mut resolved.env, policy);
1156 Ok(resolved)
1157}
1158
1159fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1173 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1174 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1175 entry.1.clear();
1176 } else {
1177 env.push((key.to_string(), String::new()));
1178 }
1179 }
1180}
1181
1182pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1188
1189pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1193
1194pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1211 let root = normalized_workspace_roots(policy).into_iter().next()?;
1212 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1213 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1214 warn_once(
1215 "handler_sandbox_workspace_tmpdir",
1216 &format!(
1217 "could not create workspace-local temp dir '{}': {error}; \
1218 leaving the child's inherited temp dir in place",
1219 tmpdir.display()
1220 ),
1221 );
1222 return None;
1223 }
1224 let ignore = tmpdir.join(".gitignore");
1230 if !ignore.exists() {
1231 let _ = std::fs::write(
1232 &ignore,
1233 "# Created by the Harn sandbox; safe to delete.\n*\n",
1234 );
1235 }
1236 Some(tmpdir)
1237}
1238
1239pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1249 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1250 return;
1251 }
1252 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1253 return;
1254 };
1255 let tmpdir = tmpdir.display().to_string();
1256 for key in TMPDIR_ENV_KEYS {
1257 if env.iter().any(|(existing, _)| existing == key) {
1258 continue;
1260 }
1261 env.push((key.to_string(), tmpdir.clone()));
1262 }
1263}
1264
1265pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1280 let Some(policy) = crate::orchestration::current_execution_policy() else {
1281 return Vec::new();
1282 };
1283 let mut env = Vec::new();
1284 inject_workspace_tmpdir(&mut env, &policy);
1285 env
1286}
1287
1288pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1313 vec![
1314 ("LC_MESSAGES".to_string(), "C".to_string()),
1315 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1316 ]
1317}
1318
1319pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1324
1325fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1326 let roots = normalized_workspace_roots(policy);
1327 let current = std::env::current_dir().map_err(|error| {
1328 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1329 format!("process cwd resolution failed: {error}"),
1330 )))
1331 })?;
1332 let current = normalize_for_policy(¤t);
1333 if roots.iter().any(|root| path_is_within(¤t, root)) {
1334 return Ok(current);
1335 }
1336 roots.first().cloned().ok_or_else(|| {
1337 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1338 "process cwd resolution failed: no workspace root available",
1339 )))
1340 })
1341}
1342
1343fn build_std_command<B: SandboxBackend + ?Sized>(
1344 program: &str,
1345 args: &[String],
1346 policy: &CapabilityPolicy,
1347 profile: SandboxProfile,
1348) -> Result<Command, VmError> {
1349 let mut command = Command::new(program);
1350 command.args(args);
1351 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1352 PrepareOutcome::Direct => Ok(command),
1353 PrepareOutcome::WrappedExec { wrapper, args } => {
1354 let mut wrapped = Command::new(wrapper);
1355 wrapped.args(args);
1356 Ok(wrapped)
1357 }
1358 }
1359}
1360
1361fn build_tokio_command<B: SandboxBackend + ?Sized>(
1362 program: &str,
1363 args: &[String],
1364 policy: &CapabilityPolicy,
1365 profile: SandboxProfile,
1366) -> Result<tokio::process::Command, VmError> {
1367 let mut command = tokio::process::Command::new(program);
1368 command.args(args);
1369 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1370 PrepareOutcome::Direct => Ok(command),
1371 PrepareOutcome::WrappedExec { wrapper, args } => {
1372 let mut wrapped = tokio::process::Command::new(wrapper);
1373 wrapped.args(args);
1374 Ok(wrapped)
1375 }
1376 }
1377}
1378
1379pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1380 let policy = crate::orchestration::current_execution_policy()?;
1381 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1382 return None;
1383 }
1384 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1385 || !ActiveBackend::available()
1386 {
1387 return None;
1388 }
1389 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1390 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1391 if !output.status.success()
1392 && (stderr.contains("operation not permitted")
1393 || stderr.contains("permission denied")
1394 || stderr.contains("access is denied")
1395 || stdout.contains("operation not permitted"))
1396 {
1397 return Some(sandbox_rejection(sandbox_process_violation_message(
1398 format!(
1399 "sandbox violation: process was denied by the OS sandbox (status {})",
1400 output.status.code().unwrap_or(-1)
1401 ),
1402 )));
1403 }
1404 if sandbox_signal_status(output) {
1405 return Some(sandbox_rejection(sandbox_process_violation_message(
1406 format!(
1407 "sandbox violation: process was terminated by the OS sandbox (status {})",
1408 output.status
1409 ),
1410 )));
1411 }
1412 None
1413}
1414
1415pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1416 let policy = crate::orchestration::current_execution_policy()?;
1417 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1418 return None;
1419 }
1420 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1421 || !ActiveBackend::available()
1422 {
1423 return None;
1424 }
1425 let message = error.to_string().to_ascii_lowercase();
1426 if error.kind() == std::io::ErrorKind::PermissionDenied
1427 || message.contains("operation not permitted")
1428 || message.contains("permission denied")
1429 || message.contains("access is denied")
1430 {
1431 return Some(sandbox_rejection(sandbox_process_violation_message(
1432 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1433 )));
1434 }
1435 None
1436}
1437
1438#[cfg(unix)]
1439fn sandbox_signal_status(output: &std::process::Output) -> bool {
1440 use std::os::unix::process::ExitStatusExt;
1441
1442 matches!(
1443 output.status.signal(),
1444 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1445 )
1446}
1447
1448#[cfg(not(unix))]
1449fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1450 false
1451}
1452
1453pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1461 let policy = crate::orchestration::current_execution_policy()?;
1462 let profile = policy.sandbox_profile;
1463 match profile {
1464 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1465 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1466 if effective_fallback(profile) == SandboxFallback::Off {
1467 None
1468 } else {
1469 Some((policy, profile))
1470 }
1471 }
1472 }
1473}
1474
1475fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1476 if let Some(cwd) = config.cwd.as_ref() {
1477 command.current_dir(cwd);
1478 }
1479 command.envs(config.env.iter().map(|(key, value)| (key, value)));
1480 if config.stdin_null {
1481 command.stdin(Stdio::null());
1482 }
1483}
1484
1485fn spawn_error(error: std::io::Error) -> VmError {
1486 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1487 format!("process spawn failed: {error}"),
1488 )))
1489}
1490
1491pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1496 if matches!(profile, SandboxProfile::OsHardened) {
1497 return SandboxFallback::Enforce;
1498 }
1499 match std::env::var(HANDLER_SANDBOX_ENV)
1500 .unwrap_or_else(|_| "warn".to_string())
1501 .trim()
1502 .to_ascii_lowercase()
1503 .as_str()
1504 {
1505 "0" | "false" | "off" | "none" => SandboxFallback::Off,
1506 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1507 _ => SandboxFallback::Warn,
1508 }
1509}
1510
1511pub(crate) fn warn_once(key: &str, message: &str) {
1512 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1513 if inserted {
1514 crate::events::log_warn("handler_sandbox", message);
1515 }
1516}
1517
1518pub(crate) fn sandbox_rejection(message: String) -> VmError {
1519 VmError::CategorizedError {
1520 message,
1521 category: ErrorCategory::ToolRejected,
1522 }
1523}
1524
1525fn sandbox_process_violation_message(summary: String) -> String {
1526 format!(
1527 "{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"
1528 )
1529}
1530
1531#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1541pub(crate) fn unavailable(
1542 message: &str,
1543 profile: SandboxProfile,
1544) -> Result<PrepareOutcome, VmError> {
1545 match effective_fallback(profile) {
1546 SandboxFallback::Off | SandboxFallback::Warn => {
1547 warn_once("handler_sandbox_unavailable", message);
1548 Ok(PrepareOutcome::Direct)
1549 }
1550 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1551 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1552 ))),
1553 }
1554}
1555
1556fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1564 let session_id = crate::agent_sessions::current_session_id()?;
1565 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1566 let mut roots = vec![anchor.primary.clone()];
1567 for mounted in &anchor.additional_roots {
1568 if matches!(
1569 mounted.mount_mode,
1570 crate::workspace_anchor::MountMode::Extend
1571 ) {
1572 roots.push(mounted.path.clone());
1573 }
1574 }
1575 Some(roots)
1576}
1577
1578fn project_root_env_workspace_root() -> Option<PathBuf> {
1586 std::env::var("HARN_PROJECT_ROOT")
1587 .ok()
1588 .map(|value| value.trim().to_string())
1589 .filter(|value| !value.is_empty())
1590 .map(PathBuf::from)
1591}
1592
1593fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1594 if policy.workspace_roots.is_empty() {
1595 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1610 return anchor_roots
1611 .iter()
1612 .map(|root| normalize_for_policy(root))
1613 .collect();
1614 }
1615 if let Some(project_root) = project_root_env_workspace_root() {
1616 return vec![normalize_for_policy(&project_root)];
1617 }
1618 return vec![normalize_for_policy(
1619 &crate::stdlib::process::execution_root_path(),
1620 )];
1621 }
1622 policy
1623 .workspace_roots
1624 .iter()
1625 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1626 .collect()
1627}
1628
1629pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1630 normalized_workspace_roots(policy)
1631}
1632
1633fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1638 policy
1639 .read_only_roots
1640 .iter()
1641 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1642 .collect()
1643}
1644
1645#[cfg(any(
1646 target_os = "linux",
1647 target_os = "macos",
1648 target_os = "openbsd",
1649 target_os = "windows"
1650))]
1651pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1652 normalized_read_only_roots(policy)
1653}
1654
1655#[cfg(any(
1656 target_os = "linux",
1657 target_os = "macos",
1658 target_os = "openbsd",
1659 target_os = "windows"
1660))]
1661pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1662 normalized_process_roots(&policy.process_sandbox.read_roots)
1663}
1664
1665#[cfg(any(
1666 target_os = "linux",
1667 target_os = "macos",
1668 target_os = "openbsd",
1669 target_os = "windows"
1670))]
1671pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1672 normalized_process_roots(&policy.process_sandbox.write_roots)
1673}
1674
1675#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1676pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1677 policy.process_sandbox.effective_presets()
1678}
1679
1680#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1681pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1682 policy: &CapabilityPolicy,
1683) -> Vec<PathBuf> {
1684 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1685 return Vec::new();
1686 }
1687 let Some(home) = sandbox_user_home_dir() else {
1688 return Vec::new();
1689 };
1690 developer_toolchain_read_roots_for_home(&home)
1691}
1692
1693#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1694pub(crate) fn process_sandbox_package_manager_config_read_roots(
1695 policy: &CapabilityPolicy,
1696) -> Vec<PathBuf> {
1697 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1698 return Vec::new();
1699 }
1700 let Some(home) = sandbox_user_home_dir() else {
1701 return Vec::new();
1702 };
1703 package_manager_config_read_roots_for_home(&home)
1704}
1705
1706#[cfg(any(target_os = "linux", target_os = "macos"))]
1721pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1722 policy: &CapabilityPolicy,
1723) -> Vec<PathBuf> {
1724 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1725 return Vec::new();
1726 }
1727 let Some(home) = sandbox_user_home_dir() else {
1728 return Vec::new();
1729 };
1730 developer_toolchain_cache_write_roots_for_home(&home)
1731}
1732
1733#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1734fn sandbox_user_home_dir() -> Option<PathBuf> {
1735 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1738}
1739
1740#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1741pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1742 let mut roots: Vec<_> = [
1743 ".asdf",
1744 ".bun",
1745 ".cargo",
1746 ".fnm",
1747 ".juliaup",
1748 ".local/bin",
1749 ".local/share/mise",
1750 ".local/share/uv",
1751 ".nvm",
1752 ".pyenv",
1753 ".rbenv",
1754 ".rustup",
1755 ".sdkman",
1756 ".swiftly",
1757 ".volta",
1758 "go",
1759 ]
1760 .into_iter()
1761 .map(|entry| normalize_for_policy(&home.join(entry)))
1762 .collect();
1763 #[cfg(target_os = "windows")]
1764 roots.extend(
1765 [
1766 "AppData/Local/Programs/Python",
1767 "AppData/Local/uv",
1768 "AppData/Roaming/uv",
1769 "scoop",
1770 ]
1771 .into_iter()
1772 .map(|entry| normalize_for_policy(&home.join(entry))),
1773 );
1774 roots.sort_unstable();
1775 roots.dedup();
1776 roots
1777}
1778
1779#[cfg(any(target_os = "linux", target_os = "macos"))]
1784pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1785 let mut roots: Vec<_> = [
1786 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
1792 .into_iter()
1793 .map(|entry| normalize_for_policy(&home.join(entry)))
1794 .collect();
1795 roots.sort_unstable();
1796 roots.dedup();
1797 roots
1798}
1799
1800#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1801pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1802 let mut roots: Vec<_> = [
1803 ".npmrc",
1804 ".gitconfig",
1805 ".netrc",
1806 ".yarnrc.yml",
1807 ".config",
1808 ".npm",
1809 ".cache",
1810 ".pip",
1811 ".pypirc",
1812 ".cargo/config",
1813 ".cargo/config.toml",
1814 ".cargo/credentials",
1815 ".cargo/credentials.toml",
1816 ".cargo/registry",
1817 ".cargo/git",
1818 ]
1819 .into_iter()
1820 .map(|entry| normalize_for_policy(&home.join(entry)))
1821 .collect();
1822 roots.sort_unstable();
1823 roots.dedup();
1824 roots
1825}
1826
1827#[cfg(any(
1828 target_os = "linux",
1829 target_os = "macos",
1830 target_os = "openbsd",
1831 target_os = "windows"
1832))]
1833fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1834 roots
1835 .iter()
1836 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1837 .collect()
1838}
1839
1840fn resolve_policy_path(path: &str) -> PathBuf {
1841 let candidate = PathBuf::from(path);
1842 if candidate.is_absolute() {
1843 candidate
1844 } else {
1845 crate::stdlib::process::execution_root_path().join(candidate)
1846 }
1847}
1848
1849fn normalize_for_policy(path: &Path) -> PathBuf {
1850 let absolute = if path.is_absolute() {
1851 path.to_path_buf()
1852 } else {
1853 crate::stdlib::process::execution_root_path().join(path)
1854 };
1855 let absolute = normalize_lexically(&absolute);
1856 if let Ok(canonical) = absolute.canonicalize() {
1857 return canonical;
1858 }
1859
1860 let mut existing = absolute.as_path();
1861 let mut suffix = Vec::new();
1862 while !existing.exists() {
1863 let Some(parent) = existing.parent() else {
1864 return normalize_lexically(&absolute);
1865 };
1866 if let Some(name) = existing.file_name() {
1867 suffix.push(name.to_os_string());
1868 }
1869 existing = parent;
1870 }
1871
1872 let mut normalized = existing
1873 .canonicalize()
1874 .unwrap_or_else(|_| normalize_lexically(existing));
1875 for component in suffix.iter().rev() {
1876 normalized.push(component);
1877 }
1878 normalize_lexically(&normalized)
1879}
1880
1881fn normalize_lexically(path: &Path) -> PathBuf {
1882 let mut normalized = PathBuf::new();
1883 for component in path.components() {
1884 match component {
1885 Component::CurDir => {}
1886 Component::ParentDir => {
1887 normalized.pop();
1888 }
1889 other => normalized.push(other.as_os_str()),
1890 }
1891 }
1892 normalized
1893}
1894
1895fn path_is_within(path: &Path, root: &Path) -> bool {
1896 path == root || path.starts_with(root)
1897}
1898
1899fn normalize_io_device_path(path: &Path) -> PathBuf {
1904 let absolute = if path.is_absolute() {
1905 path.to_path_buf()
1906 } else {
1907 crate::stdlib::process::execution_root_path().join(path)
1908 };
1909 normalize_lexically(&absolute)
1910}
1911
1912fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1917 match access {
1918 FsAccess::Read => {
1919 matches!(
1920 path.to_str(),
1921 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1922 ) || is_dev_fd_descriptor(path)
1923 }
1924 FsAccess::Write => {
1925 matches!(
1926 path.to_str(),
1927 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1928 ) || is_dev_fd_descriptor(path)
1929 }
1930 FsAccess::Delete => false,
1931 }
1932}
1933
1934fn is_dev_fd_descriptor(path: &Path) -> bool {
1937 let Some(text) = path.to_str() else {
1938 return false;
1939 };
1940 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1941 return false;
1942 };
1943 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1944}
1945
1946#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1947pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1948 use crate::tool_annotations::SideEffectLevel;
1949 policy
1950 .side_effect_level
1951 .as_ref()
1952 .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
1953 .unwrap_or(true)
1954}
1955
1956#[cfg(any(
1957 target_os = "linux",
1958 target_os = "macos",
1959 target_os = "openbsd",
1960 target_os = "windows"
1961))]
1962pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1963 policy.capabilities.is_empty()
1964 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1965}
1966
1967#[cfg(any(
1968 target_os = "linux",
1969 target_os = "macos",
1970 target_os = "openbsd",
1971 target_os = "windows"
1972))]
1973pub(crate) fn policy_allows_capability(
1974 policy: &CapabilityPolicy,
1975 capability: &str,
1976 ops: &[&str],
1977) -> bool {
1978 policy
1979 .capabilities
1980 .get(capability)
1981 .map(|allowed| {
1982 ops.iter()
1983 .any(|op| allowed.iter().any(|candidate| candidate == op))
1984 })
1985 .unwrap_or(false)
1986}
1987
1988impl FsAccess {
1989 fn verb(self) -> &'static str {
1990 match self {
1991 FsAccess::Read => "read",
1992 FsAccess::Write => "write",
1993 FsAccess::Delete => "delete",
1994 }
1995 }
1996}
1997
1998#[cfg(test)]
1999mod tests {
2000 use super::*;
2001 use crate::orchestration::{pop_execution_policy, push_execution_policy};
2002
2003 #[test]
2004 fn missing_create_path_normalizes_against_existing_parent() {
2005 let dir = tempfile::tempdir().unwrap();
2006 let nested = dir.path().join("a/../new.txt");
2007 let normalized = normalize_for_policy(&nested);
2008 assert_eq!(
2009 normalized,
2010 normalize_for_policy(&dir.path().join("new.txt"))
2011 );
2012 }
2013
2014 #[test]
2015 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
2016 let _env_lock = crate::runtime_paths::test_env_lock()
2020 .lock()
2021 .unwrap_or_else(|poisoned| poisoned.into_inner());
2022 std::env::remove_var("HARN_PROJECT_ROOT");
2023 let dir = tempfile::tempdir().unwrap();
2024 crate::stdlib::process::set_thread_execution_context(Some(
2025 crate::orchestration::RunExecutionRecord {
2026 cwd: Some(dir.path().to_string_lossy().into_owned()),
2027 source_dir: None,
2028 env: Default::default(),
2029 adapter: None,
2030 repo_path: None,
2031 worktree_path: None,
2032 branch: None,
2033 base_ref: None,
2034 cleanup: None,
2035 },
2036 ));
2037 push_execution_policy(CapabilityPolicy {
2038 sandbox_profile: SandboxProfile::Worktree,
2039 ..CapabilityPolicy::default()
2040 });
2041
2042 assert!(
2043 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2044 );
2045 let outside = tempfile::tempdir().unwrap();
2046 assert!(enforce_fs_path(
2047 "read_file",
2048 &outside.path().join("outside.txt"),
2049 FsAccess::Read
2050 )
2051 .is_err());
2052
2053 pop_execution_policy();
2054 crate::stdlib::process::set_thread_execution_context(None);
2055 }
2056
2057 #[test]
2067 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2068 let _env_lock = crate::runtime_paths::test_env_lock()
2069 .lock()
2070 .unwrap_or_else(|poisoned| poisoned.into_inner());
2071 let project = tempfile::tempdir().unwrap();
2072 let execution_cwd = tempfile::tempdir().unwrap();
2073 std::env::set_var("HARN_PROJECT_ROOT", project.path());
2074 crate::stdlib::process::set_thread_execution_context(Some(
2075 crate::orchestration::RunExecutionRecord {
2076 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2077 source_dir: None,
2078 env: Default::default(),
2079 adapter: None,
2080 repo_path: None,
2081 worktree_path: None,
2082 branch: None,
2083 base_ref: None,
2084 cleanup: None,
2085 },
2086 ));
2087 push_execution_policy(CapabilityPolicy {
2088 sandbox_profile: SandboxProfile::Worktree,
2089 ..CapabilityPolicy::default()
2090 });
2091
2092 assert!(
2095 enforce_fs_path(
2096 "write_file",
2097 &project.path().join("test/created.ts"),
2098 FsAccess::Write,
2099 )
2100 .is_ok(),
2101 "write into HARN_PROJECT_ROOT must be allowed"
2102 );
2103 assert!(
2107 enforce_fs_path(
2108 "write_file",
2109 &execution_cwd.path().join("escape.ts"),
2110 FsAccess::Write,
2111 )
2112 .is_err(),
2113 "write under the execution cwd (outside the project) must be rejected"
2114 );
2115
2116 pop_execution_policy();
2117 crate::stdlib::process::set_thread_execution_context(None);
2118 std::env::remove_var("HARN_PROJECT_ROOT");
2119 }
2120
2121 #[test]
2122 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2123 let dir = tempfile::tempdir().unwrap();
2124 crate::stdlib::process::set_thread_execution_context(Some(
2125 crate::orchestration::RunExecutionRecord {
2126 cwd: Some(dir.path().to_string_lossy().into_owned()),
2127 source_dir: None,
2128 env: Default::default(),
2129 adapter: None,
2130 repo_path: None,
2131 worktree_path: None,
2132 branch: None,
2133 base_ref: None,
2134 cleanup: None,
2135 },
2136 ));
2137 push_execution_policy(CapabilityPolicy {
2138 sandbox_profile: SandboxProfile::Worktree,
2139 ..CapabilityPolicy::default()
2140 });
2141
2142 assert!(enforce_process_cwd(dir.path()).is_ok());
2143 let outside = tempfile::tempdir().unwrap();
2144 assert!(enforce_process_cwd(outside.path()).is_err());
2145
2146 pop_execution_policy();
2147 crate::stdlib::process::set_thread_execution_context(None);
2148 }
2149
2150 #[test]
2151 fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2152 let _env_lock = crate::runtime_paths::test_env_lock()
2153 .lock()
2154 .unwrap_or_else(|poisoned| poisoned.into_inner());
2155 std::env::remove_var("HARN_PROJECT_ROOT");
2156 let execution_root = tempfile::tempdir().unwrap();
2157 let command_root = tempfile::tempdir().unwrap();
2158 crate::stdlib::process::set_thread_execution_context(Some(
2159 crate::orchestration::RunExecutionRecord {
2160 cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2161 source_dir: None,
2162 env: Default::default(),
2163 adapter: None,
2164 repo_path: None,
2165 worktree_path: None,
2166 branch: None,
2167 base_ref: None,
2168 cleanup: None,
2169 },
2170 ));
2171 push_execution_policy(CapabilityPolicy {
2172 sandbox_profile: SandboxProfile::Worktree,
2173 ..CapabilityPolicy::default()
2174 });
2175
2176 assert!(
2177 enforce_process_cwd(command_root.path()).is_err(),
2178 "before the scoped overlay the command temp root is outside the execution-root fallback",
2179 );
2180 {
2181 let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2182 workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2183 })
2184 .unwrap();
2185 assert!(
2186 enforce_process_cwd(command_root.path()).is_ok(),
2187 "scoped command root must be usable as the process cwd"
2188 );
2189 assert!(
2190 enforce_process_cwd(execution_root.path()).is_err(),
2191 "the scoped root must narrow the concrete spawn jail instead of widening it"
2192 );
2193 }
2194 assert!(
2195 enforce_process_cwd(command_root.path()).is_err(),
2196 "the scoped command root must pop after the command spawn"
2197 );
2198
2199 pop_execution_policy();
2200 crate::stdlib::process::set_thread_execution_context(None);
2201 }
2202
2203 #[test]
2204 fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2205 let workspace = tempfile::tempdir().unwrap();
2206 let inside = workspace.path().join("subdir");
2207 std::fs::create_dir(&inside).unwrap();
2208 let outside = tempfile::tempdir().unwrap();
2209 push_execution_policy(CapabilityPolicy {
2210 sandbox_profile: SandboxProfile::Worktree,
2211 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2212 ..CapabilityPolicy::default()
2213 });
2214
2215 assert!(
2216 push_process_sandbox_scope(ProcessSandboxScope {
2217 workspace_roots: vec![inside.to_string_lossy().into_owned()],
2218 })
2219 .is_ok(),
2220 "a command subroot inside the explicit workspace ceiling is allowed"
2221 );
2222 assert!(
2223 push_process_sandbox_scope(ProcessSandboxScope {
2224 workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2225 })
2226 .is_err(),
2227 "a command root outside the explicit workspace ceiling must be rejected"
2228 );
2229
2230 pop_execution_policy();
2231 }
2232
2233 #[cfg(unix)]
2234 #[test]
2235 fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2236 let workspace = tempfile::tempdir().unwrap();
2237 let outside = tempfile::tempdir().unwrap();
2238 let safe_parent = workspace.path().join("safe");
2239 std::fs::create_dir(&safe_parent).unwrap();
2240 let path = safe_parent.join("state.json");
2241 push_execution_policy(CapabilityPolicy {
2242 sandbox_profile: SandboxProfile::Worktree,
2243 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2244 ..CapabilityPolicy::default()
2245 });
2246 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2247 .unwrap()
2248 .expect("restricted policy yields scoped target");
2249
2250 std::fs::remove_dir(&safe_parent).unwrap();
2251 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2252 let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2253 pop_execution_policy();
2254
2255 assert!(
2256 !outside.path().join("state.json").exists(),
2257 "scoped write must not follow swapped parent symlink; error={error}"
2258 );
2259 }
2260
2261 #[cfg(unix)]
2262 #[test]
2263 fn scoped_write_creates_missing_parent_dirs() {
2264 let workspace = tempfile::tempdir().unwrap();
2270 let path = workspace.path().join("a/b/c/plan.json");
2271 push_execution_policy(CapabilityPolicy {
2272 sandbox_profile: SandboxProfile::Worktree,
2273 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2274 ..CapabilityPolicy::default()
2275 });
2276 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2277 .unwrap()
2278 .expect("restricted policy yields scoped target");
2279 let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2280 pop_execution_policy();
2281
2282 assert!(
2283 result.is_ok(),
2284 "write must create missing parents: {result:?}"
2285 );
2286 assert_eq!(
2287 std::fs::read(&path).unwrap(),
2288 b"{\"plan\":\"Redis-backed\"}".to_vec()
2289 );
2290 }
2291
2292 #[cfg(unix)]
2293 #[test]
2294 fn scoped_parent_autocreate_refuses_preexisting_symlink_component() {
2295 let workspace = tempfile::tempdir().unwrap();
2296 let outside = tempfile::tempdir().unwrap();
2297 std::fs::create_dir(workspace.path().join("a")).unwrap();
2298 std::os::unix::fs::symlink(outside.path(), workspace.path().join("a/b")).unwrap();
2299 let target = ScopedMutationTarget {
2300 root: workspace.path().to_path_buf(),
2301 relative: PathBuf::from("a/b/c/plan.json"),
2302 };
2303
2304 let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2305
2306 assert!(
2307 !outside.path().join("c/plan.json").exists(),
2308 "parent creation must not follow a symlinked component; error={error}"
2309 );
2310 assert!(
2311 !workspace.path().join("a/b/c").exists(),
2312 "symlinked components must not be treated as satisfied parents"
2313 );
2314 }
2315
2316 #[test]
2317 fn scoped_read_check_does_not_create_missing_parent_dirs() {
2318 let workspace = tempfile::tempdir().unwrap();
2319 let path = workspace.path().join("a/b/c/plan.json");
2320 push_execution_policy(CapabilityPolicy {
2321 sandbox_profile: SandboxProfile::Worktree,
2322 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2323 ..CapabilityPolicy::default()
2324 });
2325 let result = enforce_fs_path("read_file", &path, FsAccess::Read);
2326 pop_execution_policy();
2327
2328 assert!(
2329 result.is_ok(),
2330 "read path inside workspace should be in scope"
2331 );
2332 assert!(
2333 !workspace.path().join("a").exists(),
2334 "read/list/stat/delete scope checks must not create ancestors"
2335 );
2336 }
2337
2338 #[cfg(unix)]
2339 #[test]
2340 fn scoped_paths_refuse_excessive_component_depth() {
2341 let mut relative = PathBuf::new();
2342 for index in 0..=MAX_SCOPED_PATH_COMPONENTS {
2343 relative.push(format!("d{index}"));
2344 }
2345
2346 let error = clean_relative_components(&relative).unwrap_err();
2347
2348 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2349 assert!(
2350 error
2351 .to_string()
2352 .contains("sandbox scoped path exceeds 256 components"),
2353 "unexpected error: {error}"
2354 );
2355 }
2356
2357 #[cfg(unix)]
2358 #[test]
2359 fn scoped_append_creates_missing_parent_dirs() {
2360 let workspace = tempfile::tempdir().unwrap();
2361 let path = workspace.path().join("logs/deep/qa.jsonl");
2362 push_execution_policy(CapabilityPolicy {
2363 sandbox_profile: SandboxProfile::Worktree,
2364 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2365 ..CapabilityPolicy::default()
2366 });
2367 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2368 .unwrap()
2369 .expect("restricted policy yields scoped target");
2370 append_scoped_target(&target, b"line1\n").unwrap();
2371 append_scoped_target(&target, b"line2\n").unwrap();
2372 pop_execution_policy();
2373
2374 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2375 }
2376
2377 #[test]
2378 fn unscoped_write_creates_missing_parent_dirs() {
2379 let dir = tempfile::tempdir().unwrap();
2384 let path = dir.path().join("x/y/z/plan.json");
2385 atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2386 assert_eq!(
2387 std::fs::read(&path).unwrap(),
2388 b"{\"plan\":\"Redis-backed\"}".to_vec()
2389 );
2390 }
2391
2392 #[test]
2393 fn unscoped_append_creates_missing_parent_dirs() {
2394 let dir = tempfile::tempdir().unwrap();
2395 let path = dir.path().join("logs/deep/qa.jsonl");
2396 append_unscoped(&path, b"line1\n").unwrap();
2397 append_unscoped(&path, b"line2\n").unwrap();
2398 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2399 }
2400
2401 #[cfg(unix)]
2402 #[test]
2403 fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
2404 let workspace = tempfile::tempdir().unwrap();
2408 let outside = tempfile::tempdir().unwrap();
2409 std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
2410 let path = workspace.path().join("escape/sub/plan.json");
2411 push_execution_policy(CapabilityPolicy {
2412 sandbox_profile: SandboxProfile::Worktree,
2413 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2414 ..CapabilityPolicy::default()
2415 });
2416 let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
2421 Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
2422 Ok(None) | Err(_) => false,
2423 };
2424 pop_execution_policy();
2425
2426 assert!(
2427 !escaped,
2428 "must not write through a symlinked intermediate dir"
2429 );
2430 assert!(
2431 !outside.path().join("sub/plan.json").exists(),
2432 "scoped write escaped the workspace via a symlinked parent"
2433 );
2434 }
2435
2436 #[cfg(unix)]
2437 #[test]
2438 fn scoped_append_rejects_final_symlink_created_after_policy_match() {
2439 let workspace = tempfile::tempdir().unwrap();
2440 let outside = tempfile::tempdir().unwrap();
2441 let safe_parent = workspace.path().join("safe");
2442 std::fs::create_dir(&safe_parent).unwrap();
2443 let outside_file = outside.path().join("state.log");
2444 std::fs::write(&outside_file, b"outside").unwrap();
2445 let path = safe_parent.join("state.log");
2446 push_execution_policy(CapabilityPolicy {
2447 sandbox_profile: SandboxProfile::Worktree,
2448 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2449 ..CapabilityPolicy::default()
2450 });
2451 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2452 .unwrap()
2453 .expect("restricted policy yields scoped target");
2454
2455 std::os::unix::fs::symlink(&outside_file, &path).unwrap();
2456 let error = append_scoped_target(&target, b"\nescape").unwrap_err();
2457 pop_execution_policy();
2458
2459 assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
2460 assert!(
2461 error.raw_os_error() == Some(libc::ELOOP)
2462 || error.kind() == io::ErrorKind::PermissionDenied
2463 || error.kind() == io::ErrorKind::Other,
2464 "expected symlink refusal, got {error:?}"
2465 );
2466 }
2467
2468 #[cfg(unix)]
2469 #[test]
2470 fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
2471 let workspace = tempfile::tempdir().unwrap();
2472 let outside = tempfile::tempdir().unwrap();
2473 let safe_parent = workspace.path().join("safe");
2474 std::fs::create_dir(&safe_parent).unwrap();
2475 let path = safe_parent.join("nested/deeper");
2476 push_execution_policy(CapabilityPolicy {
2477 sandbox_profile: SandboxProfile::Worktree,
2478 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2479 ..CapabilityPolicy::default()
2480 });
2481 let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
2482 .unwrap()
2483 .expect("restricted policy yields scoped target");
2484
2485 std::fs::remove_dir(&safe_parent).unwrap();
2486 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2487 let error = create_dir_all_scoped_target(&target).unwrap_err();
2488 pop_execution_policy();
2489
2490 assert!(
2491 !outside.path().join("nested").exists(),
2492 "scoped mkdir must not follow swapped parent symlink; error={error}"
2493 );
2494 }
2495
2496 #[test]
2497 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
2498 let cwd = std::env::current_dir().unwrap();
2499 let policy = CapabilityPolicy {
2500 sandbox_profile: SandboxProfile::Worktree,
2501 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2502 ..CapabilityPolicy::default()
2503 };
2504
2505 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2506
2507 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
2508 }
2509
2510 #[test]
2511 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
2512 let workspace = tempfile::tempdir().unwrap();
2513 let policy = CapabilityPolicy {
2514 sandbox_profile: SandboxProfile::Worktree,
2515 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2516 ..CapabilityPolicy::default()
2517 };
2518
2519 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2520
2521 assert_eq!(
2522 resolved.cwd.unwrap(),
2523 normalize_for_policy(workspace.path())
2524 );
2525 }
2526
2527 #[test]
2528 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
2529 let workspace = tempfile::tempdir().unwrap();
2530 let outside = tempfile::tempdir().unwrap();
2531 let policy = CapabilityPolicy {
2532 sandbox_profile: SandboxProfile::Worktree,
2533 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2534 ..CapabilityPolicy::default()
2535 };
2536 let config = ProcessCommandConfig {
2537 cwd: Some(outside.path().to_path_buf()),
2538 ..ProcessCommandConfig::default()
2539 };
2540
2541 assert!(sandboxed_process_config(&config, &policy).is_err());
2542 }
2543
2544 #[test]
2545 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
2546 let cwd = std::env::current_dir().unwrap();
2547 let policy = CapabilityPolicy {
2548 sandbox_profile: SandboxProfile::Worktree,
2549 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2550 ..CapabilityPolicy::default()
2551 };
2552
2553 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2556 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2557 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2558 assert_eq!(
2559 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
2560 Some("")
2561 );
2562 }
2563
2564 #[test]
2565 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
2566 let mut env = vec![
2569 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
2570 ("PATH".to_string(), "/usr/bin".to_string()),
2571 ];
2572 neutralize_rustc_wrapper(&mut env);
2573 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2574 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2575 assert_eq!(
2576 collected
2577 .get("CARGO_BUILD_RUSTC_WRAPPER")
2578 .map(String::as_str),
2579 Some("")
2580 );
2581 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
2582 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
2584 }
2585
2586 #[test]
2587 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
2588 let workspace = tempfile::tempdir().unwrap();
2589 let policy = CapabilityPolicy {
2590 sandbox_profile: SandboxProfile::Worktree,
2591 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2592 ..CapabilityPolicy::default()
2593 };
2594
2595 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
2596
2597 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
2600 assert!(
2601 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
2602 "temp dir {tmpdir:?} must be inside the writable workspace root"
2603 );
2604 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
2605 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
2607 assert!(
2608 ignore.lines().any(|line| line.trim() == "*"),
2609 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
2610 );
2611 push_execution_policy(policy);
2614 assert!(
2615 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
2616 "writes under the workspace-local temp dir must be in sandbox scope"
2617 );
2618 pop_execution_policy();
2619 }
2620
2621 #[test]
2622 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
2623 let policy = CapabilityPolicy {
2626 sandbox_profile: SandboxProfile::Unrestricted,
2627 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
2628 ..CapabilityPolicy::default()
2629 };
2630 let mut env = Vec::new();
2631 inject_workspace_tmpdir(&mut env, &policy);
2632 assert!(
2633 env.is_empty(),
2634 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
2635 );
2636 }
2637
2638 #[test]
2639 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
2640 let workspace = tempfile::tempdir().unwrap();
2641 let policy = CapabilityPolicy {
2642 sandbox_profile: SandboxProfile::Worktree,
2643 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2644 ..CapabilityPolicy::default()
2645 };
2646 let mut env = Vec::new();
2647 inject_workspace_tmpdir(&mut env, &policy);
2648
2649 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
2650 let expected = workspace_local_tmpdir(&policy)
2651 .unwrap()
2652 .display()
2653 .to_string();
2654 for key in TMPDIR_ENV_KEYS {
2655 assert_eq!(
2656 collected.get(key).map(String::as_str),
2657 Some(expected.as_str()),
2658 "{key} must point at the workspace-local temp dir"
2659 );
2660 }
2661 }
2662
2663 #[test]
2664 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
2665 let env: std::collections::BTreeMap<_, _> =
2666 deterministic_message_locale_env().into_iter().collect();
2667 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
2670 assert_eq!(
2672 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
2673 Some("en")
2674 );
2675 assert!(
2678 !env.contains_key("LC_ALL"),
2679 "must not force LC_ALL (would clobber UTF-8 ctype)"
2680 );
2681 assert!(!env.contains_key("LC_CTYPE"));
2682 assert!(!env.contains_key("LANG"));
2683 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
2686 }
2687
2688 #[test]
2689 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
2690 let workspace = tempfile::tempdir().unwrap();
2691 let policy = CapabilityPolicy {
2692 sandbox_profile: SandboxProfile::Worktree,
2693 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2694 ..CapabilityPolicy::default()
2695 };
2696 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
2698 inject_workspace_tmpdir(&mut env, &policy);
2699
2700 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2701 assert_eq!(
2702 collected.get("TMPDIR").map(String::as_str),
2703 Some("/caller/explicit/tmp"),
2704 "an explicit caller TMPDIR must be preserved untouched"
2705 );
2706 let expected = workspace_local_tmpdir(&policy)
2707 .unwrap()
2708 .display()
2709 .to_string();
2710 assert_eq!(
2711 collected.get("TMP").map(String::as_str),
2712 Some(expected.as_str())
2713 );
2714 assert_eq!(
2715 collected.get("TEMP").map(String::as_str),
2716 Some(expected.as_str())
2717 );
2718 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
2720 }
2721
2722 #[test]
2723 fn sandboxed_process_config_injects_workspace_tmpdir() {
2724 let workspace = tempfile::tempdir().unwrap();
2725 let policy = CapabilityPolicy {
2726 sandbox_profile: SandboxProfile::Worktree,
2727 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2728 ..CapabilityPolicy::default()
2729 };
2730 let config = ProcessCommandConfig {
2731 cwd: Some(workspace.path().to_path_buf()),
2732 ..ProcessCommandConfig::default()
2733 };
2734 let resolved = sandboxed_process_config(&config, &policy).unwrap();
2735 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2736 let expected = workspace_local_tmpdir(&policy)
2737 .unwrap()
2738 .display()
2739 .to_string();
2740 assert_eq!(
2741 env.get("TMPDIR").map(String::as_str),
2742 Some(expected.as_str()),
2743 "the command_output path must inject a workspace-local TMPDIR"
2744 );
2745 }
2746
2747 #[test]
2748 fn read_only_root_outside_workspace_allows_read_denies_write() {
2749 let workspace = tempfile::tempdir().unwrap();
2754 let read_only = tempfile::tempdir().unwrap();
2755 push_execution_policy(CapabilityPolicy {
2756 sandbox_profile: SandboxProfile::Worktree,
2757 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2758 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
2759 ..CapabilityPolicy::default()
2760 });
2761
2762 let asset = read_only
2763 .path()
2764 .join("partials/agent-web-tools.harn.prompt");
2765 assert!(
2767 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
2768 "read under a configured read-only root must be allowed"
2769 );
2770
2771 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
2773 .expect_err("write under a read-only root must be denied");
2774 assert!(write_err.read_only, "write rejection must set read_only");
2775
2776 assert!(
2778 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
2779 "delete under a read-only root must be denied"
2780 );
2781
2782 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
2784
2785 let stranger = tempfile::tempdir().unwrap();
2788 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
2789 .expect_err("read outside all roots must be denied");
2790 assert!(
2791 !outside_err.read_only,
2792 "out-of-scope rejection must not be flagged read_only"
2793 );
2794
2795 pop_execution_policy();
2796 }
2797
2798 #[cfg(unix)]
2799 #[test]
2800 fn standard_io_device_files_allowed_under_restricted_profile() {
2801 let workspace = tempfile::tempdir().unwrap();
2806 push_execution_policy(CapabilityPolicy {
2807 sandbox_profile: SandboxProfile::Worktree,
2808 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2809 ..CapabilityPolicy::default()
2810 });
2811
2812 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
2813 assert!(
2814 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
2815 "write to standard device {device} must be allowed"
2816 );
2817 assert!(
2819 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
2820 "read of standard device {device} must be allowed"
2821 );
2822 }
2823 assert!(
2824 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
2825 "read of standard device /dev/stdin must be allowed"
2826 );
2827 assert!(
2828 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
2829 "write to /dev/stdin is not a standard output stream"
2830 );
2831 assert!(
2832 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
2833 "standard devices must not bypass delete scoping"
2834 );
2835 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
2837 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
2838
2839 let stranger = tempfile::tempdir().unwrap();
2841 assert!(
2842 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
2843 "a real out-of-root write must still be rejected"
2844 );
2845 assert!(
2847 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
2848 "/dev/sda must not be allowed by the standard-device allowlist"
2849 );
2850 assert!(
2851 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
2852 "non-numeric /dev/fd/<x> must not be allowed"
2853 );
2854
2855 pop_execution_policy();
2856 }
2857
2858 #[test]
2859 fn is_standard_io_device_matches_only_known_streams() {
2860 assert!(is_standard_io_device_for_access(
2861 Path::new("/dev/stdin"),
2862 FsAccess::Read
2863 ));
2864 assert!(!is_standard_io_device_for_access(
2865 Path::new("/dev/stdin"),
2866 FsAccess::Write
2867 ));
2868 assert!(is_standard_io_device_for_access(
2869 Path::new("/dev/stdout"),
2870 FsAccess::Write
2871 ));
2872 assert!(is_standard_io_device_for_access(
2873 Path::new("/dev/stderr"),
2874 FsAccess::Write
2875 ));
2876 assert!(is_standard_io_device_for_access(
2877 Path::new("/dev/null"),
2878 FsAccess::Write
2879 ));
2880 assert!(is_standard_io_device_for_access(
2881 Path::new("/dev/fd/0"),
2882 FsAccess::Read
2883 ));
2884 assert!(is_standard_io_device_for_access(
2885 Path::new("/dev/fd/12"),
2886 FsAccess::Write
2887 ));
2888 assert!(!is_standard_io_device_for_access(
2889 Path::new("/dev/null"),
2890 FsAccess::Delete
2891 ));
2892 assert!(!is_standard_io_device_for_access(
2893 Path::new("/dev/fd/"),
2894 FsAccess::Write
2895 ));
2896 assert!(!is_standard_io_device_for_access(
2897 Path::new("/dev/fd/1a"),
2898 FsAccess::Write
2899 ));
2900 assert!(!is_standard_io_device_for_access(
2901 Path::new("/dev/stdoutx"),
2902 FsAccess::Write
2903 ));
2904 assert!(!is_standard_io_device_for_access(
2905 Path::new("/dev/random"),
2906 FsAccess::Read
2907 ));
2908 assert!(!is_standard_io_device_for_access(
2909 Path::new("/tmp/dev/null"),
2910 FsAccess::Write
2911 ));
2912 }
2913
2914 #[test]
2915 fn path_within_root_accepts_root_and_children() {
2916 let root = Path::new("/tmp/harn-root");
2917 assert!(path_is_within(root, root));
2918 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
2919 assert!(!path_is_within(
2920 Path::new("/tmp/harn-root-other/file"),
2921 root
2922 ));
2923 }
2924
2925 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2926 #[test]
2927 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
2928 let temp_home = tempfile::tempdir().expect("temp home");
2929 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
2930 let normalized_home = normalize_for_policy(temp_home.path());
2931
2932 for suffix in [
2933 Path::new(".cargo"),
2934 Path::new(".rustup"),
2935 Path::new(".pyenv"),
2936 Path::new(".nvm"),
2937 Path::new(".volta"),
2938 Path::new(".local/share/uv"),
2939 Path::new("go"),
2940 ] {
2941 assert!(
2942 roots.iter().any(|path| path.ends_with(suffix)),
2943 "expected a developer-toolchain grant for {}",
2944 suffix.display()
2945 );
2946 }
2947 assert!(
2948 roots.iter().all(|path| path.starts_with(&normalized_home)),
2949 "developer-toolchain roots must stay under HOME"
2950 );
2951 }
2952
2953 #[cfg(any(target_os = "linux", target_os = "macos"))]
2954 #[test]
2955 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
2956 let temp_home = tempfile::tempdir().expect("temp home");
2957 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
2958 let normalized_home = normalize_for_policy(temp_home.path());
2959
2960 for suffix in [
2961 Path::new(".gradle"),
2962 Path::new(".m2"),
2963 Path::new(".konan"),
2964 Path::new("Library/Caches/CocoaPods"),
2965 Path::new("Library/Developer/Xcode/DerivedData"),
2966 ] {
2967 assert!(
2968 roots.iter().any(|path| path.ends_with(suffix)),
2969 "expected a JVM/iOS toolchain cache grant for {}",
2970 suffix.display()
2971 );
2972 }
2973 assert!(
2974 roots.iter().all(|path| path.starts_with(&normalized_home)),
2975 "toolchain cache roots must stay under HOME"
2976 );
2977 }
2978
2979 #[cfg(any(target_os = "linux", target_os = "macos"))]
2980 #[test]
2981 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
2982 let mut policy = CapabilityPolicy {
2983 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
2984 ..CapabilityPolicy::default()
2985 };
2986 if sandbox_user_home_dir().is_some() {
2989 assert!(
2990 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2991 "default presets should render JVM/iOS cache roots"
2992 );
2993 }
2994 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2996 assert!(
2997 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2998 "cache roots must be gated on the DeveloperToolchains preset"
2999 );
3000 }
3001
3002 #[test]
3003 fn os_hardened_profile_overrides_fallback_env() {
3004 assert_eq!(
3009 effective_fallback(SandboxProfile::OsHardened),
3010 SandboxFallback::Enforce
3011 );
3012 }
3013
3014 #[test]
3015 fn unrestricted_profile_skips_active_sandbox() {
3016 let policy = CapabilityPolicy {
3017 sandbox_profile: SandboxProfile::Unrestricted,
3018 workspace_roots: vec!["/tmp".to_string()],
3019 ..Default::default()
3020 };
3021 crate::orchestration::push_execution_policy(policy);
3022 let result = active_sandbox_policy();
3023 crate::orchestration::pop_execution_policy();
3024 assert!(
3025 result.is_none(),
3026 "Unrestricted profile must short-circuit sandbox dispatch"
3027 );
3028 }
3029
3030 #[test]
3031 fn worktree_profile_engages_active_sandbox() {
3032 let policy = CapabilityPolicy {
3033 sandbox_profile: SandboxProfile::Worktree,
3034 workspace_roots: vec!["/tmp".to_string()],
3035 ..Default::default()
3036 };
3037 crate::orchestration::push_execution_policy(policy);
3038 let result = active_sandbox_policy();
3039 crate::orchestration::pop_execution_policy();
3040 assert!(
3041 result.is_some(),
3042 "Worktree profile must keep sandbox dispatch active"
3043 );
3044 }
3045}