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};
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::{environment_io_error_thrown, ErrorCategory, VmError, VmValue};
48use crate::vm::Vm;
49
50use paths::{
51 access_is_exempt_from_scope, is_standard_io_device_for_access, normalize_for_policy,
52 normalize_io_device_path, path_is_within, relocated_runtime_roots,
53};
54
55mod handler_env;
56#[cfg(target_os = "linux")]
57mod linux;
58mod locked_append;
59#[cfg(target_os = "macos")]
60mod macos;
61#[cfg(target_os = "openbsd")]
62mod openbsd;
63mod paths;
64mod process_output;
65use process_output::apply_process_config;
66#[cfg(target_os = "windows")]
67pub(crate) use process_output::windows_command_output;
68pub(crate) mod process_cwd;
69use process_cwd::enforce_process_cwd_for_policy;
70mod policy;
71mod replace;
72#[cfg(target_os = "windows")]
73mod windows;
74pub(crate) mod workspace_env;
75#[cfg(all(test, unix))]
76mod workspace_env_integration;
77
78pub(crate) use handler_env::effective_fallback;
79#[cfg(test)]
80pub(crate) use handler_env::handler_sandbox_test_guard;
81pub(crate) use locked_append::AppendLockOptions;
82pub(crate) use policy::allows_network as policy_allows_network;
83pub(crate) use replace::{
84 atomic_replace_scoped_at_open_unlocked, atomic_write_scoped_at_open,
85 read_for_replace_scoped_at_open,
86};
87pub use workspace_env::active_workspace_process_env;
88pub(crate) use workspace_env::{
89 inject_workspace_process_env, workspace_local_tmpdir, WORKSPACE_TMPDIR_NAME,
90};
91#[cfg(test)]
92pub(crate) use workspace_env::{inject_workspace_tmpdir, TMPDIR_ENV_KEYS};
93
94const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
95#[cfg(any(unix, windows))]
96const MAX_SCOPED_PATH_COMPONENTS: usize = 256;
97
98thread_local! {
99 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum FsAccess {
107 Read,
108 Write,
109 Delete,
110}
111
112#[derive(Clone, Debug, Default)]
113pub struct ProcessCommandConfig {
114 pub cwd: Option<PathBuf>,
115 pub env: Vec<(String, String)>,
116 pub env_remove: Vec<String>,
119 pub stdin_null: bool,
120 pub closed_env: bool,
127}
128
129#[derive(Clone, Debug, Default)]
130pub struct ProcessSandboxScope {
131 pub workspace_roots: Vec<String>,
132}
133
134#[must_use]
135pub struct ProcessSandboxScopeGuard {
136 pushed: bool,
137}
138
139impl Drop for ProcessSandboxScopeGuard {
140 fn drop(&mut self) {
141 if self.pushed {
142 crate::orchestration::pop_execution_policy();
143 }
144 }
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub(crate) enum SandboxFallback {
149 Off,
150 Warn,
151 Enforce,
152}
153
154pub(crate) trait SandboxBackend {
165 fn name() -> &'static str;
167
168 fn available() -> bool;
172
173 fn prepare_std_command(
179 program: &str,
180 args: &[String],
181 command: &mut Command,
182 policy: &CapabilityPolicy,
183 profile: SandboxProfile,
184 ) -> Result<PrepareOutcome, VmError>;
185
186 fn prepare_tokio_command(
188 program: &str,
189 args: &[String],
190 command: &mut tokio::process::Command,
191 policy: &CapabilityPolicy,
192 profile: SandboxProfile,
193 ) -> Result<PrepareOutcome, VmError>;
194
195 fn run_to_output(
200 program: &str,
201 args: &[String],
202 config: &ProcessCommandConfig,
203 policy: &CapabilityPolicy,
204 profile: SandboxProfile,
205 ) -> Result<Output, VmError> {
206 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
207 apply_process_config(&mut command, config);
208 crate::op_interrupt::capture_output_interruptible(&mut command)
209 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
210 }
211}
212
213pub(crate) enum PrepareOutcome {
217 Direct,
219 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
225 WrappedExec { wrapper: String, args: Vec<String> },
226}
227
228#[cfg(target_os = "linux")]
229type ActiveBackend = linux::Backend;
230#[cfg(target_os = "macos")]
231type ActiveBackend = macos::Backend;
232#[cfg(target_os = "openbsd")]
233type ActiveBackend = openbsd::Backend;
234#[cfg(target_os = "windows")]
235type ActiveBackend = windows::Backend;
236#[cfg(not(any(
237 target_os = "linux",
238 target_os = "macos",
239 target_os = "openbsd",
240 target_os = "windows"
241)))]
242type ActiveBackend = NoopBackend;
243
244#[cfg(not(any(
245 target_os = "linux",
246 target_os = "macos",
247 target_os = "openbsd",
248 target_os = "windows"
249)))]
250pub(crate) struct NoopBackend;
251
252#[cfg(not(any(
253 target_os = "linux",
254 target_os = "macos",
255 target_os = "openbsd",
256 target_os = "windows"
257)))]
258impl SandboxBackend for NoopBackend {
259 fn name() -> &'static str {
260 "noop"
261 }
262 fn available() -> bool {
263 false
264 }
265 fn prepare_std_command(
266 _program: &str,
267 _args: &[String],
268 _command: &mut Command,
269 _policy: &CapabilityPolicy,
270 _profile: SandboxProfile,
271 ) -> Result<PrepareOutcome, VmError> {
272 Ok(PrepareOutcome::Direct)
273 }
274 fn prepare_tokio_command(
275 _program: &str,
276 _args: &[String],
277 _command: &mut tokio::process::Command,
278 _policy: &CapabilityPolicy,
279 _profile: SandboxProfile,
280 ) -> Result<PrepareOutcome, VmError> {
281 Ok(PrepareOutcome::Direct)
282 }
283}
284
285pub(crate) fn reset_sandbox_state() {
286 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
287}
288
289pub fn active_backend_name() -> &'static str {
293 ActiveBackend::name()
294}
295
296pub fn active_backend_available() -> bool {
301 ActiveBackend::available()
302}
303
304pub fn register_sandbox_builtins(vm: &mut Vm) {
308 for def in MODULE_BUILTINS {
309 vm.register_builtin_def(def);
310 }
311 use harn_builtin_meta::CapabilityId;
312 vm.register_capability_method(
313 CapabilityId::System,
314 "sandbox_active_backend",
315 sandbox_active_backend_impl,
316 );
317 vm.register_capability_method(
318 CapabilityId::System,
319 "sandbox_backend_available",
320 sandbox_backend_available_impl,
321 );
322 vm.register_capability_method(
323 CapabilityId::System,
324 "sandbox_active_profile",
325 sandbox_active_profile_impl,
326 );
327}
328
329pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
330 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
331 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
332 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
333];
334
335#[crate::stdlib::macros::harn_builtin(
336 exposure = "runtime_internal",
337 effects = [],
338 sig = "sandbox_active_backend() -> string",
339 category = "sandbox"
340)]
341fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
342 Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
343}
344
345#[crate::stdlib::macros::harn_builtin(
346 exposure = "runtime_internal",
347 effects = [],
348 sig = "sandbox_backend_available() -> bool",
349 category = "sandbox"
350)]
351fn sandbox_backend_available_impl(
352 _args: &[VmValue],
353 _out: &mut String,
354) -> Result<VmValue, VmError> {
355 Ok(VmValue::Bool(active_backend_available()))
356}
357
358#[crate::stdlib::macros::harn_builtin(
359 exposure = "runtime_internal",
360 effects = [],
361 sig = "sandbox_active_profile() -> string",
362 category = "sandbox"
363)]
364fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
365 let profile = crate::orchestration::current_execution_policy()
366 .map(|policy| policy.sandbox_profile)
367 .unwrap_or(SandboxProfile::Unrestricted);
368 Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
369}
370
371#[derive(Clone, Debug)]
378pub struct SandboxViolation {
379 pub attempted: PathBuf,
383 pub roots: Vec<PathBuf>,
386 pub access: FsAccess,
388 pub read_only: bool,
392}
393
394impl SandboxViolation {
395 pub fn message(&self, builtin: &str) -> String {
399 if self.read_only {
400 return format!(
401 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
402 self.access.verb(),
403 self.attempted.display(),
404 );
405 }
406 format!(
407 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
408 self.access.verb(),
409 self.attempted.display(),
410 self.roots
411 .iter()
412 .map(|root| root.display().to_string())
413 .collect::<Vec<_>>()
414 .join(", ")
415 )
416 }
417}
418
419pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
434 let Some(policy) = crate::orchestration::current_execution_policy() else {
435 return Ok(());
436 };
437 if !policy.sandbox_profile.enforces_path_scope() {
438 return Ok(());
439 }
440 if access_is_exempt_from_scope(path, access) {
451 return Ok(());
452 }
453 let candidate = normalize_for_policy(path);
454 let roots = normalized_workspace_roots(&policy);
455 if roots.iter().any(|root| path_is_within(&candidate, root)) {
456 return Ok(());
457 }
458 let read_only_roots = normalized_read_only_roots(&policy);
459 let within_read_only = read_only_roots
460 .iter()
461 .any(|root| path_is_within(&candidate, root));
462 if within_read_only && access == FsAccess::Read {
463 return Ok(());
464 }
465 Err(SandboxViolation {
466 attempted: candidate,
467 roots,
468 access,
469 read_only: within_read_only,
470 })
471}
472
473pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
474 check_fs_path_scope(path, access)
475 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
476}
477
478pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
479 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
480 return append_unscoped(path, contents);
481 };
482 append_scoped_target(&target, contents)
483}
484
485pub(crate) fn append_locked_scoped_at_open(
486 builtin: &str,
487 path: &Path,
488 contents: &[u8],
489 options: AppendLockOptions,
490) -> io::Result<()> {
491 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
492 return locked_append::append_locked_unscoped(path, contents, options);
493 };
494 locked_append::append_locked_scoped_target(&target, contents, options)
495}
496
497pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
498 let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
499 return std::fs::copy(src, dst);
500 };
501 copy_scoped_target(src, &target)
502}
503
504pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
505 let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
506 return std::fs::rename(src, dst);
507 };
508 let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
509 io::Error::new(
510 io::ErrorKind::PermissionDenied,
511 format!(
512 "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
513 dst.display()
514 ),
515 )
516 })?;
517 rename_scoped_targets(&src_target, &dst_target)
518}
519
520pub(crate) fn create_dir_scoped_at_open(
521 builtin: &str,
522 path: &Path,
523 recursive: bool,
524) -> io::Result<()> {
525 let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
526 return if recursive {
527 std::fs::create_dir_all(path)
528 } else {
529 std::fs::create_dir(path)
530 };
531 };
532 if recursive {
533 create_dir_all_scoped_target(&target)
534 } else {
535 create_dir_scoped_target(&target)
536 }
537}
538
539#[derive(Clone, Debug)]
540struct ScopedMutationTarget {
541 root: PathBuf,
542 relative: PathBuf,
543}
544
545fn scoped_mutation_target(
546 builtin: &str,
547 path: &Path,
548 access: FsAccess,
549) -> io::Result<Option<ScopedMutationTarget>> {
550 let Some(policy) = crate::orchestration::current_execution_policy() else {
551 return Ok(None);
552 };
553 if !policy.sandbox_profile.enforces_path_scope() {
554 return Ok(None);
555 }
556 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
557 return Ok(None);
558 }
559 check_fs_path_scope(path, access).map_err(|violation| {
560 io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
561 })?;
562 let candidate = normalize_for_policy(path);
563 let roots = normalized_workspace_roots(&policy);
564 let Some(root) = roots
565 .into_iter()
566 .find(|root| path_is_within(&candidate, root))
567 else {
568 return Err(io::Error::new(
569 io::ErrorKind::PermissionDenied,
570 format!(
571 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
572 access.verb(),
573 candidate.display()
574 ),
575 ));
576 };
577 let relative = candidate.strip_prefix(&root).map_err(|_| {
578 io::Error::new(
579 io::ErrorKind::PermissionDenied,
580 format!(
581 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
582 access.verb(),
583 candidate.display(),
584 root.display()
585 ),
586 )
587 })?;
588 if relative.as_os_str().is_empty() {
589 return Err(io::Error::new(
590 io::ErrorKind::InvalidInput,
591 format!(
592 "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
593 access.verb(),
594 root.display()
595 ),
596 ));
597 }
598 Ok(Some(ScopedMutationTarget {
599 root,
600 relative: relative.to_path_buf(),
601 }))
602}
603
604fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
605 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
608 std::fs::create_dir_all(parent)?;
609 }
610 std::fs::OpenOptions::new()
611 .create(true)
612 .append(true)
613 .open(path)
614 .and_then(|mut file| file.write_all(contents))
615}
616
617#[cfg(test)]
618fn shared_atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
619 crate::atomic_io::atomic_write(path, contents)
620}
621
622#[cfg(unix)]
623fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
624 use std::os::fd::AsRawFd;
625
626 let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
629 let mut file = openat_file(
630 parent.as_raw_fd(),
631 &file_name,
632 libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
633 0o666,
634 )?;
635 file.write_all(contents)
636}
637
638#[cfg(windows)]
639fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
640 let (parent, file_name) = win_scoped_parent(target, true)?;
641 let full = parent.join(&file_name);
642 win_reject_reparse_leaf(&full)?;
643 append_unscoped(&full, contents)
644}
645
646#[cfg(all(not(unix), not(windows)))]
647fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
648 let full = target.root.join(&target.relative);
649 if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
650 std::fs::create_dir_all(parent)?;
651 }
652 append_unscoped(&full, contents)
653}
654
655#[cfg(unix)]
656fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
657 use std::os::fd::AsRawFd;
658
659 let mut source = std::fs::File::open(src)?;
660 let source_metadata = source.metadata().ok();
661 let (parent, file_name) = open_parent_dir_scoped(target)?;
662 let mut destination = openat_file(
663 parent.as_raw_fd(),
664 &file_name,
665 libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
666 0o666,
667 )?;
668 let copied = io::copy(&mut source, &mut destination)?;
669 destination.sync_all()?;
670 if let Some(metadata) = source_metadata {
671 let _ = destination.set_permissions(metadata.permissions());
672 }
673 sync_dir_fd(parent.as_raw_fd());
674 Ok(copied)
675}
676
677#[cfg(windows)]
678fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
679 let (parent, file_name) = win_scoped_parent(target, false)?;
683 let full = parent.join(&file_name);
684 win_reject_reparse_leaf(&full)?;
685 std::fs::copy(src, full)
686}
687
688#[cfg(all(not(unix), not(windows)))]
689fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
690 std::fs::copy(src, target.root.join(&target.relative))
691}
692
693#[cfg(unix)]
694fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
695 use std::os::fd::AsRawFd;
696
697 let (src_parent, src_name) = open_parent_dir_scoped(src)?;
698 let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
699 renameat_name(
700 src_parent.as_raw_fd(),
701 &src_name,
702 dst_parent.as_raw_fd(),
703 &dst_name,
704 )?;
705 sync_dir_fd(dst_parent.as_raw_fd());
706 Ok(())
707}
708
709#[cfg(windows)]
710fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
711 let (src_parent, src_name) = win_scoped_parent(src, false)?;
716 let (dst_parent, dst_name) = win_scoped_parent(dst, false)?;
717 std::fs::rename(src_parent.join(&src_name), dst_parent.join(&dst_name))
718}
719
720#[cfg(all(not(unix), not(windows)))]
721fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
722 std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
723}
724
725#[cfg(unix)]
726fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
727 use std::os::fd::AsRawFd;
728
729 let (parent, file_name) = open_parent_dir_scoped(target)?;
730 mkdirat_name(parent.as_raw_fd(), &file_name)?;
731 sync_dir_fd(parent.as_raw_fd());
732 Ok(())
733}
734
735#[cfg(windows)]
736fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
737 let (parent, file_name) = win_scoped_parent(target, false)?;
744 win_create_dir_raw(&parent.join(&file_name))
745}
746
747#[cfg(all(not(unix), not(windows)))]
748fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
749 std::fs::create_dir(target.root.join(&target.relative))
750}
751
752#[cfg(unix)]
753fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
754 use std::os::fd::AsRawFd;
755
756 let root = open_dir_absolute(&target.root)?;
757 let mut current = root;
758 for component in clean_relative_components(&target.relative)? {
759 match open_dir_at(current.as_raw_fd(), &component) {
760 Ok(next) => current = next,
761 Err(error) if error.kind() == io::ErrorKind::NotFound => {
762 mkdirat_name(current.as_raw_fd(), &component)?;
763 let next = open_dir_at(current.as_raw_fd(), &component)?;
764 current = next;
765 }
766 Err(error) => return Err(error),
767 }
768 }
769 Ok(())
770}
771
772#[cfg(windows)]
773fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
774 let components = win_clean_relative_components(&target.relative)?;
777 win_walk_components(&target.root, &components, true)?;
778 Ok(())
779}
780
781#[cfg(all(not(unix), not(windows)))]
782fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
783 std::fs::create_dir_all(target.root.join(&target.relative))
784}
785
786#[cfg(unix)]
787#[cfg(unix)]
806fn ensure_parent_dirs_scoped(
807 target: &ScopedMutationTarget,
808) -> io::Result<(std::os::fd::OwnedFd, String)> {
809 use std::os::fd::AsRawFd;
810
811 let mut components = clean_relative_components(&target.relative)?;
812 let file_name = components.pop().ok_or_else(|| {
813 io::Error::new(
814 io::ErrorKind::InvalidInput,
815 format!(
816 "sandbox scoped open requires a file name: {}",
817 target.relative.display()
818 ),
819 )
820 })?;
821 let root = open_dir_absolute(&target.root)?;
822 let mut current = root;
823 for component in components {
824 match open_dir_at(current.as_raw_fd(), &component) {
825 Ok(next) => current = next,
826 Err(error) if error.kind() == io::ErrorKind::NotFound => {
827 if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
828 if mkerr.kind() != io::ErrorKind::AlreadyExists {
829 return Err(mkerr);
830 }
831 }
832 current = open_dir_at(current.as_raw_fd(), &component)?;
833 }
834 Err(error) => return Err(error),
835 }
836 }
837 Ok((current, file_name))
838}
839
840#[cfg(unix)]
841fn open_parent_dir_scoped(
842 target: &ScopedMutationTarget,
843) -> io::Result<(std::os::fd::OwnedFd, String)> {
844 use std::os::fd::AsRawFd;
845
846 let mut components = clean_relative_components(&target.relative)?;
847 let file_name = components.pop().ok_or_else(|| {
848 io::Error::new(
849 io::ErrorKind::InvalidInput,
850 format!(
851 "sandbox scoped open requires a file name: {}",
852 target.relative.display()
853 ),
854 )
855 })?;
856 let root = open_dir_absolute(&target.root)?;
857 let mut current = root;
858 for component in components {
859 current = open_dir_at(current.as_raw_fd(), &component)?;
860 }
861 Ok((current, file_name))
862}
863
864#[cfg(unix)]
865fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
866 use std::os::unix::ffi::OsStrExt;
867
868 let mut out = Vec::new();
869 for component in path.components() {
870 match component {
871 Component::Normal(value) => {
872 let bytes = value.as_bytes();
873 if bytes.contains(&0) {
874 return Err(io::Error::new(
875 io::ErrorKind::InvalidInput,
876 format!("path component contains NUL: {}", path.display()),
877 ));
878 }
879 out.push(value.to_string_lossy().into_owned());
880 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
881 return Err(io::Error::new(
882 io::ErrorKind::InvalidInput,
883 format!(
884 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
885 path.display()
886 ),
887 ));
888 }
889 }
890 Component::CurDir => {}
891 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
892 return Err(io::Error::new(
893 io::ErrorKind::InvalidInput,
894 format!("sandbox scoped path must stay relative: {}", path.display()),
895 ));
896 }
897 }
898 }
899 Ok(out)
900}
901
902#[cfg(unix)]
903fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
904 use std::os::fd::{FromRawFd, OwnedFd};
905 use std::os::unix::ffi::OsStrExt;
906
907 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
908 io::Error::new(
909 io::ErrorKind::InvalidInput,
910 format!("path contains NUL: {}", path.display()),
911 )
912 })?;
913 let fd = unsafe {
914 libc::open(
915 c_path.as_ptr(),
916 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
917 )
918 };
919 if fd < 0 {
920 return Err(io::Error::last_os_error());
921 }
922 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
923}
924
925#[cfg(unix)]
926fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
927 use std::os::fd::{FromRawFd, OwnedFd};
928
929 let c_name = c_name(name)?;
930 let fd = unsafe {
931 libc::openat(
932 parent_fd,
933 c_name.as_ptr(),
934 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
935 )
936 };
937 if fd < 0 {
938 return Err(io::Error::last_os_error());
939 }
940 Ok(unsafe { OwnedFd::from_raw_fd(fd) })
941}
942
943#[cfg(unix)]
944fn openat_file(
945 parent_fd: libc::c_int,
946 name: &str,
947 flags: libc::c_int,
948 mode: libc::mode_t,
949) -> io::Result<std::fs::File> {
950 use std::os::fd::FromRawFd;
951
952 let c_name = c_name(name)?;
953 let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
954 if fd < 0 {
955 return Err(io::Error::last_os_error());
956 }
957 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
958}
959
960#[cfg(unix)]
961fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
962 let c_name = c_name(name)?;
963 let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
964 if rc != 0 {
965 return Err(io::Error::last_os_error());
966 }
967 Ok(())
968}
969
970#[cfg(unix)]
971fn renameat_name(
972 old_parent_fd: libc::c_int,
973 old_name: &str,
974 new_parent_fd: libc::c_int,
975 new_name: &str,
976) -> io::Result<()> {
977 let old_name = c_name(old_name)?;
978 let new_name = c_name(new_name)?;
979 let rc = unsafe {
980 libc::renameat(
981 old_parent_fd,
982 old_name.as_ptr(),
983 new_parent_fd,
984 new_name.as_ptr(),
985 )
986 };
987 if rc != 0 {
988 return Err(io::Error::last_os_error());
989 }
990 Ok(())
991}
992
993#[cfg(unix)]
994fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
995 let c_name = c_name(name)?;
996 let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
997 if rc != 0 {
998 return Err(io::Error::last_os_error());
999 }
1000 Ok(())
1001}
1002
1003#[cfg(unix)]
1004fn sync_dir_fd(fd: libc::c_int) -> bool {
1005 (unsafe { libc::fsync(fd) }) == 0
1006}
1007
1008#[cfg(unix)]
1009fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1010 std::ffi::CString::new(name).map_err(|_| {
1011 io::Error::new(
1012 io::ErrorKind::InvalidInput,
1013 format!("path component contains NUL: {name:?}"),
1014 )
1015 })
1016}
1017
1018#[cfg(windows)]
1045const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1046#[cfg(windows)]
1047const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1048
1049#[cfg(windows)]
1050fn win_wide(path: &Path) -> Vec<u16> {
1051 use std::os::windows::ffi::OsStrExt;
1052 path.as_os_str()
1053 .encode_wide()
1054 .chain(std::iter::once(0))
1055 .collect()
1056}
1057
1058#[cfg(windows)]
1065fn win_reject_reparse_point(path: &Path) -> io::Result<()> {
1066 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1067 use windows_sys::Win32::Storage::FileSystem::{
1068 CreateFileW, FileAttributeTagInfo, GetFileInformationByHandleEx,
1069 FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS,
1070 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1071 OPEN_EXISTING,
1072 };
1073
1074 const FILE_READ_ATTRIBUTES: u32 = 0x0080;
1076
1077 let wide = win_wide(path);
1078 let handle = unsafe {
1079 CreateFileW(
1080 wide.as_ptr(),
1081 FILE_READ_ATTRIBUTES,
1082 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1083 std::ptr::null(),
1084 OPEN_EXISTING,
1085 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
1086 std::ptr::null_mut(),
1087 )
1088 };
1089 if handle == INVALID_HANDLE_VALUE {
1090 return Err(io::Error::last_os_error());
1091 }
1092 let mut info = FILE_ATTRIBUTE_TAG_INFO::default();
1093 let ok = unsafe {
1094 GetFileInformationByHandleEx(
1095 handle,
1096 FileAttributeTagInfo,
1097 std::ptr::from_mut(&mut info).cast(),
1098 std::mem::size_of::<FILE_ATTRIBUTE_TAG_INFO>() as u32,
1099 )
1100 };
1101 let result = if ok == 0 {
1102 Err(io::Error::last_os_error())
1103 } else if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1104 && matches!(
1105 info.ReparseTag,
1106 IO_REPARSE_TAG_MOUNT_POINT | IO_REPARSE_TAG_SYMLINK
1107 )
1108 {
1109 Err(io::Error::new(
1110 io::ErrorKind::PermissionDenied,
1111 format!(
1112 "sandbox scoped walk refuses reparse-point (junction/symlink) component: {}",
1113 path.display()
1114 ),
1115 ))
1116 } else {
1117 Ok(())
1118 };
1119 unsafe {
1120 CloseHandle(handle);
1121 }
1122 result
1123}
1124
1125#[cfg(windows)]
1128fn win_reject_reparse_leaf(path: &Path) -> io::Result<()> {
1129 match win_reject_reparse_point(path) {
1130 Ok(()) => Ok(()),
1131 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1132 Err(err) => Err(err),
1133 }
1134}
1135
1136#[cfg(windows)]
1140fn win_create_dir_raw(path: &Path) -> io::Result<()> {
1141 use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
1142 let wide = win_wide(path);
1143 let ok = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) };
1144 if ok == 0 {
1145 return Err(io::Error::last_os_error());
1146 }
1147 Ok(())
1148}
1149
1150#[cfg(windows)]
1154fn win_clean_relative_components(path: &Path) -> io::Result<Vec<std::ffi::OsString>> {
1155 use std::os::windows::ffi::OsStrExt;
1156
1157 let mut out: Vec<std::ffi::OsString> = Vec::new();
1158 for component in path.components() {
1159 match component {
1160 Component::Normal(value) => {
1161 if value.encode_wide().any(|unit| unit == 0) {
1162 return Err(io::Error::new(
1163 io::ErrorKind::InvalidInput,
1164 format!("path component contains NUL: {}", path.display()),
1165 ));
1166 }
1167 out.push(value.to_os_string());
1168 if out.len() > MAX_SCOPED_PATH_COMPONENTS {
1169 return Err(io::Error::new(
1170 io::ErrorKind::InvalidInput,
1171 format!(
1172 "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
1173 path.display()
1174 ),
1175 ));
1176 }
1177 }
1178 Component::CurDir => {}
1179 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1180 return Err(io::Error::new(
1181 io::ErrorKind::InvalidInput,
1182 format!("sandbox scoped path must stay relative: {}", path.display()),
1183 ));
1184 }
1185 }
1186 }
1187 Ok(out)
1188}
1189
1190#[cfg(windows)]
1195fn win_walk_components(
1196 root: &Path,
1197 components: &[std::ffi::OsString],
1198 create: bool,
1199) -> io::Result<PathBuf> {
1200 win_reject_reparse_point(root)?;
1204 let mut current = root.to_path_buf();
1205 for component in components {
1206 current.push(component);
1207 match win_reject_reparse_point(¤t) {
1208 Ok(()) => {}
1209 Err(err) if create && err.kind() == io::ErrorKind::NotFound => {
1210 match win_create_dir_raw(¤t) {
1211 Ok(()) => {}
1212 Err(mkerr) if mkerr.kind() == io::ErrorKind::AlreadyExists => {}
1214 Err(mkerr) => return Err(mkerr),
1215 }
1216 win_reject_reparse_point(¤t)?;
1217 }
1218 Err(err) => return Err(err),
1219 }
1220 }
1221 Ok(current)
1222}
1223
1224#[cfg(windows)]
1228fn win_scoped_parent(
1229 target: &ScopedMutationTarget,
1230 create_parents: bool,
1231) -> io::Result<(PathBuf, std::ffi::OsString)> {
1232 let mut components = win_clean_relative_components(&target.relative)?;
1233 let file_name = components.pop().ok_or_else(|| {
1234 io::Error::new(
1235 io::ErrorKind::InvalidInput,
1236 format!(
1237 "sandbox scoped open requires a file name: {}",
1238 target.relative.display()
1239 ),
1240 )
1241 })?;
1242 let parent = win_walk_components(&target.root, &components, create_parents)?;
1243 Ok((parent, file_name))
1244}
1245
1246pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
1247 let Some(policy) = crate::orchestration::current_execution_policy() else {
1248 return Ok(());
1249 };
1250 enforce_process_cwd_for_policy(path, &policy)
1251}
1252
1253pub fn push_process_sandbox_scope(
1254 scope: ProcessSandboxScope,
1255) -> Result<ProcessSandboxScopeGuard, VmError> {
1256 let Some(mut policy) = crate::orchestration::current_execution_policy() else {
1257 return Ok(ProcessSandboxScopeGuard { pushed: false });
1258 };
1259 if !policy.sandbox_profile.enforces_path_scope() {
1260 return Ok(ProcessSandboxScopeGuard { pushed: false });
1261 }
1262
1263 let requested_roots: Vec<PathBuf> = scope
1264 .workspace_roots
1265 .iter()
1266 .filter_map(|root| {
1267 let trimmed = root.trim();
1268 (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1269 })
1270 .collect();
1271 if requested_roots.is_empty() {
1272 return Ok(ProcessSandboxScopeGuard { pushed: false });
1273 }
1274
1275 if !policy.workspace_roots.is_empty() {
1276 let ceiling_roots = normalized_workspace_roots(&policy);
1277 if let Some(rejected) = requested_roots.iter().find(|root| {
1278 !ceiling_roots
1279 .iter()
1280 .any(|ceiling| path_is_within(root, ceiling))
1281 }) {
1282 return Err(sandbox_rejection(format!(
1283 "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1284 rejected.display(),
1285 ceiling_roots
1286 .iter()
1287 .map(|root| root.display().to_string())
1288 .collect::<Vec<_>>()
1289 .join(", ")
1290 )));
1291 }
1292 }
1293
1294 let mut merged_roots = if policy.workspace_roots.is_empty() {
1295 Vec::new()
1296 } else {
1297 normalized_workspace_roots(&policy)
1298 };
1299 for requested in requested_roots {
1300 if !merged_roots
1301 .iter()
1302 .any(|existing| path_is_within(&requested, existing))
1303 {
1304 merged_roots.push(requested);
1305 }
1306 }
1307 policy.workspace_roots = merged_roots
1308 .into_iter()
1309 .map(|root| root.display().to_string())
1310 .collect();
1311 crate::orchestration::push_execution_policy(policy);
1312 Ok(ProcessSandboxScopeGuard { pushed: true })
1313}
1314
1315macro_rules! close_env_for_session {
1323 ($command:expr, $program:expr) => {
1324 if let Some(env) =
1325 crate::stdlib::process::session_closed_env_for_command($program, std::iter::empty())?
1326 {
1327 $command.env_clear();
1328 for (key, value) in env {
1329 $command.env(key, value);
1330 }
1331 }
1332 };
1333}
1334
1335pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1336 let mut command = match active_sandbox_policy() {
1337 Some((policy, profile)) => {
1338 build_std_command::<ActiveBackend>(program, args, &policy, profile)?
1339 }
1340 None => {
1341 let mut command = Command::new(program);
1342 command.args(args);
1343 command
1344 }
1345 };
1346 close_env_for_session!(command, program);
1347 Ok(command)
1348}
1349
1350pub fn tokio_command_for(
1351 program: &str,
1352 args: &[String],
1353) -> Result<tokio::process::Command, VmError> {
1354 let mut command = match active_sandbox_policy() {
1355 Some((policy, profile)) => {
1356 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)?
1357 }
1358 None => {
1359 let mut command = tokio::process::Command::new(program);
1360 command.args(args);
1361 command
1362 }
1363 };
1364 close_env_for_session!(command, program);
1365 Ok(command)
1366}
1367
1368pub fn command_output(
1369 program: &str,
1370 args: &[String],
1371 config: &ProcessCommandConfig,
1372) -> Result<Output, VmError> {
1373 if let Some(intercepted) =
1378 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1379 {
1380 return intercepted.map_err(|message| {
1381 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1382 });
1383 }
1384
1385 let recording =
1386 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1387
1388 let closed_config;
1392 let config = if let Some(env) =
1393 crate::stdlib::process::session_closed_env_for_command(program, config.env.iter().cloned())?
1394 {
1395 closed_config = ProcessCommandConfig {
1396 env,
1397 closed_env: true,
1398 ..config.clone()
1399 };
1400 &closed_config
1401 } else {
1402 config
1403 };
1404
1405 let output = match active_sandbox_policy() {
1406 Some((policy, profile)) => {
1407 let config = sandboxed_process_config(config, &policy)?;
1408 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1409 }
1410 None => {
1411 let mut command = Command::new(program);
1412 command.args(args);
1413 apply_process_config(&mut command, config);
1414 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1419 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1420 })?
1421 }
1422 };
1423 if let Some(error) = process_violation_error(&output) {
1424 return Err(error);
1425 }
1426 if let Some(span) = recording {
1427 span.finish(&output);
1428 }
1429 Ok(output)
1430}
1431
1432fn sandboxed_process_config(
1433 config: &ProcessCommandConfig,
1434 policy: &CapabilityPolicy,
1435) -> Result<ProcessCommandConfig, VmError> {
1436 let mut resolved = config.clone();
1437 if let Some(cwd) = resolved.cwd.as_ref() {
1438 enforce_process_cwd_for_policy(cwd, policy)?;
1439 } else {
1440 resolved.cwd = Some(policy_process_cwd(policy)?);
1441 }
1442 neutralize_rustc_wrapper(&mut resolved.env);
1443 inject_workspace_process_env(&mut resolved.env, policy);
1444 resolved.env.retain(|(key, _)| {
1445 !resolved
1446 .env_remove
1447 .iter()
1448 .any(|removed| key.eq_ignore_ascii_case(removed))
1449 });
1450 Ok(resolved)
1451}
1452
1453fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1467 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1468 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1469 entry.1.clear();
1470 } else {
1471 env.push((key.to_string(), String::new()));
1472 }
1473 }
1474}
1475
1476pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1501 vec![
1502 ("LC_MESSAGES".to_string(), "C".to_string()),
1503 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1504 ]
1505}
1506
1507pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1512
1513pub(crate) fn policy_process_cwd(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1514 let roots = normalized_workspace_roots(policy);
1515 let current = std::env::current_dir().map_err(|error| {
1516 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1517 format!("process cwd resolution failed: {error}"),
1518 )))
1519 })?;
1520 let current = normalize_for_policy(¤t);
1521 if roots.iter().any(|root| path_is_within(¤t, root)) {
1522 return Ok(current);
1523 }
1524 roots.first().cloned().ok_or_else(|| {
1525 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1526 "process cwd resolution failed: no workspace root available",
1527 )))
1528 })
1529}
1530
1531fn build_std_command<B: SandboxBackend + ?Sized>(
1532 program: &str,
1533 args: &[String],
1534 policy: &CapabilityPolicy,
1535 profile: SandboxProfile,
1536) -> Result<Command, VmError> {
1537 let mut command = Command::new(program);
1538 command.args(args);
1539 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1540 PrepareOutcome::Direct => Ok(command),
1541 PrepareOutcome::WrappedExec { wrapper, args } => {
1542 let mut wrapped = Command::new(wrapper);
1543 wrapped.args(args);
1544 Ok(wrapped)
1545 }
1546 }
1547}
1548
1549fn build_tokio_command<B: SandboxBackend + ?Sized>(
1550 program: &str,
1551 args: &[String],
1552 policy: &CapabilityPolicy,
1553 profile: SandboxProfile,
1554) -> Result<tokio::process::Command, VmError> {
1555 let mut command = tokio::process::Command::new(program);
1556 command.args(args);
1557 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1558 PrepareOutcome::Direct => Ok(command),
1559 PrepareOutcome::WrappedExec { wrapper, args } => {
1560 let mut wrapped = tokio::process::Command::new(wrapper);
1561 wrapped.args(args);
1562 Ok(wrapped)
1563 }
1564 }
1565}
1566
1567pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1568 let policy = crate::orchestration::current_execution_policy()?;
1569 if !policy.sandbox_profile.confines_processes() {
1573 return None;
1574 }
1575 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1576 || !ActiveBackend::available()
1577 {
1578 return None;
1579 }
1580 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1581 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1582 if !output.status.success()
1583 && (stderr.contains("operation not permitted")
1584 || stderr.contains("permission denied")
1585 || stderr.contains("access is denied")
1586 || stdout.contains("operation not permitted"))
1587 {
1588 return Some(sandbox_denial_error(
1589 format!(
1590 "sandbox violation: process was denied by the OS sandbox (status {})",
1591 output.status.code().unwrap_or(-1)
1592 ),
1593 &format!("{stderr}\n{stdout}"),
1594 &policy,
1595 ));
1596 }
1597 if sandbox_signal_status(output) {
1598 return Some(sandbox_denial_error(
1599 format!(
1600 "sandbox violation: process was terminated by the OS sandbox (status {})",
1601 output.status
1602 ),
1603 &format!("{stderr}\n{stdout}"),
1604 &policy,
1605 ));
1606 }
1607 None
1608}
1609
1610pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1611 let policy = crate::orchestration::current_execution_policy()?;
1612 if !policy.sandbox_profile.confines_processes() {
1613 return None;
1614 }
1615 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1616 || !ActiveBackend::available()
1617 {
1618 return None;
1619 }
1620 let message = error.to_string().to_ascii_lowercase();
1621 if error.kind() == std::io::ErrorKind::PermissionDenied
1622 || message.contains("operation not permitted")
1623 || message.contains("permission denied")
1624 || message.contains("access is denied")
1625 {
1626 return Some(sandbox_denial_error(
1627 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1628 &message,
1629 &policy,
1630 ));
1631 }
1632 None
1633}
1634
1635#[cfg(unix)]
1636fn sandbox_signal_status(output: &std::process::Output) -> bool {
1637 use std::os::unix::process::ExitStatusExt;
1638
1639 matches!(
1640 output.status.signal(),
1641 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1642 )
1643}
1644
1645#[cfg(not(unix))]
1646fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1647 false
1648}
1649
1650pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1656 let policy = crate::orchestration::current_execution_policy()?;
1657 let profile = policy.sandbox_profile;
1658 if !profile.confines_processes() || effective_fallback(profile) == SandboxFallback::Off {
1659 return None;
1660 }
1661 Some((policy, profile))
1662}
1663
1664fn spawn_error(error: std::io::Error) -> VmError {
1665 environment_io_error_thrown(&error, format!("process spawn failed: {error}"))
1666}
1667
1668pub(crate) fn warn_once(key: &str, message: &str) {
1669 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1670 if inserted {
1671 crate::events::log_warn("handler_sandbox", message);
1672 }
1673}
1674
1675pub(crate) fn sandbox_rejection(message: String) -> VmError {
1676 VmError::CategorizedError {
1677 message,
1678 category: ErrorCategory::ToolRejected,
1679 }
1680}
1681
1682fn sandbox_denial_error(summary: String, detail: &str, policy: &CapabilityPolicy) -> VmError {
1698 if let Some((var, path)) = toolchain_cache_gap_named_in_denial(policy, detail) {
1699 return VmError::CategorizedError {
1700 message: format!(
1701 "{summary}; the {var} toolchain cache resolves to '{}', which is outside the \
1702 sandbox profile — a host environment/config gap, not the agent's code defect. \
1703 For `harn run`, pass --sandbox-write-root '{}'; embedders can add it to \
1704 process_sandbox.write_roots or extend the DeveloperToolchains preset",
1705 path.display(),
1706 path.display()
1707 ),
1708 category: ErrorCategory::Environment,
1709 };
1710 }
1711 #[cfg(any(target_os = "linux", target_os = "macos"))]
1712 if let Some(path) = toolchain_cache_default_named_in_denial(policy, detail) {
1713 return VmError::CategorizedError {
1714 message: format!(
1715 "{summary}; the sandbox denied writing '{}', a well-known developer-toolchain \
1716 cache outside the active profile — a host environment/config gap, not the \
1717 agent's code defect. For `harn run`, pass --sandbox-write-root '{}'; embedders \
1718 can enable the DeveloperToolchains preset or add process_sandbox.write_roots",
1719 path.display(),
1720 path.display()
1721 ),
1722 category: ErrorCategory::Environment,
1723 };
1724 }
1725 sandbox_rejection(sandbox_process_violation_message(summary))
1726}
1727
1728fn sandbox_process_violation_message(summary: String) -> String {
1729 format!(
1730 "{summary}; if the command depends on a developer toolchain or cache outside the \
1731 workspace, pass --sandbox-read-root / --sandbox-write-root to `harn run`, or add the \
1732 root to process_sandbox.read_roots / process_sandbox.write_roots in an embedder policy"
1733 )
1734}
1735
1736#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1743fn coverage_jail_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1744 let mut roots = normalized_workspace_roots(policy);
1745 roots.extend(process_sandbox_roots(policy));
1746 roots.extend(process_sandbox_readonly_roots(policy));
1747 roots.extend(process_sandbox_policy_read_roots(policy));
1748 roots.extend(process_sandbox_policy_write_roots(policy));
1749 roots.extend(process_sandbox_developer_toolchain_read_roots(policy));
1750 roots.extend(process_sandbox_package_manager_config_read_roots(policy));
1751 #[cfg(any(target_os = "linux", target_os = "macos"))]
1752 roots.extend(process_sandbox_developer_toolchain_cache_roots(policy));
1753 roots
1754}
1755
1756#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1766fn toolchain_cache_gap_named_in_denial(
1767 policy: &CapabilityPolicy,
1768 detail: &str,
1769) -> Option<(String, PathBuf)> {
1770 let detail = detail.to_ascii_lowercase();
1771 let jail = coverage_jail_roots(policy);
1772 for name in crate::security::environment_policy::TOOLCHAIN_CACHE_ENV_VARS {
1773 let Some(value) = std::env::var(name)
1774 .ok()
1775 .filter(|value| !value.trim().is_empty())
1776 else {
1777 continue;
1778 };
1779 let path = normalize_for_policy(Path::new(&value));
1780 let named = detail.contains(&path.to_string_lossy().to_ascii_lowercase());
1781 if named && !jail.iter().any(|root| path_is_within(&path, root)) {
1782 return Some(((*name).to_string(), path));
1783 }
1784 }
1785 None
1786}
1787
1788#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1789fn toolchain_cache_gap_named_in_denial(
1790 _policy: &CapabilityPolicy,
1791 _detail: &str,
1792) -> Option<(String, PathBuf)> {
1793 None
1794}
1795
1796#[cfg(any(target_os = "linux", target_os = "macos"))]
1805fn toolchain_cache_default_named_in_denial(
1806 policy: &CapabilityPolicy,
1807 detail: &str,
1808) -> Option<PathBuf> {
1809 let home = sandbox_user_home_dir()?;
1810 let detail = detail.to_ascii_lowercase();
1811 let jail = coverage_jail_roots(policy);
1812 developer_toolchain_cache_write_roots_for_home(&home)
1813 .into_iter()
1814 .find(|root| {
1815 detail.contains(&root.to_string_lossy().to_ascii_lowercase())
1816 && !jail.iter().any(|jail_root| path_is_within(root, jail_root))
1817 })
1818}
1819
1820#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1830pub(crate) fn unavailable(
1831 message: &str,
1832 profile: SandboxProfile,
1833) -> Result<PrepareOutcome, VmError> {
1834 match effective_fallback(profile) {
1835 SandboxFallback::Off | SandboxFallback::Warn => {
1836 warn_once("handler_sandbox_unavailable", message);
1837 Ok(PrepareOutcome::Direct)
1838 }
1839 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1840 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1841 ))),
1842 }
1843}
1844
1845fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1853 let session_id = crate::agent_sessions::current_session_id()?;
1854 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1855 let mut roots = vec![anchor.primary.clone()];
1856 for mounted in &anchor.additional_roots {
1857 if matches!(
1858 mounted.mount_mode,
1859 crate::workspace_anchor::MountMode::Extend
1860 ) {
1861 roots.push(mounted.path.clone());
1862 }
1863 }
1864 Some(roots)
1865}
1866
1867fn project_root_workspace_root() -> Option<PathBuf> {
1872 crate::stdlib::process::project_root_path().or_else(|| {
1873 std::env::var("HARN_PROJECT_ROOT")
1874 .ok()
1875 .map(|value| value.trim().to_string())
1876 .filter(|value| !value.is_empty())
1877 .map(PathBuf::from)
1878 })
1879}
1880
1881fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1882 let mut roots = base_workspace_roots(policy);
1883 let mut outside = git_scope_extension_for_roots(&roots).read_write;
1886 outside.extend(relocated_runtime_roots(&roots));
1887 for dir in outside {
1888 if !roots.iter().any(|existing| existing == &dir) {
1889 roots.push(dir);
1890 }
1891 }
1892 roots
1893}
1894
1895fn base_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1900 if policy.workspace_roots.is_empty() {
1901 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1917 return anchor_roots
1918 .iter()
1919 .map(|root| normalize_for_policy(root))
1920 .collect();
1921 }
1922 if let Some(project_root) = project_root_workspace_root() {
1923 return vec![normalize_for_policy(&project_root)];
1924 }
1925 return vec![normalize_for_policy(
1926 &crate::stdlib::process::execution_root_path(),
1927 )];
1928 }
1929 policy
1930 .workspace_roots
1931 .iter()
1932 .map(|root| render_policy_root(root))
1933 .collect()
1934}
1935
1936pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1937 normalized_workspace_roots(policy)
1938}
1939
1940fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1945 let mut roots: Vec<PathBuf> = policy
1946 .read_only_roots
1947 .iter()
1948 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1949 .collect();
1950 for dir in git_scope_extension_for_roots(&base_workspace_roots(policy)).read_only {
1954 if !roots.iter().any(|existing| existing == &dir) {
1955 roots.push(dir);
1956 }
1957 }
1958 roots
1959}
1960
1961fn git_scope_extension_for_roots(
1966 base_roots: &[PathBuf],
1967) -> crate::stdlib::git_topology::GitScopeExtension {
1968 let mut merged = crate::stdlib::git_topology::GitScopeExtension::default();
1969 for root in base_roots {
1970 let ext = crate::stdlib::git_topology::git_scope_extension(root);
1971 for dir in ext.read_write {
1972 let dir = normalize_for_policy(&dir);
1973 if !merged.read_write.iter().any(|existing| existing == &dir) {
1974 merged.read_write.push(dir);
1975 }
1976 }
1977 for dir in ext.read_only {
1978 let dir = normalize_for_policy(&dir);
1979 if !merged.read_only.iter().any(|existing| existing == &dir) {
1980 merged.read_only.push(dir);
1981 }
1982 }
1983 }
1984 merged
1985}
1986
1987#[cfg(any(
1988 target_os = "linux",
1989 target_os = "macos",
1990 target_os = "openbsd",
1991 target_os = "windows"
1992))]
1993pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1994 normalized_read_only_roots(policy)
1995}
1996
1997#[cfg(any(
1998 target_os = "linux",
1999 target_os = "macos",
2000 target_os = "openbsd",
2001 target_os = "windows"
2002))]
2003pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2004 normalized_process_roots(&policy.process_sandbox.read_roots)
2005}
2006
2007#[cfg(any(
2008 target_os = "linux",
2009 target_os = "macos",
2010 target_os = "openbsd",
2011 target_os = "windows"
2012))]
2013pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2014 normalized_process_roots(&policy.process_sandbox.write_roots)
2015}
2016
2017#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2018pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
2019 policy.process_sandbox.effective_presets()
2020}
2021
2022#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2023pub(crate) fn process_sandbox_developer_toolchain_read_roots(
2024 policy: &CapabilityPolicy,
2025) -> Vec<PathBuf> {
2026 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2027 return Vec::new();
2028 }
2029 let Some(home) = sandbox_user_home_dir() else {
2030 return Vec::new();
2031 };
2032 developer_toolchain_read_roots_for_home(&home)
2033}
2034
2035#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2036pub(crate) fn process_sandbox_package_manager_config_read_roots(
2037 policy: &CapabilityPolicy,
2038) -> Vec<PathBuf> {
2039 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
2040 return Vec::new();
2041 }
2042 let Some(home) = sandbox_user_home_dir() else {
2043 return Vec::new();
2044 };
2045 package_manager_config_read_roots_for_home(&home)
2046}
2047
2048#[cfg(any(target_os = "linux", target_os = "macos"))]
2063pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
2064 policy: &CapabilityPolicy,
2065) -> Vec<PathBuf> {
2066 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2067 return Vec::new();
2068 }
2069 let Some(home) = sandbox_user_home_dir() else {
2070 return Vec::new();
2071 };
2072 developer_toolchain_cache_write_roots_for_home(&home)
2073}
2074
2075#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2076fn sandbox_user_home_dir() -> Option<PathBuf> {
2077 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
2080}
2081
2082#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2083pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2084 let mut roots: Vec<_> = [
2085 ".asdf",
2086 ".bun",
2087 ".cargo",
2088 ".fnm",
2089 ".juliaup",
2090 ".local/bin",
2091 ".local/share/mise",
2092 ".local/share/uv",
2093 ".nvm",
2094 ".pyenv",
2095 ".rbenv",
2096 ".rustup",
2097 ".sdkman",
2098 ".swiftly",
2099 ".volta",
2100 "go",
2101 ]
2102 .into_iter()
2103 .map(|entry| normalize_for_policy(&home.join(entry)))
2104 .collect();
2105 #[cfg(target_os = "windows")]
2106 roots.extend(
2107 [
2108 "AppData/Local/Programs/Python",
2109 "AppData/Local/uv",
2110 "AppData/Roaming/uv",
2111 "scoop",
2112 ]
2113 .into_iter()
2114 .map(|entry| normalize_for_policy(&home.join(entry))),
2115 );
2116 roots.sort_unstable();
2117 roots.dedup();
2118 roots
2119}
2120
2121#[cfg(any(target_os = "linux", target_os = "macos"))]
2126pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
2127 let mut roots: Vec<_> = [
2128 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", "Library/Caches/go-build", ".cache/go-build", "go/pkg/mod", "Library/Application Support/go", ".cargo/registry", ".cargo/git", ".cargo/.package-cache", ]
2169 .into_iter()
2170 .map(|entry| normalize_for_policy(&home.join(entry)))
2171 .collect();
2172 roots.sort_unstable();
2173 roots.dedup();
2174 roots
2175}
2176
2177#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2178pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2179 let mut roots: Vec<_> = [
2180 ".npmrc",
2181 ".gitconfig",
2182 ".netrc",
2183 ".yarnrc.yml",
2184 ".config",
2185 ".npm",
2186 ".cache",
2187 ".pip",
2188 ".pypirc",
2189 ".cargo/config",
2190 ".cargo/config.toml",
2191 ".cargo/credentials",
2192 ".cargo/credentials.toml",
2193 ]
2201 .into_iter()
2202 .map(|entry| normalize_for_policy(&home.join(entry)))
2203 .collect();
2204 roots.sort_unstable();
2205 roots.dedup();
2206 roots
2207}
2208
2209fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
2210 roots
2211 .iter()
2212 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
2213 .collect()
2214}
2215
2216fn resolve_policy_path(path: &str) -> PathBuf {
2217 let candidate = PathBuf::from(path);
2218 if candidate.is_absolute() {
2219 candidate
2220 } else {
2221 crate::stdlib::process::execution_root_path().join(candidate)
2222 }
2223}
2224
2225pub fn render_policy_root(path: &str) -> PathBuf {
2231 normalize_for_policy(&resolve_policy_path(path))
2232}
2233
2234#[cfg(any(
2235 target_os = "linux",
2236 target_os = "macos",
2237 target_os = "openbsd",
2238 target_os = "windows"
2239))]
2240pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
2241 !policy.capabilities_are_restricted()
2242 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
2243}
2244
2245#[cfg(any(
2246 target_os = "linux",
2247 target_os = "macos",
2248 target_os = "openbsd",
2249 target_os = "windows"
2250))]
2251pub(crate) fn policy_allows_capability(
2252 policy: &CapabilityPolicy,
2253 capability: &str,
2254 ops: &[&str],
2255) -> bool {
2256 policy
2257 .capabilities
2258 .get(capability)
2259 .map(|allowed| {
2260 ops.iter()
2261 .any(|op| allowed.iter().any(|candidate| candidate == op))
2262 })
2263 .unwrap_or(false)
2264}
2265
2266impl FsAccess {
2267 fn verb(self) -> &'static str {
2268 match self {
2269 FsAccess::Read => "read",
2270 FsAccess::Write => "write",
2271 FsAccess::Delete => "delete",
2272 }
2273 }
2274}
2275
2276#[cfg(test)]
2277mod tests;