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
61thread_local! {
62 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum FsAccess {
70 Read,
71 Write,
72 Delete,
73}
74
75#[derive(Clone, Debug, Default)]
76pub struct ProcessCommandConfig {
77 pub cwd: Option<PathBuf>,
78 pub env: Vec<(String, String)>,
79 pub stdin_null: bool,
80}
81
82#[derive(Clone, Debug, Default)]
83pub struct ProcessSandboxScope {
84 pub workspace_roots: Vec<String>,
85}
86
87#[must_use]
88pub struct ProcessSandboxScopeGuard {
89 pushed: bool,
90}
91
92impl Drop for ProcessSandboxScopeGuard {
93 fn drop(&mut self) {
94 if self.pushed {
95 crate::orchestration::pop_execution_policy();
96 }
97 }
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub(crate) enum SandboxFallback {
102 Off,
103 Warn,
104 Enforce,
105}
106
107pub(crate) trait SandboxBackend {
118 fn name() -> &'static str;
120
121 fn available() -> bool;
125
126 fn prepare_std_command(
132 program: &str,
133 args: &[String],
134 command: &mut Command,
135 policy: &CapabilityPolicy,
136 profile: SandboxProfile,
137 ) -> Result<PrepareOutcome, VmError>;
138
139 fn prepare_tokio_command(
141 program: &str,
142 args: &[String],
143 command: &mut tokio::process::Command,
144 policy: &CapabilityPolicy,
145 profile: SandboxProfile,
146 ) -> Result<PrepareOutcome, VmError>;
147
148 fn run_to_output(
153 program: &str,
154 args: &[String],
155 config: &ProcessCommandConfig,
156 policy: &CapabilityPolicy,
157 profile: SandboxProfile,
158 ) -> Result<Output, VmError> {
159 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
160 apply_process_config(&mut command, config);
161 crate::op_interrupt::capture_output_interruptible(&mut command)
162 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
163 }
164}
165
166pub(crate) enum PrepareOutcome {
170 Direct,
172 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
178 WrappedExec { wrapper: String, args: Vec<String> },
179}
180
181#[cfg(target_os = "linux")]
182type ActiveBackend = linux::Backend;
183#[cfg(target_os = "macos")]
184type ActiveBackend = macos::Backend;
185#[cfg(target_os = "openbsd")]
186type ActiveBackend = openbsd::Backend;
187#[cfg(target_os = "windows")]
188type ActiveBackend = windows::Backend;
189#[cfg(not(any(
190 target_os = "linux",
191 target_os = "macos",
192 target_os = "openbsd",
193 target_os = "windows"
194)))]
195type ActiveBackend = NoopBackend;
196
197#[cfg(not(any(
198 target_os = "linux",
199 target_os = "macos",
200 target_os = "openbsd",
201 target_os = "windows"
202)))]
203pub(crate) struct NoopBackend;
204
205#[cfg(not(any(
206 target_os = "linux",
207 target_os = "macos",
208 target_os = "openbsd",
209 target_os = "windows"
210)))]
211impl SandboxBackend for NoopBackend {
212 fn name() -> &'static str {
213 "noop"
214 }
215 fn available() -> bool {
216 false
217 }
218 fn prepare_std_command(
219 _program: &str,
220 _args: &[String],
221 _command: &mut Command,
222 _policy: &CapabilityPolicy,
223 _profile: SandboxProfile,
224 ) -> Result<PrepareOutcome, VmError> {
225 Ok(PrepareOutcome::Direct)
226 }
227 fn prepare_tokio_command(
228 _program: &str,
229 _args: &[String],
230 _command: &mut tokio::process::Command,
231 _policy: &CapabilityPolicy,
232 _profile: SandboxProfile,
233 ) -> Result<PrepareOutcome, VmError> {
234 Ok(PrepareOutcome::Direct)
235 }
236}
237
238pub(crate) fn reset_sandbox_state() {
239 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
240}
241
242pub fn active_backend_name() -> &'static str {
246 ActiveBackend::name()
247}
248
249pub fn active_backend_available() -> bool {
254 ActiveBackend::available()
255}
256
257pub fn register_sandbox_builtins(vm: &mut Vm) {
261 for def in MODULE_BUILTINS {
262 vm.register_builtin_def(def);
263 }
264}
265
266pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
267 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
268 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
269 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
270];
271
272#[crate::stdlib::macros::harn_builtin(
273 sig = "sandbox_active_backend() -> string",
274 category = "sandbox"
275)]
276fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277 Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
278}
279
280#[crate::stdlib::macros::harn_builtin(
281 sig = "sandbox_backend_available() -> bool",
282 category = "sandbox"
283)]
284fn sandbox_backend_available_impl(
285 _args: &[VmValue],
286 _out: &mut String,
287) -> Result<VmValue, VmError> {
288 Ok(VmValue::Bool(active_backend_available()))
289}
290
291#[crate::stdlib::macros::harn_builtin(
292 sig = "sandbox_active_profile() -> string",
293 category = "sandbox"
294)]
295fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
296 let profile = crate::orchestration::current_execution_policy()
297 .map(|policy| policy.sandbox_profile)
298 .unwrap_or(SandboxProfile::Unrestricted);
299 Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
300}
301
302#[derive(Clone, Debug)]
309pub struct SandboxViolation {
310 pub attempted: PathBuf,
314 pub roots: Vec<PathBuf>,
317 pub access: FsAccess,
319 pub read_only: bool,
323}
324
325impl SandboxViolation {
326 pub fn message(&self, builtin: &str) -> String {
330 if self.read_only {
331 return format!(
332 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
333 self.access.verb(),
334 self.attempted.display(),
335 );
336 }
337 format!(
338 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
339 self.access.verb(),
340 self.attempted.display(),
341 self.roots
342 .iter()
343 .map(|root| root.display().to_string())
344 .collect::<Vec<_>>()
345 .join(", ")
346 )
347 }
348}
349
350pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
365 let Some(policy) = crate::orchestration::current_execution_policy() else {
366 return Ok(());
367 };
368 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
369 return Ok(());
370 }
371 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
382 return Ok(());
383 }
384 let candidate = normalize_for_policy(path);
385 let roots = normalized_workspace_roots(&policy);
386 if roots.iter().any(|root| path_is_within(&candidate, root)) {
387 return Ok(());
388 }
389 let read_only_roots = normalized_read_only_roots(&policy);
390 let within_read_only = read_only_roots
391 .iter()
392 .any(|root| path_is_within(&candidate, root));
393 if within_read_only && access == FsAccess::Read {
394 return Ok(());
395 }
396 Err(SandboxViolation {
397 attempted: candidate,
398 roots,
399 access,
400 read_only: within_read_only,
401 })
402}
403
404pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
405 check_fs_path_scope(path, access)
406 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
407}
408
409pub(crate) fn atomic_write_scoped_at_open(
410 builtin: &str,
411 path: &Path,
412 contents: &[u8],
413) -> io::Result<()> {
414 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
415 return atomic_write_unscoped(path, contents);
416 };
417 atomic_write_scoped_target(&target, contents)
418}
419
420pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
421 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
422 return append_unscoped(path, contents);
423 };
424 append_scoped_target(&target, contents)
425}
426
427pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
428 let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
429 return std::fs::copy(src, dst);
430 };
431 copy_scoped_target(src, &target)
432}
433
434pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
435 let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
436 return std::fs::rename(src, dst);
437 };
438 let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
439 io::Error::new(
440 io::ErrorKind::PermissionDenied,
441 format!(
442 "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
443 dst.display()
444 ),
445 )
446 })?;
447 rename_scoped_targets(&src_target, &dst_target)
448}
449
450pub(crate) fn create_dir_scoped_at_open(
451 builtin: &str,
452 path: &Path,
453 recursive: bool,
454) -> io::Result<()> {
455 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
456 return if recursive {
457 std::fs::create_dir_all(path)
458 } else {
459 std::fs::create_dir(path)
460 };
461 };
462 if recursive {
463 create_dir_all_scoped_target(&target)
464 } else {
465 create_dir_scoped_target(&target)
466 }
467}
468
469#[derive(Clone, Debug)]
470struct ScopedMutationTarget {
471 root: PathBuf,
472 relative: PathBuf,
473}
474
475fn scoped_mutation_target(
476 builtin: &str,
477 path: &Path,
478 access: FsAccess,
479) -> io::Result<Option<ScopedMutationTarget>> {
480 let Some(policy) = crate::orchestration::current_execution_policy() else {
481 return Ok(None);
482 };
483 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
484 return Ok(None);
485 }
486 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
487 return Ok(None);
488 }
489 check_fs_path_scope(path, access).map_err(|violation| {
490 io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
491 })?;
492 let candidate = normalize_for_policy(path);
493 let roots = normalized_workspace_roots(&policy);
494 let Some(root) = roots
495 .into_iter()
496 .find(|root| path_is_within(&candidate, root))
497 else {
498 return Err(io::Error::new(
499 io::ErrorKind::PermissionDenied,
500 format!(
501 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
502 access.verb(),
503 candidate.display()
504 ),
505 ));
506 };
507 let relative = candidate.strip_prefix(&root).map_err(|_| {
508 io::Error::new(
509 io::ErrorKind::PermissionDenied,
510 format!(
511 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
512 access.verb(),
513 candidate.display(),
514 root.display()
515 ),
516 )
517 })?;
518 if relative.as_os_str().is_empty() {
519 return Err(io::Error::new(
520 io::ErrorKind::InvalidInput,
521 format!(
522 "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
523 access.verb(),
524 root.display()
525 ),
526 ));
527 }
528 Ok(Some(ScopedMutationTarget {
529 root,
530 relative: relative.to_path_buf(),
531 }))
532}
533
534fn atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
535 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
536 let dir = parent.unwrap_or_else(|| Path::new("."));
537 if let Some(parent) = parent {
542 std::fs::create_dir_all(parent)?;
543 }
544 let tmp_path = dir.join(scoped_tmp_name(path));
545 let write_result = (|| -> io::Result<()> {
546 let mut file = std::fs::File::create(&tmp_path)?;
547 file.write_all(contents)?;
548 file.flush()?;
549 file.sync_all()?;
550 Ok(())
551 })();
552 if let Err(err) = write_result {
553 let _ = std::fs::remove_file(&tmp_path);
554 return Err(err);
555 }
556 if let Err(err) = std::fs::rename(&tmp_path, path) {
557 let _ = std::fs::remove_file(&tmp_path);
558 return Err(err);
559 }
560 Ok(())
561}
562
563fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
564 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
567 std::fs::create_dir_all(parent)?;
568 }
569 std::fs::OpenOptions::new()
570 .create(true)
571 .append(true)
572 .open(path)
573 .and_then(|mut file| file.write_all(contents))
574}
575
576fn scoped_tmp_name(path: &Path) -> String {
577 use std::sync::atomic::{AtomicU64, Ordering};
578 static COUNTER: AtomicU64 = AtomicU64::new(0);
579 let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
580 let file_name = path
581 .file_name()
582 .map(|name| name.to_string_lossy().into_owned())
583 .unwrap_or_else(|| "file".to_string());
584 format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id())
585}
586
587#[cfg(unix)]
588fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
589 use std::os::fd::AsRawFd;
590
591 ensure_parent_dirs_scoped(target)?;
596 let (parent, file_name) = open_parent_dir_scoped(target)?;
597 let tmp_name = scoped_tmp_name(Path::new(&file_name));
598 let mut file = openat_file(
599 parent.as_raw_fd(),
600 &tmp_name,
601 libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
602 0o666,
603 )?;
604 let write_result = (|| -> io::Result<()> {
605 file.write_all(contents)?;
606 file.flush()?;
607 file.sync_all()?;
608 Ok(())
609 })();
610 if let Err(err) = write_result {
611 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
612 return Err(err);
613 }
614 if let Err(err) = renameat_name(
615 parent.as_raw_fd(),
616 &tmp_name,
617 parent.as_raw_fd(),
618 &file_name,
619 ) {
620 let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
621 return Err(err);
622 }
623 sync_dir_fd(parent.as_raw_fd());
624 Ok(())
625}
626
627#[cfg(not(unix))]
628fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
629 let full = target.root.join(&target.relative);
630 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
631 std::fs::create_dir_all(parent)?;
632 }
633 atomic_write_unscoped(&full, contents)
634}
635
636#[cfg(unix)]
637fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
638 use std::os::fd::AsRawFd;
639
640 ensure_parent_dirs_scoped(target)?;
643 let (parent, file_name) = open_parent_dir_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)]
765fn ensure_parent_dirs_scoped(target: &ScopedMutationTarget) -> io::Result<()> {
766 use std::os::fd::AsRawFd;
767
768 let mut components = clean_relative_components(&target.relative)?;
769 if components.pop().is_none() || components.is_empty() {
772 return Ok(());
773 }
774 let mut current = open_dir_absolute(&target.root)?;
775 for component in components {
776 match open_dir_at(current.as_raw_fd(), &component) {
777 Ok(next) => current = next,
778 Err(error) if error.kind() == io::ErrorKind::NotFound => {
779 if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
780 if mkerr.kind() != io::ErrorKind::AlreadyExists {
783 return Err(mkerr);
784 }
785 }
786 current = open_dir_at(current.as_raw_fd(), &component)?;
787 }
788 Err(error) => return Err(error),
789 }
790 }
791 Ok(())
792}
793
794#[cfg(unix)]
795fn open_parent_dir_scoped(
796 target: &ScopedMutationTarget,
797) -> io::Result<(std::os::fd::OwnedFd, String)> {
798 use std::os::fd::AsRawFd;
799
800 let mut components = clean_relative_components(&target.relative)?;
801 let file_name = components.pop().ok_or_else(|| {
802 io::Error::new(
803 io::ErrorKind::InvalidInput,
804 format!(
805 "sandbox scoped open requires a file name: {}",
806 target.relative.display()
807 ),
808 )
809 })?;
810 let root = open_dir_absolute(&target.root)?;
811 let mut current = root;
812 for component in components {
813 current = open_dir_at(current.as_raw_fd(), &component)?;
814 }
815 Ok((current, file_name))
816}
817
818#[cfg(unix)]
819fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
820 use std::os::unix::ffi::OsStrExt;
821
822 let mut out = Vec::new();
823 for component in path.components() {
824 match component {
825 Component::Normal(value) => {
826 let bytes = value.as_bytes();
827 if bytes.contains(&0) {
828 return Err(io::Error::new(
829 io::ErrorKind::InvalidInput,
830 format!("path component contains NUL: {}", path.display()),
831 ));
832 }
833 out.push(value.to_string_lossy().into_owned());
834 }
835 Component::CurDir => {}
836 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
837 return Err(io::Error::new(
838 io::ErrorKind::InvalidInput,
839 format!("sandbox scoped path must stay relative: {}", path.display()),
840 ));
841 }
842 }
843 }
844 Ok(out)
845}
846
847#[cfg(unix)]
848fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
849 use std::os::fd::{FromRawFd, OwnedFd};
850 use std::os::unix::ffi::OsStrExt;
851
852 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
853 io::Error::new(
854 io::ErrorKind::InvalidInput,
855 format!("path contains NUL: {}", path.display()),
856 )
857 })?;
858 let fd = unsafe {
859 libc::open(
860 c_path.as_ptr(),
861 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
862 )
863 };
864 if fd < 0 {
865 return Err(io::Error::last_os_error());
866 }
867 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
868}
869
870#[cfg(unix)]
871fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
872 use std::os::fd::{FromRawFd, OwnedFd};
873
874 let c_name = c_name(name)?;
875 let fd = unsafe {
876 libc::openat(
877 parent_fd,
878 c_name.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 openat_file(
890 parent_fd: libc::c_int,
891 name: &str,
892 flags: libc::c_int,
893 mode: libc::mode_t,
894) -> io::Result<std::fs::File> {
895 use std::os::fd::FromRawFd;
896
897 let c_name = c_name(name)?;
898 let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
899 if fd < 0 {
900 return Err(io::Error::last_os_error());
901 }
902 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
903}
904
905#[cfg(unix)]
906fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
907 let c_name = c_name(name)?;
908 let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
909 if rc != 0 {
910 return Err(io::Error::last_os_error());
911 }
912 Ok(())
913}
914
915#[cfg(unix)]
916fn renameat_name(
917 old_parent_fd: libc::c_int,
918 old_name: &str,
919 new_parent_fd: libc::c_int,
920 new_name: &str,
921) -> io::Result<()> {
922 let old_name = c_name(old_name)?;
923 let new_name = c_name(new_name)?;
924 let rc = unsafe {
925 libc::renameat(
926 old_parent_fd,
927 old_name.as_ptr(),
928 new_parent_fd,
929 new_name.as_ptr(),
930 )
931 };
932 if rc != 0 {
933 return Err(io::Error::last_os_error());
934 }
935 Ok(())
936}
937
938#[cfg(unix)]
939fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
940 let c_name = c_name(name)?;
941 let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
942 if rc != 0 {
943 return Err(io::Error::last_os_error());
944 }
945 Ok(())
946}
947
948#[cfg(unix)]
949fn sync_dir_fd(fd: libc::c_int) {
950 let _ = unsafe { libc::fsync(fd) };
951}
952
953#[cfg(unix)]
954fn c_name(name: &str) -> io::Result<std::ffi::CString> {
955 std::ffi::CString::new(name).map_err(|_| {
956 io::Error::new(
957 io::ErrorKind::InvalidInput,
958 format!("path component contains NUL: {name:?}"),
959 )
960 })
961}
962
963pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
964 let Some(policy) = crate::orchestration::current_execution_policy() else {
965 return Ok(());
966 };
967 enforce_process_cwd_for_policy(path, &policy)
968}
969
970pub fn push_process_sandbox_scope(
971 scope: ProcessSandboxScope,
972) -> Result<ProcessSandboxScopeGuard, VmError> {
973 let Some(mut policy) = crate::orchestration::current_execution_policy() else {
974 return Ok(ProcessSandboxScopeGuard { pushed: false });
975 };
976 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
977 return Ok(ProcessSandboxScopeGuard { pushed: false });
978 }
979
980 let requested_roots: Vec<PathBuf> = scope
981 .workspace_roots
982 .iter()
983 .filter_map(|root| {
984 let trimmed = root.trim();
985 (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
986 })
987 .collect();
988 if requested_roots.is_empty() {
989 return Ok(ProcessSandboxScopeGuard { pushed: false });
990 }
991
992 if !policy.workspace_roots.is_empty() {
993 let ceiling_roots = normalized_workspace_roots(&policy);
994 if let Some(rejected) = requested_roots.iter().find(|root| {
995 !ceiling_roots
996 .iter()
997 .any(|ceiling| path_is_within(root, ceiling))
998 }) {
999 return Err(sandbox_rejection(format!(
1000 "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1001 rejected.display(),
1002 ceiling_roots
1003 .iter()
1004 .map(|root| root.display().to_string())
1005 .collect::<Vec<_>>()
1006 .join(", ")
1007 )));
1008 }
1009 }
1010
1011 let mut merged_roots = if policy.workspace_roots.is_empty() {
1012 Vec::new()
1013 } else {
1014 normalized_workspace_roots(&policy)
1015 };
1016 for requested in requested_roots {
1017 if !merged_roots
1018 .iter()
1019 .any(|existing| path_is_within(&requested, existing))
1020 {
1021 merged_roots.push(requested);
1022 }
1023 }
1024 policy.workspace_roots = merged_roots
1025 .into_iter()
1026 .map(|root| root.display().to_string())
1027 .collect();
1028 crate::orchestration::push_execution_policy(policy);
1029 Ok(ProcessSandboxScopeGuard { pushed: true })
1030}
1031
1032fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1033 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1034 return Ok(());
1035 }
1036 let candidate = normalize_for_policy(path);
1037 let roots = normalized_workspace_roots(policy);
1038 if roots.iter().any(|root| path_is_within(&candidate, root)) {
1039 return Ok(());
1040 }
1041 Err(sandbox_rejection(format!(
1042 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1043 candidate.display(),
1044 roots
1045 .iter()
1046 .map(|root| root.display().to_string())
1047 .collect::<Vec<_>>()
1048 .join(", ")
1049 )))
1050}
1051
1052pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1053 let (policy, profile) = match active_sandbox_policy() {
1054 Some(value) => value,
1055 None => {
1056 let mut command = Command::new(program);
1057 command.args(args);
1058 return Ok(command);
1059 }
1060 };
1061 build_std_command::<ActiveBackend>(program, args, &policy, profile)
1062}
1063
1064pub fn tokio_command_for(
1065 program: &str,
1066 args: &[String],
1067) -> Result<tokio::process::Command, VmError> {
1068 let (policy, profile) = match active_sandbox_policy() {
1069 Some(value) => value,
1070 None => {
1071 let mut command = tokio::process::Command::new(program);
1072 command.args(args);
1073 return Ok(command);
1074 }
1075 };
1076 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1077}
1078
1079pub fn command_output(
1080 program: &str,
1081 args: &[String],
1082 config: &ProcessCommandConfig,
1083) -> Result<Output, VmError> {
1084 if let Some(intercepted) =
1089 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1090 {
1091 return intercepted.map_err(|message| {
1092 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1093 });
1094 }
1095
1096 let recording =
1097 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1098
1099 let output = match active_sandbox_policy() {
1100 Some((policy, profile)) => {
1101 let config = sandboxed_process_config(config, &policy)?;
1102 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1103 }
1104 None => {
1105 let mut command = Command::new(program);
1106 command.args(args);
1107 apply_process_config(&mut command, config);
1108 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1113 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1114 })?
1115 }
1116 };
1117 if let Some(error) = process_violation_error(&output) {
1118 return Err(error);
1119 }
1120 if let Some(span) = recording {
1121 span.finish(&output);
1122 }
1123 Ok(output)
1124}
1125
1126fn sandboxed_process_config(
1127 config: &ProcessCommandConfig,
1128 policy: &CapabilityPolicy,
1129) -> Result<ProcessCommandConfig, VmError> {
1130 let mut resolved = config.clone();
1131 if let Some(cwd) = resolved.cwd.as_ref() {
1132 enforce_process_cwd_for_policy(cwd, policy)?;
1133 } else {
1134 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1135 }
1136 neutralize_rustc_wrapper(&mut resolved.env);
1137 inject_workspace_tmpdir(&mut resolved.env, policy);
1138 Ok(resolved)
1139}
1140
1141fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1155 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1156 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1157 entry.1.clear();
1158 } else {
1159 env.push((key.to_string(), String::new()));
1160 }
1161 }
1162}
1163
1164pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1170
1171pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1175
1176pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1193 let root = normalized_workspace_roots(policy).into_iter().next()?;
1194 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1195 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1196 warn_once(
1197 "handler_sandbox_workspace_tmpdir",
1198 &format!(
1199 "could not create workspace-local temp dir '{}': {error}; \
1200 leaving the child's inherited temp dir in place",
1201 tmpdir.display()
1202 ),
1203 );
1204 return None;
1205 }
1206 let ignore = tmpdir.join(".gitignore");
1212 if !ignore.exists() {
1213 let _ = std::fs::write(
1214 &ignore,
1215 "# Created by the Harn sandbox; safe to delete.\n*\n",
1216 );
1217 }
1218 Some(tmpdir)
1219}
1220
1221pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1231 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1232 return;
1233 }
1234 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1235 return;
1236 };
1237 let tmpdir = tmpdir.display().to_string();
1238 for key in TMPDIR_ENV_KEYS {
1239 if env.iter().any(|(existing, _)| existing == key) {
1240 continue;
1242 }
1243 env.push((key.to_string(), tmpdir.clone()));
1244 }
1245}
1246
1247pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1262 let Some(policy) = crate::orchestration::current_execution_policy() else {
1263 return Vec::new();
1264 };
1265 let mut env = Vec::new();
1266 inject_workspace_tmpdir(&mut env, &policy);
1267 env
1268}
1269
1270pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1295 vec![
1296 ("LC_MESSAGES".to_string(), "C".to_string()),
1297 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1298 ]
1299}
1300
1301pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1306
1307fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1308 let roots = normalized_workspace_roots(policy);
1309 let current = std::env::current_dir().map_err(|error| {
1310 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1311 format!("process cwd resolution failed: {error}"),
1312 )))
1313 })?;
1314 let current = normalize_for_policy(¤t);
1315 if roots.iter().any(|root| path_is_within(¤t, root)) {
1316 return Ok(current);
1317 }
1318 roots.first().cloned().ok_or_else(|| {
1319 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1320 "process cwd resolution failed: no workspace root available",
1321 )))
1322 })
1323}
1324
1325fn build_std_command<B: SandboxBackend + ?Sized>(
1326 program: &str,
1327 args: &[String],
1328 policy: &CapabilityPolicy,
1329 profile: SandboxProfile,
1330) -> Result<Command, VmError> {
1331 let mut command = Command::new(program);
1332 command.args(args);
1333 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1334 PrepareOutcome::Direct => Ok(command),
1335 PrepareOutcome::WrappedExec { wrapper, args } => {
1336 let mut wrapped = Command::new(wrapper);
1337 wrapped.args(args);
1338 Ok(wrapped)
1339 }
1340 }
1341}
1342
1343fn build_tokio_command<B: SandboxBackend + ?Sized>(
1344 program: &str,
1345 args: &[String],
1346 policy: &CapabilityPolicy,
1347 profile: SandboxProfile,
1348) -> Result<tokio::process::Command, VmError> {
1349 let mut command = tokio::process::Command::new(program);
1350 command.args(args);
1351 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1352 PrepareOutcome::Direct => Ok(command),
1353 PrepareOutcome::WrappedExec { wrapper, args } => {
1354 let mut wrapped = tokio::process::Command::new(wrapper);
1355 wrapped.args(args);
1356 Ok(wrapped)
1357 }
1358 }
1359}
1360
1361pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1362 let policy = crate::orchestration::current_execution_policy()?;
1363 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1364 return None;
1365 }
1366 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1367 || !ActiveBackend::available()
1368 {
1369 return None;
1370 }
1371 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1372 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1373 if !output.status.success()
1374 && (stderr.contains("operation not permitted")
1375 || stderr.contains("permission denied")
1376 || stderr.contains("access is denied")
1377 || stdout.contains("operation not permitted"))
1378 {
1379 return Some(sandbox_rejection(sandbox_process_violation_message(
1380 format!(
1381 "sandbox violation: process was denied by the OS sandbox (status {})",
1382 output.status.code().unwrap_or(-1)
1383 ),
1384 )));
1385 }
1386 if sandbox_signal_status(output) {
1387 return Some(sandbox_rejection(sandbox_process_violation_message(
1388 format!(
1389 "sandbox violation: process was terminated by the OS sandbox (status {})",
1390 output.status
1391 ),
1392 )));
1393 }
1394 None
1395}
1396
1397pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1398 let policy = crate::orchestration::current_execution_policy()?;
1399 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1400 return None;
1401 }
1402 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1403 || !ActiveBackend::available()
1404 {
1405 return None;
1406 }
1407 let message = error.to_string().to_ascii_lowercase();
1408 if error.kind() == std::io::ErrorKind::PermissionDenied
1409 || message.contains("operation not permitted")
1410 || message.contains("permission denied")
1411 || message.contains("access is denied")
1412 {
1413 return Some(sandbox_rejection(sandbox_process_violation_message(
1414 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1415 )));
1416 }
1417 None
1418}
1419
1420#[cfg(unix)]
1421fn sandbox_signal_status(output: &std::process::Output) -> bool {
1422 use std::os::unix::process::ExitStatusExt;
1423
1424 matches!(
1425 output.status.signal(),
1426 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1427 )
1428}
1429
1430#[cfg(not(unix))]
1431fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1432 false
1433}
1434
1435pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1443 let policy = crate::orchestration::current_execution_policy()?;
1444 let profile = policy.sandbox_profile;
1445 match profile {
1446 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1447 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1448 if effective_fallback(profile) == SandboxFallback::Off {
1449 None
1450 } else {
1451 Some((policy, profile))
1452 }
1453 }
1454 }
1455}
1456
1457fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1458 if let Some(cwd) = config.cwd.as_ref() {
1459 command.current_dir(cwd);
1460 }
1461 command.envs(config.env.iter().map(|(key, value)| (key, value)));
1462 if config.stdin_null {
1463 command.stdin(Stdio::null());
1464 }
1465}
1466
1467fn spawn_error(error: std::io::Error) -> VmError {
1468 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1469 format!("process spawn failed: {error}"),
1470 )))
1471}
1472
1473pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1478 if matches!(profile, SandboxProfile::OsHardened) {
1479 return SandboxFallback::Enforce;
1480 }
1481 match std::env::var(HANDLER_SANDBOX_ENV)
1482 .unwrap_or_else(|_| "warn".to_string())
1483 .trim()
1484 .to_ascii_lowercase()
1485 .as_str()
1486 {
1487 "0" | "false" | "off" | "none" => SandboxFallback::Off,
1488 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1489 _ => SandboxFallback::Warn,
1490 }
1491}
1492
1493pub(crate) fn warn_once(key: &str, message: &str) {
1494 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1495 if inserted {
1496 crate::events::log_warn("handler_sandbox", message);
1497 }
1498}
1499
1500pub(crate) fn sandbox_rejection(message: String) -> VmError {
1501 VmError::CategorizedError {
1502 message,
1503 category: ErrorCategory::ToolRejected,
1504 }
1505}
1506
1507fn sandbox_process_violation_message(summary: String) -> String {
1508 format!(
1509 "{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"
1510 )
1511}
1512
1513#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1523pub(crate) fn unavailable(
1524 message: &str,
1525 profile: SandboxProfile,
1526) -> Result<PrepareOutcome, VmError> {
1527 match effective_fallback(profile) {
1528 SandboxFallback::Off | SandboxFallback::Warn => {
1529 warn_once("handler_sandbox_unavailable", message);
1530 Ok(PrepareOutcome::Direct)
1531 }
1532 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1533 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1534 ))),
1535 }
1536}
1537
1538fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1546 let session_id = crate::agent_sessions::current_session_id()?;
1547 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1548 let mut roots = vec![anchor.primary.clone()];
1549 for mounted in &anchor.additional_roots {
1550 if matches!(
1551 mounted.mount_mode,
1552 crate::workspace_anchor::MountMode::Extend
1553 ) {
1554 roots.push(mounted.path.clone());
1555 }
1556 }
1557 Some(roots)
1558}
1559
1560fn project_root_env_workspace_root() -> Option<PathBuf> {
1568 std::env::var("HARN_PROJECT_ROOT")
1569 .ok()
1570 .map(|value| value.trim().to_string())
1571 .filter(|value| !value.is_empty())
1572 .map(PathBuf::from)
1573}
1574
1575fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1576 if policy.workspace_roots.is_empty() {
1577 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1592 return anchor_roots
1593 .iter()
1594 .map(|root| normalize_for_policy(root))
1595 .collect();
1596 }
1597 if let Some(project_root) = project_root_env_workspace_root() {
1598 return vec![normalize_for_policy(&project_root)];
1599 }
1600 return vec![normalize_for_policy(
1601 &crate::stdlib::process::execution_root_path(),
1602 )];
1603 }
1604 policy
1605 .workspace_roots
1606 .iter()
1607 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1608 .collect()
1609}
1610
1611pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1612 normalized_workspace_roots(policy)
1613}
1614
1615fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1620 policy
1621 .read_only_roots
1622 .iter()
1623 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1624 .collect()
1625}
1626
1627#[cfg(any(
1628 target_os = "linux",
1629 target_os = "macos",
1630 target_os = "openbsd",
1631 target_os = "windows"
1632))]
1633pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1634 normalized_read_only_roots(policy)
1635}
1636
1637#[cfg(any(
1638 target_os = "linux",
1639 target_os = "macos",
1640 target_os = "openbsd",
1641 target_os = "windows"
1642))]
1643pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1644 normalized_process_roots(&policy.process_sandbox.read_roots)
1645}
1646
1647#[cfg(any(
1648 target_os = "linux",
1649 target_os = "macos",
1650 target_os = "openbsd",
1651 target_os = "windows"
1652))]
1653pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1654 normalized_process_roots(&policy.process_sandbox.write_roots)
1655}
1656
1657#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1658pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1659 policy.process_sandbox.effective_presets()
1660}
1661
1662#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1663pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1664 policy: &CapabilityPolicy,
1665) -> Vec<PathBuf> {
1666 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1667 return Vec::new();
1668 }
1669 let Some(home) = sandbox_user_home_dir() else {
1670 return Vec::new();
1671 };
1672 developer_toolchain_read_roots_for_home(&home)
1673}
1674
1675#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1676pub(crate) fn process_sandbox_package_manager_config_read_roots(
1677 policy: &CapabilityPolicy,
1678) -> Vec<PathBuf> {
1679 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1680 return Vec::new();
1681 }
1682 let Some(home) = sandbox_user_home_dir() else {
1683 return Vec::new();
1684 };
1685 package_manager_config_read_roots_for_home(&home)
1686}
1687
1688#[cfg(any(target_os = "linux", target_os = "macos"))]
1703pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1704 policy: &CapabilityPolicy,
1705) -> Vec<PathBuf> {
1706 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1707 return Vec::new();
1708 }
1709 let Some(home) = sandbox_user_home_dir() else {
1710 return Vec::new();
1711 };
1712 developer_toolchain_cache_write_roots_for_home(&home)
1713}
1714
1715#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1716fn sandbox_user_home_dir() -> Option<PathBuf> {
1717 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1720}
1721
1722#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1723pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1724 let mut roots: Vec<_> = [
1725 ".asdf",
1726 ".bun",
1727 ".cargo",
1728 ".fnm",
1729 ".juliaup",
1730 ".local/bin",
1731 ".local/share/mise",
1732 ".local/share/uv",
1733 ".nvm",
1734 ".pyenv",
1735 ".rbenv",
1736 ".rustup",
1737 ".sdkman",
1738 ".swiftly",
1739 ".volta",
1740 "go",
1741 ]
1742 .into_iter()
1743 .map(|entry| normalize_for_policy(&home.join(entry)))
1744 .collect();
1745 #[cfg(target_os = "windows")]
1746 roots.extend(
1747 [
1748 "AppData/Local/Programs/Python",
1749 "AppData/Local/uv",
1750 "AppData/Roaming/uv",
1751 "scoop",
1752 ]
1753 .into_iter()
1754 .map(|entry| normalize_for_policy(&home.join(entry))),
1755 );
1756 roots.sort_unstable();
1757 roots.dedup();
1758 roots
1759}
1760
1761#[cfg(any(target_os = "linux", target_os = "macos"))]
1766pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1767 let mut roots: Vec<_> = [
1768 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
1774 .into_iter()
1775 .map(|entry| normalize_for_policy(&home.join(entry)))
1776 .collect();
1777 roots.sort_unstable();
1778 roots.dedup();
1779 roots
1780}
1781
1782#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1783pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1784 let mut roots: Vec<_> = [
1785 ".npmrc",
1786 ".gitconfig",
1787 ".netrc",
1788 ".yarnrc.yml",
1789 ".config",
1790 ".npm",
1791 ".cache",
1792 ".pip",
1793 ".pypirc",
1794 ".cargo/config",
1795 ".cargo/config.toml",
1796 ".cargo/credentials",
1797 ".cargo/credentials.toml",
1798 ".cargo/registry",
1799 ".cargo/git",
1800 ]
1801 .into_iter()
1802 .map(|entry| normalize_for_policy(&home.join(entry)))
1803 .collect();
1804 roots.sort_unstable();
1805 roots.dedup();
1806 roots
1807}
1808
1809#[cfg(any(
1810 target_os = "linux",
1811 target_os = "macos",
1812 target_os = "openbsd",
1813 target_os = "windows"
1814))]
1815fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1816 roots
1817 .iter()
1818 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1819 .collect()
1820}
1821
1822fn resolve_policy_path(path: &str) -> PathBuf {
1823 let candidate = PathBuf::from(path);
1824 if candidate.is_absolute() {
1825 candidate
1826 } else {
1827 crate::stdlib::process::execution_root_path().join(candidate)
1828 }
1829}
1830
1831fn normalize_for_policy(path: &Path) -> PathBuf {
1832 let absolute = if path.is_absolute() {
1833 path.to_path_buf()
1834 } else {
1835 crate::stdlib::process::execution_root_path().join(path)
1836 };
1837 let absolute = normalize_lexically(&absolute);
1838 if let Ok(canonical) = absolute.canonicalize() {
1839 return canonical;
1840 }
1841
1842 let mut existing = absolute.as_path();
1843 let mut suffix = Vec::new();
1844 while !existing.exists() {
1845 let Some(parent) = existing.parent() else {
1846 return normalize_lexically(&absolute);
1847 };
1848 if let Some(name) = existing.file_name() {
1849 suffix.push(name.to_os_string());
1850 }
1851 existing = parent;
1852 }
1853
1854 let mut normalized = existing
1855 .canonicalize()
1856 .unwrap_or_else(|_| normalize_lexically(existing));
1857 for component in suffix.iter().rev() {
1858 normalized.push(component);
1859 }
1860 normalize_lexically(&normalized)
1861}
1862
1863fn normalize_lexically(path: &Path) -> PathBuf {
1864 let mut normalized = PathBuf::new();
1865 for component in path.components() {
1866 match component {
1867 Component::CurDir => {}
1868 Component::ParentDir => {
1869 normalized.pop();
1870 }
1871 other => normalized.push(other.as_os_str()),
1872 }
1873 }
1874 normalized
1875}
1876
1877fn path_is_within(path: &Path, root: &Path) -> bool {
1878 path == root || path.starts_with(root)
1879}
1880
1881fn normalize_io_device_path(path: &Path) -> PathBuf {
1886 let absolute = if path.is_absolute() {
1887 path.to_path_buf()
1888 } else {
1889 crate::stdlib::process::execution_root_path().join(path)
1890 };
1891 normalize_lexically(&absolute)
1892}
1893
1894fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1899 match access {
1900 FsAccess::Read => {
1901 matches!(
1902 path.to_str(),
1903 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1904 ) || is_dev_fd_descriptor(path)
1905 }
1906 FsAccess::Write => {
1907 matches!(
1908 path.to_str(),
1909 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1910 ) || is_dev_fd_descriptor(path)
1911 }
1912 FsAccess::Delete => false,
1913 }
1914}
1915
1916fn is_dev_fd_descriptor(path: &Path) -> bool {
1919 let Some(text) = path.to_str() else {
1920 return false;
1921 };
1922 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1923 return false;
1924 };
1925 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1926}
1927
1928#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1929pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1930 use crate::tool_annotations::SideEffectLevel;
1931 policy
1932 .side_effect_level
1933 .as_ref()
1934 .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
1935 .unwrap_or(true)
1936}
1937
1938#[cfg(any(
1939 target_os = "linux",
1940 target_os = "macos",
1941 target_os = "openbsd",
1942 target_os = "windows"
1943))]
1944pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1945 policy.capabilities.is_empty()
1946 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1947}
1948
1949#[cfg(any(
1950 target_os = "linux",
1951 target_os = "macos",
1952 target_os = "openbsd",
1953 target_os = "windows"
1954))]
1955pub(crate) fn policy_allows_capability(
1956 policy: &CapabilityPolicy,
1957 capability: &str,
1958 ops: &[&str],
1959) -> bool {
1960 policy
1961 .capabilities
1962 .get(capability)
1963 .map(|allowed| {
1964 ops.iter()
1965 .any(|op| allowed.iter().any(|candidate| candidate == op))
1966 })
1967 .unwrap_or(false)
1968}
1969
1970impl FsAccess {
1971 fn verb(self) -> &'static str {
1972 match self {
1973 FsAccess::Read => "read",
1974 FsAccess::Write => "write",
1975 FsAccess::Delete => "delete",
1976 }
1977 }
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1984
1985 #[test]
1986 fn missing_create_path_normalizes_against_existing_parent() {
1987 let dir = tempfile::tempdir().unwrap();
1988 let nested = dir.path().join("a/../new.txt");
1989 let normalized = normalize_for_policy(&nested);
1990 assert_eq!(
1991 normalized,
1992 normalize_for_policy(&dir.path().join("new.txt"))
1993 );
1994 }
1995
1996 #[test]
1997 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1998 let _env_lock = crate::runtime_paths::test_env_lock()
2002 .lock()
2003 .unwrap_or_else(|poisoned| poisoned.into_inner());
2004 std::env::remove_var("HARN_PROJECT_ROOT");
2005 let dir = tempfile::tempdir().unwrap();
2006 crate::stdlib::process::set_thread_execution_context(Some(
2007 crate::orchestration::RunExecutionRecord {
2008 cwd: Some(dir.path().to_string_lossy().into_owned()),
2009 source_dir: None,
2010 env: Default::default(),
2011 adapter: None,
2012 repo_path: None,
2013 worktree_path: None,
2014 branch: None,
2015 base_ref: None,
2016 cleanup: None,
2017 },
2018 ));
2019 push_execution_policy(CapabilityPolicy {
2020 sandbox_profile: SandboxProfile::Worktree,
2021 ..CapabilityPolicy::default()
2022 });
2023
2024 assert!(
2025 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2026 );
2027 let outside = tempfile::tempdir().unwrap();
2028 assert!(enforce_fs_path(
2029 "read_file",
2030 &outside.path().join("outside.txt"),
2031 FsAccess::Read
2032 )
2033 .is_err());
2034
2035 pop_execution_policy();
2036 crate::stdlib::process::set_thread_execution_context(None);
2037 }
2038
2039 #[test]
2049 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2050 let _env_lock = crate::runtime_paths::test_env_lock()
2051 .lock()
2052 .unwrap_or_else(|poisoned| poisoned.into_inner());
2053 let project = tempfile::tempdir().unwrap();
2054 let execution_cwd = tempfile::tempdir().unwrap();
2055 std::env::set_var("HARN_PROJECT_ROOT", project.path());
2056 crate::stdlib::process::set_thread_execution_context(Some(
2057 crate::orchestration::RunExecutionRecord {
2058 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2059 source_dir: None,
2060 env: Default::default(),
2061 adapter: None,
2062 repo_path: None,
2063 worktree_path: None,
2064 branch: None,
2065 base_ref: None,
2066 cleanup: None,
2067 },
2068 ));
2069 push_execution_policy(CapabilityPolicy {
2070 sandbox_profile: SandboxProfile::Worktree,
2071 ..CapabilityPolicy::default()
2072 });
2073
2074 assert!(
2077 enforce_fs_path(
2078 "write_file",
2079 &project.path().join("test/created.ts"),
2080 FsAccess::Write,
2081 )
2082 .is_ok(),
2083 "write into HARN_PROJECT_ROOT must be allowed"
2084 );
2085 assert!(
2089 enforce_fs_path(
2090 "write_file",
2091 &execution_cwd.path().join("escape.ts"),
2092 FsAccess::Write,
2093 )
2094 .is_err(),
2095 "write under the execution cwd (outside the project) must be rejected"
2096 );
2097
2098 pop_execution_policy();
2099 crate::stdlib::process::set_thread_execution_context(None);
2100 std::env::remove_var("HARN_PROJECT_ROOT");
2101 }
2102
2103 #[test]
2104 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2105 let dir = tempfile::tempdir().unwrap();
2106 crate::stdlib::process::set_thread_execution_context(Some(
2107 crate::orchestration::RunExecutionRecord {
2108 cwd: Some(dir.path().to_string_lossy().into_owned()),
2109 source_dir: None,
2110 env: Default::default(),
2111 adapter: None,
2112 repo_path: None,
2113 worktree_path: None,
2114 branch: None,
2115 base_ref: None,
2116 cleanup: None,
2117 },
2118 ));
2119 push_execution_policy(CapabilityPolicy {
2120 sandbox_profile: SandboxProfile::Worktree,
2121 ..CapabilityPolicy::default()
2122 });
2123
2124 assert!(enforce_process_cwd(dir.path()).is_ok());
2125 let outside = tempfile::tempdir().unwrap();
2126 assert!(enforce_process_cwd(outside.path()).is_err());
2127
2128 pop_execution_policy();
2129 crate::stdlib::process::set_thread_execution_context(None);
2130 }
2131
2132 #[test]
2133 fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2134 let _env_lock = crate::runtime_paths::test_env_lock()
2135 .lock()
2136 .unwrap_or_else(|poisoned| poisoned.into_inner());
2137 std::env::remove_var("HARN_PROJECT_ROOT");
2138 let execution_root = tempfile::tempdir().unwrap();
2139 let command_root = tempfile::tempdir().unwrap();
2140 crate::stdlib::process::set_thread_execution_context(Some(
2141 crate::orchestration::RunExecutionRecord {
2142 cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2143 source_dir: None,
2144 env: Default::default(),
2145 adapter: None,
2146 repo_path: None,
2147 worktree_path: None,
2148 branch: None,
2149 base_ref: None,
2150 cleanup: None,
2151 },
2152 ));
2153 push_execution_policy(CapabilityPolicy {
2154 sandbox_profile: SandboxProfile::Worktree,
2155 ..CapabilityPolicy::default()
2156 });
2157
2158 assert!(
2159 enforce_process_cwd(command_root.path()).is_err(),
2160 "before the scoped overlay the command temp root is outside the execution-root fallback",
2161 );
2162 {
2163 let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2164 workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2165 })
2166 .unwrap();
2167 assert!(
2168 enforce_process_cwd(command_root.path()).is_ok(),
2169 "scoped command root must be usable as the process cwd"
2170 );
2171 assert!(
2172 enforce_process_cwd(execution_root.path()).is_err(),
2173 "the scoped root must narrow the concrete spawn jail instead of widening it"
2174 );
2175 }
2176 assert!(
2177 enforce_process_cwd(command_root.path()).is_err(),
2178 "the scoped command root must pop after the command spawn"
2179 );
2180
2181 pop_execution_policy();
2182 crate::stdlib::process::set_thread_execution_context(None);
2183 }
2184
2185 #[test]
2186 fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2187 let workspace = tempfile::tempdir().unwrap();
2188 let inside = workspace.path().join("subdir");
2189 std::fs::create_dir(&inside).unwrap();
2190 let outside = tempfile::tempdir().unwrap();
2191 push_execution_policy(CapabilityPolicy {
2192 sandbox_profile: SandboxProfile::Worktree,
2193 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2194 ..CapabilityPolicy::default()
2195 });
2196
2197 assert!(
2198 push_process_sandbox_scope(ProcessSandboxScope {
2199 workspace_roots: vec![inside.to_string_lossy().into_owned()],
2200 })
2201 .is_ok(),
2202 "a command subroot inside the explicit workspace ceiling is allowed"
2203 );
2204 assert!(
2205 push_process_sandbox_scope(ProcessSandboxScope {
2206 workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2207 })
2208 .is_err(),
2209 "a command root outside the explicit workspace ceiling must be rejected"
2210 );
2211
2212 pop_execution_policy();
2213 }
2214
2215 #[cfg(unix)]
2216 #[test]
2217 fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2218 let workspace = tempfile::tempdir().unwrap();
2219 let outside = tempfile::tempdir().unwrap();
2220 let safe_parent = workspace.path().join("safe");
2221 std::fs::create_dir(&safe_parent).unwrap();
2222 let path = safe_parent.join("state.json");
2223 push_execution_policy(CapabilityPolicy {
2224 sandbox_profile: SandboxProfile::Worktree,
2225 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2226 ..CapabilityPolicy::default()
2227 });
2228 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2229 .unwrap()
2230 .expect("restricted policy yields scoped target");
2231
2232 std::fs::remove_dir(&safe_parent).unwrap();
2233 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2234 let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2235 pop_execution_policy();
2236
2237 assert!(
2238 !outside.path().join("state.json").exists(),
2239 "scoped write must not follow swapped parent symlink; error={error}"
2240 );
2241 }
2242
2243 #[cfg(unix)]
2244 #[test]
2245 fn scoped_write_creates_missing_parent_dirs() {
2246 let workspace = tempfile::tempdir().unwrap();
2252 let path = workspace.path().join("a/b/c/plan.json");
2253 push_execution_policy(CapabilityPolicy {
2254 sandbox_profile: SandboxProfile::Worktree,
2255 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2256 ..CapabilityPolicy::default()
2257 });
2258 let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2259 .unwrap()
2260 .expect("restricted policy yields scoped target");
2261 let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2262 pop_execution_policy();
2263
2264 assert!(
2265 result.is_ok(),
2266 "write must create missing parents: {result:?}"
2267 );
2268 assert_eq!(
2269 std::fs::read(&path).unwrap(),
2270 b"{\"plan\":\"Redis-backed\"}".to_vec()
2271 );
2272 }
2273
2274 #[cfg(unix)]
2275 #[test]
2276 fn scoped_append_creates_missing_parent_dirs() {
2277 let workspace = tempfile::tempdir().unwrap();
2278 let path = workspace.path().join("logs/deep/qa.jsonl");
2279 push_execution_policy(CapabilityPolicy {
2280 sandbox_profile: SandboxProfile::Worktree,
2281 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2282 ..CapabilityPolicy::default()
2283 });
2284 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2285 .unwrap()
2286 .expect("restricted policy yields scoped target");
2287 append_scoped_target(&target, b"line1\n").unwrap();
2288 append_scoped_target(&target, b"line2\n").unwrap();
2289 pop_execution_policy();
2290
2291 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2292 }
2293
2294 #[test]
2295 fn unscoped_write_creates_missing_parent_dirs() {
2296 let dir = tempfile::tempdir().unwrap();
2301 let path = dir.path().join("x/y/z/plan.json");
2302 atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2303 assert_eq!(
2304 std::fs::read(&path).unwrap(),
2305 b"{\"plan\":\"Redis-backed\"}".to_vec()
2306 );
2307 }
2308
2309 #[test]
2310 fn unscoped_append_creates_missing_parent_dirs() {
2311 let dir = tempfile::tempdir().unwrap();
2312 let path = dir.path().join("logs/deep/qa.jsonl");
2313 append_unscoped(&path, b"line1\n").unwrap();
2314 append_unscoped(&path, b"line2\n").unwrap();
2315 assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2316 }
2317
2318 #[cfg(unix)]
2319 #[test]
2320 fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
2321 let workspace = tempfile::tempdir().unwrap();
2325 let outside = tempfile::tempdir().unwrap();
2326 std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
2327 let path = workspace.path().join("escape/sub/plan.json");
2328 push_execution_policy(CapabilityPolicy {
2329 sandbox_profile: SandboxProfile::Worktree,
2330 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2331 ..CapabilityPolicy::default()
2332 });
2333 let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
2338 Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
2339 Ok(None) | Err(_) => false,
2340 };
2341 pop_execution_policy();
2342
2343 assert!(
2344 !escaped,
2345 "must not write through a symlinked intermediate dir"
2346 );
2347 assert!(
2348 !outside.path().join("sub/plan.json").exists(),
2349 "scoped write escaped the workspace via a symlinked parent"
2350 );
2351 }
2352
2353 #[cfg(unix)]
2354 #[test]
2355 fn scoped_append_rejects_final_symlink_created_after_policy_match() {
2356 let workspace = tempfile::tempdir().unwrap();
2357 let outside = tempfile::tempdir().unwrap();
2358 let safe_parent = workspace.path().join("safe");
2359 std::fs::create_dir(&safe_parent).unwrap();
2360 let outside_file = outside.path().join("state.log");
2361 std::fs::write(&outside_file, b"outside").unwrap();
2362 let path = safe_parent.join("state.log");
2363 push_execution_policy(CapabilityPolicy {
2364 sandbox_profile: SandboxProfile::Worktree,
2365 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2366 ..CapabilityPolicy::default()
2367 });
2368 let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2369 .unwrap()
2370 .expect("restricted policy yields scoped target");
2371
2372 std::os::unix::fs::symlink(&outside_file, &path).unwrap();
2373 let error = append_scoped_target(&target, b"\nescape").unwrap_err();
2374 pop_execution_policy();
2375
2376 assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
2377 assert!(
2378 error.raw_os_error() == Some(libc::ELOOP)
2379 || error.kind() == io::ErrorKind::PermissionDenied
2380 || error.kind() == io::ErrorKind::Other,
2381 "expected symlink refusal, got {error:?}"
2382 );
2383 }
2384
2385 #[cfg(unix)]
2386 #[test]
2387 fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
2388 let workspace = tempfile::tempdir().unwrap();
2389 let outside = tempfile::tempdir().unwrap();
2390 let safe_parent = workspace.path().join("safe");
2391 std::fs::create_dir(&safe_parent).unwrap();
2392 let path = safe_parent.join("nested/deeper");
2393 push_execution_policy(CapabilityPolicy {
2394 sandbox_profile: SandboxProfile::Worktree,
2395 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2396 ..CapabilityPolicy::default()
2397 });
2398 let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
2399 .unwrap()
2400 .expect("restricted policy yields scoped target");
2401
2402 std::fs::remove_dir(&safe_parent).unwrap();
2403 std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2404 let error = create_dir_all_scoped_target(&target).unwrap_err();
2405 pop_execution_policy();
2406
2407 assert!(
2408 !outside.path().join("nested").exists(),
2409 "scoped mkdir must not follow swapped parent symlink; error={error}"
2410 );
2411 }
2412
2413 #[test]
2414 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
2415 let cwd = std::env::current_dir().unwrap();
2416 let policy = CapabilityPolicy {
2417 sandbox_profile: SandboxProfile::Worktree,
2418 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2419 ..CapabilityPolicy::default()
2420 };
2421
2422 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2423
2424 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
2425 }
2426
2427 #[test]
2428 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
2429 let workspace = tempfile::tempdir().unwrap();
2430 let policy = CapabilityPolicy {
2431 sandbox_profile: SandboxProfile::Worktree,
2432 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2433 ..CapabilityPolicy::default()
2434 };
2435
2436 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2437
2438 assert_eq!(
2439 resolved.cwd.unwrap(),
2440 normalize_for_policy(workspace.path())
2441 );
2442 }
2443
2444 #[test]
2445 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
2446 let workspace = tempfile::tempdir().unwrap();
2447 let outside = tempfile::tempdir().unwrap();
2448 let policy = CapabilityPolicy {
2449 sandbox_profile: SandboxProfile::Worktree,
2450 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2451 ..CapabilityPolicy::default()
2452 };
2453 let config = ProcessCommandConfig {
2454 cwd: Some(outside.path().to_path_buf()),
2455 ..ProcessCommandConfig::default()
2456 };
2457
2458 assert!(sandboxed_process_config(&config, &policy).is_err());
2459 }
2460
2461 #[test]
2462 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
2463 let cwd = std::env::current_dir().unwrap();
2464 let policy = CapabilityPolicy {
2465 sandbox_profile: SandboxProfile::Worktree,
2466 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2467 ..CapabilityPolicy::default()
2468 };
2469
2470 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2473 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2474 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2475 assert_eq!(
2476 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
2477 Some("")
2478 );
2479 }
2480
2481 #[test]
2482 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
2483 let mut env = vec![
2486 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
2487 ("PATH".to_string(), "/usr/bin".to_string()),
2488 ];
2489 neutralize_rustc_wrapper(&mut env);
2490 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2491 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2492 assert_eq!(
2493 collected
2494 .get("CARGO_BUILD_RUSTC_WRAPPER")
2495 .map(String::as_str),
2496 Some("")
2497 );
2498 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
2499 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
2501 }
2502
2503 #[test]
2504 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
2505 let workspace = tempfile::tempdir().unwrap();
2506 let policy = CapabilityPolicy {
2507 sandbox_profile: SandboxProfile::Worktree,
2508 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2509 ..CapabilityPolicy::default()
2510 };
2511
2512 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
2513
2514 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
2517 assert!(
2518 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
2519 "temp dir {tmpdir:?} must be inside the writable workspace root"
2520 );
2521 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
2522 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
2524 assert!(
2525 ignore.lines().any(|line| line.trim() == "*"),
2526 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
2527 );
2528 push_execution_policy(policy);
2531 assert!(
2532 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
2533 "writes under the workspace-local temp dir must be in sandbox scope"
2534 );
2535 pop_execution_policy();
2536 }
2537
2538 #[test]
2539 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
2540 let policy = CapabilityPolicy {
2543 sandbox_profile: SandboxProfile::Unrestricted,
2544 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
2545 ..CapabilityPolicy::default()
2546 };
2547 let mut env = Vec::new();
2548 inject_workspace_tmpdir(&mut env, &policy);
2549 assert!(
2550 env.is_empty(),
2551 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
2552 );
2553 }
2554
2555 #[test]
2556 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
2557 let workspace = tempfile::tempdir().unwrap();
2558 let policy = CapabilityPolicy {
2559 sandbox_profile: SandboxProfile::Worktree,
2560 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2561 ..CapabilityPolicy::default()
2562 };
2563 let mut env = Vec::new();
2564 inject_workspace_tmpdir(&mut env, &policy);
2565
2566 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
2567 let expected = workspace_local_tmpdir(&policy)
2568 .unwrap()
2569 .display()
2570 .to_string();
2571 for key in TMPDIR_ENV_KEYS {
2572 assert_eq!(
2573 collected.get(key).map(String::as_str),
2574 Some(expected.as_str()),
2575 "{key} must point at the workspace-local temp dir"
2576 );
2577 }
2578 }
2579
2580 #[test]
2581 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
2582 let env: std::collections::BTreeMap<_, _> =
2583 deterministic_message_locale_env().into_iter().collect();
2584 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
2587 assert_eq!(
2589 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
2590 Some("en")
2591 );
2592 assert!(
2595 !env.contains_key("LC_ALL"),
2596 "must not force LC_ALL (would clobber UTF-8 ctype)"
2597 );
2598 assert!(!env.contains_key("LC_CTYPE"));
2599 assert!(!env.contains_key("LANG"));
2600 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
2603 }
2604
2605 #[test]
2606 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
2607 let workspace = tempfile::tempdir().unwrap();
2608 let policy = CapabilityPolicy {
2609 sandbox_profile: SandboxProfile::Worktree,
2610 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2611 ..CapabilityPolicy::default()
2612 };
2613 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
2615 inject_workspace_tmpdir(&mut env, &policy);
2616
2617 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2618 assert_eq!(
2619 collected.get("TMPDIR").map(String::as_str),
2620 Some("/caller/explicit/tmp"),
2621 "an explicit caller TMPDIR must be preserved untouched"
2622 );
2623 let expected = workspace_local_tmpdir(&policy)
2624 .unwrap()
2625 .display()
2626 .to_string();
2627 assert_eq!(
2628 collected.get("TMP").map(String::as_str),
2629 Some(expected.as_str())
2630 );
2631 assert_eq!(
2632 collected.get("TEMP").map(String::as_str),
2633 Some(expected.as_str())
2634 );
2635 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
2637 }
2638
2639 #[test]
2640 fn sandboxed_process_config_injects_workspace_tmpdir() {
2641 let workspace = tempfile::tempdir().unwrap();
2642 let policy = CapabilityPolicy {
2643 sandbox_profile: SandboxProfile::Worktree,
2644 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2645 ..CapabilityPolicy::default()
2646 };
2647 let config = ProcessCommandConfig {
2648 cwd: Some(workspace.path().to_path_buf()),
2649 ..ProcessCommandConfig::default()
2650 };
2651 let resolved = sandboxed_process_config(&config, &policy).unwrap();
2652 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2653 let expected = workspace_local_tmpdir(&policy)
2654 .unwrap()
2655 .display()
2656 .to_string();
2657 assert_eq!(
2658 env.get("TMPDIR").map(String::as_str),
2659 Some(expected.as_str()),
2660 "the command_output path must inject a workspace-local TMPDIR"
2661 );
2662 }
2663
2664 #[test]
2665 fn read_only_root_outside_workspace_allows_read_denies_write() {
2666 let workspace = tempfile::tempdir().unwrap();
2671 let read_only = tempfile::tempdir().unwrap();
2672 push_execution_policy(CapabilityPolicy {
2673 sandbox_profile: SandboxProfile::Worktree,
2674 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2675 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
2676 ..CapabilityPolicy::default()
2677 });
2678
2679 let asset = read_only
2680 .path()
2681 .join("partials/agent-web-tools.harn.prompt");
2682 assert!(
2684 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
2685 "read under a configured read-only root must be allowed"
2686 );
2687
2688 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
2690 .expect_err("write under a read-only root must be denied");
2691 assert!(write_err.read_only, "write rejection must set read_only");
2692
2693 assert!(
2695 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
2696 "delete under a read-only root must be denied"
2697 );
2698
2699 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
2701
2702 let stranger = tempfile::tempdir().unwrap();
2705 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
2706 .expect_err("read outside all roots must be denied");
2707 assert!(
2708 !outside_err.read_only,
2709 "out-of-scope rejection must not be flagged read_only"
2710 );
2711
2712 pop_execution_policy();
2713 }
2714
2715 #[cfg(unix)]
2716 #[test]
2717 fn standard_io_device_files_allowed_under_restricted_profile() {
2718 let workspace = tempfile::tempdir().unwrap();
2723 push_execution_policy(CapabilityPolicy {
2724 sandbox_profile: SandboxProfile::Worktree,
2725 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2726 ..CapabilityPolicy::default()
2727 });
2728
2729 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
2730 assert!(
2731 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
2732 "write to standard device {device} must be allowed"
2733 );
2734 assert!(
2736 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
2737 "read of standard device {device} must be allowed"
2738 );
2739 }
2740 assert!(
2741 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
2742 "read of standard device /dev/stdin must be allowed"
2743 );
2744 assert!(
2745 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
2746 "write to /dev/stdin is not a standard output stream"
2747 );
2748 assert!(
2749 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
2750 "standard devices must not bypass delete scoping"
2751 );
2752 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
2754 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
2755
2756 let stranger = tempfile::tempdir().unwrap();
2758 assert!(
2759 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
2760 "a real out-of-root write must still be rejected"
2761 );
2762 assert!(
2764 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
2765 "/dev/sda must not be allowed by the standard-device allowlist"
2766 );
2767 assert!(
2768 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
2769 "non-numeric /dev/fd/<x> must not be allowed"
2770 );
2771
2772 pop_execution_policy();
2773 }
2774
2775 #[test]
2776 fn is_standard_io_device_matches_only_known_streams() {
2777 assert!(is_standard_io_device_for_access(
2778 Path::new("/dev/stdin"),
2779 FsAccess::Read
2780 ));
2781 assert!(!is_standard_io_device_for_access(
2782 Path::new("/dev/stdin"),
2783 FsAccess::Write
2784 ));
2785 assert!(is_standard_io_device_for_access(
2786 Path::new("/dev/stdout"),
2787 FsAccess::Write
2788 ));
2789 assert!(is_standard_io_device_for_access(
2790 Path::new("/dev/stderr"),
2791 FsAccess::Write
2792 ));
2793 assert!(is_standard_io_device_for_access(
2794 Path::new("/dev/null"),
2795 FsAccess::Write
2796 ));
2797 assert!(is_standard_io_device_for_access(
2798 Path::new("/dev/fd/0"),
2799 FsAccess::Read
2800 ));
2801 assert!(is_standard_io_device_for_access(
2802 Path::new("/dev/fd/12"),
2803 FsAccess::Write
2804 ));
2805 assert!(!is_standard_io_device_for_access(
2806 Path::new("/dev/null"),
2807 FsAccess::Delete
2808 ));
2809 assert!(!is_standard_io_device_for_access(
2810 Path::new("/dev/fd/"),
2811 FsAccess::Write
2812 ));
2813 assert!(!is_standard_io_device_for_access(
2814 Path::new("/dev/fd/1a"),
2815 FsAccess::Write
2816 ));
2817 assert!(!is_standard_io_device_for_access(
2818 Path::new("/dev/stdoutx"),
2819 FsAccess::Write
2820 ));
2821 assert!(!is_standard_io_device_for_access(
2822 Path::new("/dev/random"),
2823 FsAccess::Read
2824 ));
2825 assert!(!is_standard_io_device_for_access(
2826 Path::new("/tmp/dev/null"),
2827 FsAccess::Write
2828 ));
2829 }
2830
2831 #[test]
2832 fn path_within_root_accepts_root_and_children() {
2833 let root = Path::new("/tmp/harn-root");
2834 assert!(path_is_within(root, root));
2835 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
2836 assert!(!path_is_within(
2837 Path::new("/tmp/harn-root-other/file"),
2838 root
2839 ));
2840 }
2841
2842 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2843 #[test]
2844 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
2845 let temp_home = tempfile::tempdir().expect("temp home");
2846 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
2847 let normalized_home = normalize_for_policy(temp_home.path());
2848
2849 for suffix in [
2850 Path::new(".cargo"),
2851 Path::new(".rustup"),
2852 Path::new(".pyenv"),
2853 Path::new(".nvm"),
2854 Path::new(".volta"),
2855 Path::new(".local/share/uv"),
2856 Path::new("go"),
2857 ] {
2858 assert!(
2859 roots.iter().any(|path| path.ends_with(suffix)),
2860 "expected a developer-toolchain grant for {}",
2861 suffix.display()
2862 );
2863 }
2864 assert!(
2865 roots.iter().all(|path| path.starts_with(&normalized_home)),
2866 "developer-toolchain roots must stay under HOME"
2867 );
2868 }
2869
2870 #[cfg(any(target_os = "linux", target_os = "macos"))]
2871 #[test]
2872 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
2873 let temp_home = tempfile::tempdir().expect("temp home");
2874 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
2875 let normalized_home = normalize_for_policy(temp_home.path());
2876
2877 for suffix in [
2878 Path::new(".gradle"),
2879 Path::new(".m2"),
2880 Path::new(".konan"),
2881 Path::new("Library/Caches/CocoaPods"),
2882 Path::new("Library/Developer/Xcode/DerivedData"),
2883 ] {
2884 assert!(
2885 roots.iter().any(|path| path.ends_with(suffix)),
2886 "expected a JVM/iOS toolchain cache grant for {}",
2887 suffix.display()
2888 );
2889 }
2890 assert!(
2891 roots.iter().all(|path| path.starts_with(&normalized_home)),
2892 "toolchain cache roots must stay under HOME"
2893 );
2894 }
2895
2896 #[cfg(any(target_os = "linux", target_os = "macos"))]
2897 #[test]
2898 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
2899 let mut policy = CapabilityPolicy {
2900 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
2901 ..CapabilityPolicy::default()
2902 };
2903 if sandbox_user_home_dir().is_some() {
2906 assert!(
2907 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2908 "default presets should render JVM/iOS cache roots"
2909 );
2910 }
2911 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2913 assert!(
2914 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2915 "cache roots must be gated on the DeveloperToolchains preset"
2916 );
2917 }
2918
2919 #[test]
2920 fn os_hardened_profile_overrides_fallback_env() {
2921 assert_eq!(
2926 effective_fallback(SandboxProfile::OsHardened),
2927 SandboxFallback::Enforce
2928 );
2929 }
2930
2931 #[test]
2932 fn unrestricted_profile_skips_active_sandbox() {
2933 let policy = CapabilityPolicy {
2934 sandbox_profile: SandboxProfile::Unrestricted,
2935 workspace_roots: vec!["/tmp".to_string()],
2936 ..Default::default()
2937 };
2938 crate::orchestration::push_execution_policy(policy);
2939 let result = active_sandbox_policy();
2940 crate::orchestration::pop_execution_policy();
2941 assert!(
2942 result.is_none(),
2943 "Unrestricted profile must short-circuit sandbox dispatch"
2944 );
2945 }
2946
2947 #[test]
2948 fn worktree_profile_engages_active_sandbox() {
2949 let policy = CapabilityPolicy {
2950 sandbox_profile: SandboxProfile::Worktree,
2951 workspace_roots: vec!["/tmp".to_string()],
2952 ..Default::default()
2953 };
2954 crate::orchestration::push_execution_policy(policy);
2955 let result = active_sandbox_policy();
2956 crate::orchestration::pop_execution_policy();
2957 assert!(
2958 result.is_some(),
2959 "Worktree profile must keep sandbox dispatch active"
2960 );
2961 }
2962}