1use std::cell::RefCell;
38use std::collections::BTreeSet;
39use std::path::{Component, Path, PathBuf};
40use std::process::{Command, Output, Stdio};
41
42#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
43use crate::orchestration::ProcessSandboxPreset;
44use crate::orchestration::{CapabilityPolicy, SandboxProfile};
45use crate::value::{ErrorCategory, VmError, VmValue};
46use crate::vm::Vm;
47
48#[cfg(target_os = "linux")]
49mod linux;
50#[cfg(target_os = "macos")]
51mod macos;
52#[cfg(target_os = "openbsd")]
53mod openbsd;
54#[cfg(target_os = "windows")]
55mod windows;
56
57const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
58
59thread_local! {
60 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FsAccess {
68 Read,
69 Write,
70 Delete,
71}
72
73#[derive(Clone, Debug, Default)]
74pub struct ProcessCommandConfig {
75 pub cwd: Option<PathBuf>,
76 pub env: Vec<(String, String)>,
77 pub stdin_null: bool,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub(crate) enum SandboxFallback {
82 Off,
83 Warn,
84 Enforce,
85}
86
87pub(crate) trait SandboxBackend {
98 fn name() -> &'static str;
100
101 fn available() -> bool;
105
106 fn prepare_std_command(
112 program: &str,
113 args: &[String],
114 command: &mut Command,
115 policy: &CapabilityPolicy,
116 profile: SandboxProfile,
117 ) -> Result<PrepareOutcome, VmError>;
118
119 fn prepare_tokio_command(
121 program: &str,
122 args: &[String],
123 command: &mut tokio::process::Command,
124 policy: &CapabilityPolicy,
125 profile: SandboxProfile,
126 ) -> Result<PrepareOutcome, VmError>;
127
128 fn run_to_output(
133 program: &str,
134 args: &[String],
135 config: &ProcessCommandConfig,
136 policy: &CapabilityPolicy,
137 profile: SandboxProfile,
138 ) -> Result<Output, VmError> {
139 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
140 apply_process_config(&mut command, config);
141 command
142 .output()
143 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
144 }
145}
146
147pub(crate) enum PrepareOutcome {
151 Direct,
153 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
159 WrappedExec { wrapper: String, args: Vec<String> },
160}
161
162#[cfg(target_os = "linux")]
163type ActiveBackend = linux::Backend;
164#[cfg(target_os = "macos")]
165type ActiveBackend = macos::Backend;
166#[cfg(target_os = "openbsd")]
167type ActiveBackend = openbsd::Backend;
168#[cfg(target_os = "windows")]
169type ActiveBackend = windows::Backend;
170#[cfg(not(any(
171 target_os = "linux",
172 target_os = "macos",
173 target_os = "openbsd",
174 target_os = "windows"
175)))]
176type ActiveBackend = NoopBackend;
177
178#[cfg(not(any(
179 target_os = "linux",
180 target_os = "macos",
181 target_os = "openbsd",
182 target_os = "windows"
183)))]
184pub(crate) struct NoopBackend;
185
186#[cfg(not(any(
187 target_os = "linux",
188 target_os = "macos",
189 target_os = "openbsd",
190 target_os = "windows"
191)))]
192impl SandboxBackend for NoopBackend {
193 fn name() -> &'static str {
194 "noop"
195 }
196 fn available() -> bool {
197 false
198 }
199 fn prepare_std_command(
200 _program: &str,
201 _args: &[String],
202 _command: &mut Command,
203 _policy: &CapabilityPolicy,
204 _profile: SandboxProfile,
205 ) -> Result<PrepareOutcome, VmError> {
206 Ok(PrepareOutcome::Direct)
207 }
208 fn prepare_tokio_command(
209 _program: &str,
210 _args: &[String],
211 _command: &mut tokio::process::Command,
212 _policy: &CapabilityPolicy,
213 _profile: SandboxProfile,
214 ) -> Result<PrepareOutcome, VmError> {
215 Ok(PrepareOutcome::Direct)
216 }
217}
218
219pub(crate) fn reset_sandbox_state() {
220 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
221}
222
223pub fn active_backend_name() -> &'static str {
227 ActiveBackend::name()
228}
229
230pub fn active_backend_available() -> bool {
235 ActiveBackend::available()
236}
237
238pub fn register_sandbox_builtins(vm: &mut Vm) {
242 for def in MODULE_BUILTINS {
243 vm.register_builtin_def(def);
244 }
245}
246
247pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
248 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
249 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
250 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
251];
252
253#[crate::stdlib::macros::harn_builtin(
254 sig = "sandbox_active_backend() -> string",
255 category = "sandbox"
256)]
257fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
258 Ok(VmValue::String(std::sync::Arc::from(active_backend_name())))
259}
260
261#[crate::stdlib::macros::harn_builtin(
262 sig = "sandbox_backend_available() -> bool",
263 category = "sandbox"
264)]
265fn sandbox_backend_available_impl(
266 _args: &[VmValue],
267 _out: &mut String,
268) -> Result<VmValue, VmError> {
269 Ok(VmValue::Bool(active_backend_available()))
270}
271
272#[crate::stdlib::macros::harn_builtin(
273 sig = "sandbox_active_profile() -> string",
274 category = "sandbox"
275)]
276fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277 let profile = crate::orchestration::current_execution_policy()
278 .map(|policy| policy.sandbox_profile)
279 .unwrap_or(SandboxProfile::Unrestricted);
280 Ok(VmValue::String(std::sync::Arc::from(profile.as_str())))
281}
282
283#[derive(Clone, Debug)]
290pub struct SandboxViolation {
291 pub attempted: PathBuf,
295 pub roots: Vec<PathBuf>,
298 pub access: FsAccess,
300 pub read_only: bool,
304}
305
306impl SandboxViolation {
307 pub fn message(&self, builtin: &str) -> String {
311 if self.read_only {
312 return format!(
313 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
314 self.access.verb(),
315 self.attempted.display(),
316 );
317 }
318 format!(
319 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
320 self.access.verb(),
321 self.attempted.display(),
322 self.roots
323 .iter()
324 .map(|root| root.display().to_string())
325 .collect::<Vec<_>>()
326 .join(", ")
327 )
328 }
329}
330
331pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
346 let Some(policy) = crate::orchestration::current_execution_policy() else {
347 return Ok(());
348 };
349 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
350 return Ok(());
351 }
352 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
363 return Ok(());
364 }
365 let candidate = normalize_for_policy(path);
366 let roots = normalized_workspace_roots(&policy);
367 if roots.iter().any(|root| path_is_within(&candidate, root)) {
368 return Ok(());
369 }
370 let read_only_roots = normalized_read_only_roots(&policy);
371 let within_read_only = read_only_roots
372 .iter()
373 .any(|root| path_is_within(&candidate, root));
374 if within_read_only && access == FsAccess::Read {
375 return Ok(());
376 }
377 Err(SandboxViolation {
378 attempted: candidate,
379 roots,
380 access,
381 read_only: within_read_only,
382 })
383}
384
385pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
386 check_fs_path_scope(path, access)
387 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
388}
389
390pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
391 let Some(policy) = crate::orchestration::current_execution_policy() else {
392 return Ok(());
393 };
394 enforce_process_cwd_for_policy(path, &policy)
395}
396
397fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
398 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
399 return Ok(());
400 }
401 let candidate = normalize_for_policy(path);
402 let roots = normalized_workspace_roots(policy);
403 if roots.iter().any(|root| path_is_within(&candidate, root)) {
404 return Ok(());
405 }
406 Err(sandbox_rejection(format!(
407 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
408 candidate.display(),
409 roots
410 .iter()
411 .map(|root| root.display().to_string())
412 .collect::<Vec<_>>()
413 .join(", ")
414 )))
415}
416
417pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
418 let (policy, profile) = match active_sandbox_policy() {
419 Some(value) => value,
420 None => {
421 let mut command = Command::new(program);
422 command.args(args);
423 return Ok(command);
424 }
425 };
426 build_std_command::<ActiveBackend>(program, args, &policy, profile)
427}
428
429pub fn tokio_command_for(
430 program: &str,
431 args: &[String],
432) -> Result<tokio::process::Command, VmError> {
433 let (policy, profile) = match active_sandbox_policy() {
434 Some(value) => value,
435 None => {
436 let mut command = tokio::process::Command::new(program);
437 command.args(args);
438 return Ok(command);
439 }
440 };
441 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
442}
443
444pub fn command_output(
445 program: &str,
446 args: &[String],
447 config: &ProcessCommandConfig,
448) -> Result<Output, VmError> {
449 if let Some(intercepted) =
454 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
455 {
456 return intercepted.map_err(|message| {
457 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(message)))
458 });
459 }
460
461 let recording =
462 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
463
464 let output = match active_sandbox_policy() {
465 Some((policy, profile)) => {
466 let config = sandboxed_process_config(config, &policy)?;
467 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
468 }
469 None => {
470 let mut command = Command::new(program);
471 command.args(args);
472 apply_process_config(&mut command, config);
473 command.output().map_err(|error| {
474 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
475 })?
476 }
477 };
478 if let Some(error) = process_violation_error(&output) {
479 return Err(error);
480 }
481 if let Some(span) = recording {
482 span.finish(&output);
483 }
484 Ok(output)
485}
486
487fn sandboxed_process_config(
488 config: &ProcessCommandConfig,
489 policy: &CapabilityPolicy,
490) -> Result<ProcessCommandConfig, VmError> {
491 let mut resolved = config.clone();
492 if let Some(cwd) = resolved.cwd.as_ref() {
493 enforce_process_cwd_for_policy(cwd, policy)?;
494 } else {
495 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
496 }
497 neutralize_rustc_wrapper(&mut resolved.env);
498 Ok(resolved)
499}
500
501fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
515 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
516 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
517 entry.1.clear();
518 } else {
519 env.push((key.to_string(), String::new()));
520 }
521 }
522}
523
524fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
525 let roots = normalized_workspace_roots(policy);
526 let current = std::env::current_dir().map_err(|error| {
527 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
528 format!("process cwd resolution failed: {error}"),
529 )))
530 })?;
531 let current = normalize_for_policy(¤t);
532 if roots.iter().any(|root| path_is_within(¤t, root)) {
533 return Ok(current);
534 }
535 roots.first().cloned().ok_or_else(|| {
536 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
537 "process cwd resolution failed: no workspace root available",
538 )))
539 })
540}
541
542fn build_std_command<B: SandboxBackend + ?Sized>(
543 program: &str,
544 args: &[String],
545 policy: &CapabilityPolicy,
546 profile: SandboxProfile,
547) -> Result<Command, VmError> {
548 let mut command = Command::new(program);
549 command.args(args);
550 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
551 PrepareOutcome::Direct => Ok(command),
552 PrepareOutcome::WrappedExec { wrapper, args } => {
553 let mut wrapped = Command::new(wrapper);
554 wrapped.args(args);
555 Ok(wrapped)
556 }
557 }
558}
559
560fn build_tokio_command<B: SandboxBackend + ?Sized>(
561 program: &str,
562 args: &[String],
563 policy: &CapabilityPolicy,
564 profile: SandboxProfile,
565) -> Result<tokio::process::Command, VmError> {
566 let mut command = tokio::process::Command::new(program);
567 command.args(args);
568 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
569 PrepareOutcome::Direct => Ok(command),
570 PrepareOutcome::WrappedExec { wrapper, args } => {
571 let mut wrapped = tokio::process::Command::new(wrapper);
572 wrapped.args(args);
573 Ok(wrapped)
574 }
575 }
576}
577
578pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
579 let policy = crate::orchestration::current_execution_policy()?;
580 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
581 return None;
582 }
583 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
584 || !ActiveBackend::available()
585 {
586 return None;
587 }
588 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
589 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
590 if !output.status.success()
591 && (stderr.contains("operation not permitted")
592 || stderr.contains("permission denied")
593 || stderr.contains("access is denied")
594 || stdout.contains("operation not permitted"))
595 {
596 return Some(sandbox_rejection(sandbox_process_violation_message(
597 format!(
598 "sandbox violation: process was denied by the OS sandbox (status {})",
599 output.status.code().unwrap_or(-1)
600 ),
601 )));
602 }
603 if sandbox_signal_status(output) {
604 return Some(sandbox_rejection(sandbox_process_violation_message(
605 format!(
606 "sandbox violation: process was terminated by the OS sandbox (status {})",
607 output.status
608 ),
609 )));
610 }
611 None
612}
613
614pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
615 let policy = crate::orchestration::current_execution_policy()?;
616 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
617 return None;
618 }
619 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
620 || !ActiveBackend::available()
621 {
622 return None;
623 }
624 let message = error.to_string().to_ascii_lowercase();
625 if error.kind() == std::io::ErrorKind::PermissionDenied
626 || message.contains("operation not permitted")
627 || message.contains("permission denied")
628 || message.contains("access is denied")
629 {
630 return Some(sandbox_rejection(sandbox_process_violation_message(
631 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
632 )));
633 }
634 None
635}
636
637#[cfg(unix)]
638fn sandbox_signal_status(output: &std::process::Output) -> bool {
639 use std::os::unix::process::ExitStatusExt;
640
641 matches!(
642 output.status.signal(),
643 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
644 )
645}
646
647#[cfg(not(unix))]
648fn sandbox_signal_status(_output: &std::process::Output) -> bool {
649 false
650}
651
652pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
660 let policy = crate::orchestration::current_execution_policy()?;
661 let profile = policy.sandbox_profile;
662 match profile {
663 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
664 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
665 if effective_fallback(profile) == SandboxFallback::Off {
666 None
667 } else {
668 Some((policy, profile))
669 }
670 }
671 }
672}
673
674fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
675 if let Some(cwd) = config.cwd.as_ref() {
676 command.current_dir(cwd);
677 }
678 command.envs(config.env.iter().map(|(key, value)| (key, value)));
679 if config.stdin_null {
680 command.stdin(Stdio::null());
681 }
682}
683
684fn spawn_error(error: std::io::Error) -> VmError {
685 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
686 format!("process spawn failed: {error}"),
687 )))
688}
689
690pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
695 if matches!(profile, SandboxProfile::OsHardened) {
696 return SandboxFallback::Enforce;
697 }
698 match std::env::var(HANDLER_SANDBOX_ENV)
699 .unwrap_or_else(|_| "warn".to_string())
700 .trim()
701 .to_ascii_lowercase()
702 .as_str()
703 {
704 "0" | "false" | "off" | "none" => SandboxFallback::Off,
705 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
706 _ => SandboxFallback::Warn,
707 }
708}
709
710pub(crate) fn warn_once(key: &str, message: &str) {
711 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
712 if inserted {
713 crate::events::log_warn("handler_sandbox", message);
714 }
715}
716
717pub(crate) fn sandbox_rejection(message: String) -> VmError {
718 VmError::CategorizedError {
719 message,
720 category: ErrorCategory::ToolRejected,
721 }
722}
723
724fn sandbox_process_violation_message(summary: String) -> String {
725 format!(
726 "{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"
727 )
728}
729
730#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
740pub(crate) fn unavailable(
741 message: &str,
742 profile: SandboxProfile,
743) -> Result<PrepareOutcome, VmError> {
744 match effective_fallback(profile) {
745 SandboxFallback::Off | SandboxFallback::Warn => {
746 warn_once("handler_sandbox_unavailable", message);
747 Ok(PrepareOutcome::Direct)
748 }
749 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
750 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
751 ))),
752 }
753}
754
755fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
756 if policy.workspace_roots.is_empty() {
757 return vec![normalize_for_policy(
758 &crate::stdlib::process::execution_root_path(),
759 )];
760 }
761 policy
762 .workspace_roots
763 .iter()
764 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
765 .collect()
766}
767
768pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
769 normalized_workspace_roots(policy)
770}
771
772fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
777 policy
778 .read_only_roots
779 .iter()
780 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
781 .collect()
782}
783
784#[cfg(any(
785 target_os = "linux",
786 target_os = "macos",
787 target_os = "openbsd",
788 target_os = "windows"
789))]
790pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
791 normalized_read_only_roots(policy)
792}
793
794#[cfg(any(
795 target_os = "linux",
796 target_os = "macos",
797 target_os = "openbsd",
798 target_os = "windows"
799))]
800pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
801 normalized_process_roots(&policy.process_sandbox.read_roots)
802}
803
804#[cfg(any(
805 target_os = "linux",
806 target_os = "macos",
807 target_os = "openbsd",
808 target_os = "windows"
809))]
810pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
811 normalized_process_roots(&policy.process_sandbox.write_roots)
812}
813
814#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
815pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
816 policy.process_sandbox.effective_presets()
817}
818
819#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
820pub(crate) fn process_sandbox_developer_toolchain_read_roots(
821 policy: &CapabilityPolicy,
822) -> Vec<PathBuf> {
823 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
824 return Vec::new();
825 }
826 let Some(home) = sandbox_user_home_dir() else {
827 return Vec::new();
828 };
829 developer_toolchain_read_roots_for_home(&home)
830}
831
832#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
833pub(crate) fn process_sandbox_package_manager_config_read_roots(
834 policy: &CapabilityPolicy,
835) -> Vec<PathBuf> {
836 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
837 return Vec::new();
838 }
839 let Some(home) = sandbox_user_home_dir() else {
840 return Vec::new();
841 };
842 package_manager_config_read_roots_for_home(&home)
843}
844
845#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
846fn sandbox_user_home_dir() -> Option<PathBuf> {
847 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
850}
851
852#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
853pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
854 let mut roots: Vec<_> = [
855 ".asdf",
856 ".bun",
857 ".cargo",
858 ".fnm",
859 ".juliaup",
860 ".local/bin",
861 ".local/share/mise",
862 ".local/share/uv",
863 ".nvm",
864 ".pyenv",
865 ".rbenv",
866 ".rustup",
867 ".sdkman",
868 ".swiftly",
869 ".volta",
870 "go",
871 ]
872 .into_iter()
873 .map(|entry| normalize_for_policy(&home.join(entry)))
874 .collect();
875 #[cfg(target_os = "windows")]
876 roots.extend(
877 [
878 "AppData/Local/Programs/Python",
879 "AppData/Local/uv",
880 "AppData/Roaming/uv",
881 "scoop",
882 ]
883 .into_iter()
884 .map(|entry| normalize_for_policy(&home.join(entry))),
885 );
886 roots.sort_unstable();
887 roots.dedup();
888 roots
889}
890
891#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
892pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
893 let mut roots: Vec<_> = [
894 ".npmrc",
895 ".gitconfig",
896 ".netrc",
897 ".yarnrc.yml",
898 ".config",
899 ".npm",
900 ".cache",
901 ".pip",
902 ".pypirc",
903 ".cargo/config",
904 ".cargo/config.toml",
905 ".cargo/credentials",
906 ".cargo/credentials.toml",
907 ".cargo/registry",
908 ".cargo/git",
909 ]
910 .into_iter()
911 .map(|entry| normalize_for_policy(&home.join(entry)))
912 .collect();
913 roots.sort_unstable();
914 roots.dedup();
915 roots
916}
917
918#[cfg(any(
919 target_os = "linux",
920 target_os = "macos",
921 target_os = "openbsd",
922 target_os = "windows"
923))]
924fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
925 roots
926 .iter()
927 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
928 .collect()
929}
930
931fn resolve_policy_path(path: &str) -> PathBuf {
932 let candidate = PathBuf::from(path);
933 if candidate.is_absolute() {
934 candidate
935 } else {
936 crate::stdlib::process::execution_root_path().join(candidate)
937 }
938}
939
940fn normalize_for_policy(path: &Path) -> PathBuf {
941 let absolute = if path.is_absolute() {
942 path.to_path_buf()
943 } else {
944 crate::stdlib::process::execution_root_path().join(path)
945 };
946 let absolute = normalize_lexically(&absolute);
947 if let Ok(canonical) = absolute.canonicalize() {
948 return canonical;
949 }
950
951 let mut existing = absolute.as_path();
952 let mut suffix = Vec::new();
953 while !existing.exists() {
954 let Some(parent) = existing.parent() else {
955 return normalize_lexically(&absolute);
956 };
957 if let Some(name) = existing.file_name() {
958 suffix.push(name.to_os_string());
959 }
960 existing = parent;
961 }
962
963 let mut normalized = existing
964 .canonicalize()
965 .unwrap_or_else(|_| normalize_lexically(existing));
966 for component in suffix.iter().rev() {
967 normalized.push(component);
968 }
969 normalize_lexically(&normalized)
970}
971
972fn normalize_lexically(path: &Path) -> PathBuf {
973 let mut normalized = PathBuf::new();
974 for component in path.components() {
975 match component {
976 Component::CurDir => {}
977 Component::ParentDir => {
978 normalized.pop();
979 }
980 other => normalized.push(other.as_os_str()),
981 }
982 }
983 normalized
984}
985
986fn path_is_within(path: &Path, root: &Path) -> bool {
987 path == root || path.starts_with(root)
988}
989
990fn normalize_io_device_path(path: &Path) -> PathBuf {
995 let absolute = if path.is_absolute() {
996 path.to_path_buf()
997 } else {
998 crate::stdlib::process::execution_root_path().join(path)
999 };
1000 normalize_lexically(&absolute)
1001}
1002
1003fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1008 match access {
1009 FsAccess::Read => {
1010 matches!(
1011 path.to_str(),
1012 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1013 ) || is_dev_fd_descriptor(path)
1014 }
1015 FsAccess::Write => {
1016 matches!(
1017 path.to_str(),
1018 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1019 ) || is_dev_fd_descriptor(path)
1020 }
1021 FsAccess::Delete => false,
1022 }
1023}
1024
1025fn is_dev_fd_descriptor(path: &Path) -> bool {
1028 let Some(text) = path.to_str() else {
1029 return false;
1030 };
1031 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1032 return false;
1033 };
1034 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1035}
1036
1037#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1038pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1039 fn rank(value: &str) -> usize {
1040 match value {
1041 "none" => 0,
1042 "read_only" => 1,
1043 "workspace_write" => 2,
1044 "process_exec" => 3,
1045 "network" => 4,
1046 _ => 5,
1047 }
1048 }
1049 policy
1050 .side_effect_level
1051 .as_ref()
1052 .map(|level| rank(level) >= rank("network"))
1053 .unwrap_or(true)
1054}
1055
1056#[cfg(any(
1057 target_os = "linux",
1058 target_os = "macos",
1059 target_os = "openbsd",
1060 target_os = "windows"
1061))]
1062pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1063 policy.capabilities.is_empty()
1064 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1065}
1066
1067#[cfg(any(
1068 target_os = "linux",
1069 target_os = "macos",
1070 target_os = "openbsd",
1071 target_os = "windows"
1072))]
1073pub(crate) fn policy_allows_capability(
1074 policy: &CapabilityPolicy,
1075 capability: &str,
1076 ops: &[&str],
1077) -> bool {
1078 policy
1079 .capabilities
1080 .get(capability)
1081 .map(|allowed| {
1082 ops.iter()
1083 .any(|op| allowed.iter().any(|candidate| candidate == op))
1084 })
1085 .unwrap_or(false)
1086}
1087
1088impl FsAccess {
1089 fn verb(self) -> &'static str {
1090 match self {
1091 FsAccess::Read => "read",
1092 FsAccess::Write => "write",
1093 FsAccess::Delete => "delete",
1094 }
1095 }
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1102
1103 #[test]
1104 fn missing_create_path_normalizes_against_existing_parent() {
1105 let dir = tempfile::tempdir().unwrap();
1106 let nested = dir.path().join("a/../new.txt");
1107 let normalized = normalize_for_policy(&nested);
1108 assert_eq!(
1109 normalized,
1110 normalize_for_policy(&dir.path().join("new.txt"))
1111 );
1112 }
1113
1114 #[test]
1115 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1116 let dir = tempfile::tempdir().unwrap();
1117 crate::stdlib::process::set_thread_execution_context(Some(
1118 crate::orchestration::RunExecutionRecord {
1119 cwd: Some(dir.path().to_string_lossy().into_owned()),
1120 source_dir: None,
1121 env: Default::default(),
1122 adapter: None,
1123 repo_path: None,
1124 worktree_path: None,
1125 branch: None,
1126 base_ref: None,
1127 cleanup: None,
1128 },
1129 ));
1130 push_execution_policy(CapabilityPolicy {
1131 sandbox_profile: SandboxProfile::Worktree,
1132 ..CapabilityPolicy::default()
1133 });
1134
1135 assert!(
1136 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1137 );
1138 let outside = tempfile::tempdir().unwrap();
1139 assert!(enforce_fs_path(
1140 "read_file",
1141 &outside.path().join("outside.txt"),
1142 FsAccess::Read
1143 )
1144 .is_err());
1145
1146 pop_execution_policy();
1147 crate::stdlib::process::set_thread_execution_context(None);
1148 }
1149
1150 #[test]
1151 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1152 let dir = tempfile::tempdir().unwrap();
1153 crate::stdlib::process::set_thread_execution_context(Some(
1154 crate::orchestration::RunExecutionRecord {
1155 cwd: Some(dir.path().to_string_lossy().into_owned()),
1156 source_dir: None,
1157 env: Default::default(),
1158 adapter: None,
1159 repo_path: None,
1160 worktree_path: None,
1161 branch: None,
1162 base_ref: None,
1163 cleanup: None,
1164 },
1165 ));
1166 push_execution_policy(CapabilityPolicy {
1167 sandbox_profile: SandboxProfile::Worktree,
1168 ..CapabilityPolicy::default()
1169 });
1170
1171 assert!(enforce_process_cwd(dir.path()).is_ok());
1172 let outside = tempfile::tempdir().unwrap();
1173 assert!(enforce_process_cwd(outside.path()).is_err());
1174
1175 pop_execution_policy();
1176 crate::stdlib::process::set_thread_execution_context(None);
1177 }
1178
1179 #[test]
1180 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1181 let cwd = std::env::current_dir().unwrap();
1182 let policy = CapabilityPolicy {
1183 sandbox_profile: SandboxProfile::Worktree,
1184 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1185 ..CapabilityPolicy::default()
1186 };
1187
1188 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1189
1190 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1191 }
1192
1193 #[test]
1194 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1195 let workspace = tempfile::tempdir().unwrap();
1196 let policy = CapabilityPolicy {
1197 sandbox_profile: SandboxProfile::Worktree,
1198 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1199 ..CapabilityPolicy::default()
1200 };
1201
1202 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1203
1204 assert_eq!(
1205 resolved.cwd.unwrap(),
1206 normalize_for_policy(workspace.path())
1207 );
1208 }
1209
1210 #[test]
1211 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1212 let workspace = tempfile::tempdir().unwrap();
1213 let outside = tempfile::tempdir().unwrap();
1214 let policy = CapabilityPolicy {
1215 sandbox_profile: SandboxProfile::Worktree,
1216 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1217 ..CapabilityPolicy::default()
1218 };
1219 let config = ProcessCommandConfig {
1220 cwd: Some(outside.path().to_path_buf()),
1221 ..ProcessCommandConfig::default()
1222 };
1223
1224 assert!(sandboxed_process_config(&config, &policy).is_err());
1225 }
1226
1227 #[test]
1228 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1229 let cwd = std::env::current_dir().unwrap();
1230 let policy = CapabilityPolicy {
1231 sandbox_profile: SandboxProfile::Worktree,
1232 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1233 ..CapabilityPolicy::default()
1234 };
1235
1236 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1239 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1240 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1241 assert_eq!(
1242 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1243 Some("")
1244 );
1245 }
1246
1247 #[test]
1248 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1249 let mut env = vec![
1252 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1253 ("PATH".to_string(), "/usr/bin".to_string()),
1254 ];
1255 neutralize_rustc_wrapper(&mut env);
1256 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1257 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1258 assert_eq!(
1259 collected
1260 .get("CARGO_BUILD_RUSTC_WRAPPER")
1261 .map(String::as_str),
1262 Some("")
1263 );
1264 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1265 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1267 }
1268
1269 #[test]
1270 fn read_only_root_outside_workspace_allows_read_denies_write() {
1271 let workspace = tempfile::tempdir().unwrap();
1276 let read_only = tempfile::tempdir().unwrap();
1277 push_execution_policy(CapabilityPolicy {
1278 sandbox_profile: SandboxProfile::Worktree,
1279 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1280 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1281 ..CapabilityPolicy::default()
1282 });
1283
1284 let asset = read_only
1285 .path()
1286 .join("partials/agent-web-tools.harn.prompt");
1287 assert!(
1289 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1290 "read under a configured read-only root must be allowed"
1291 );
1292
1293 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1295 .expect_err("write under a read-only root must be denied");
1296 assert!(write_err.read_only, "write rejection must set read_only");
1297
1298 assert!(
1300 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1301 "delete under a read-only root must be denied"
1302 );
1303
1304 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1306
1307 let stranger = tempfile::tempdir().unwrap();
1310 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1311 .expect_err("read outside all roots must be denied");
1312 assert!(
1313 !outside_err.read_only,
1314 "out-of-scope rejection must not be flagged read_only"
1315 );
1316
1317 pop_execution_policy();
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn standard_io_device_files_allowed_under_restricted_profile() {
1323 let workspace = tempfile::tempdir().unwrap();
1328 push_execution_policy(CapabilityPolicy {
1329 sandbox_profile: SandboxProfile::Worktree,
1330 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1331 ..CapabilityPolicy::default()
1332 });
1333
1334 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1335 assert!(
1336 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1337 "write to standard device {device} must be allowed"
1338 );
1339 assert!(
1341 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1342 "read of standard device {device} must be allowed"
1343 );
1344 }
1345 assert!(
1346 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1347 "read of standard device /dev/stdin must be allowed"
1348 );
1349 assert!(
1350 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1351 "write to /dev/stdin is not a standard output stream"
1352 );
1353 assert!(
1354 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1355 "standard devices must not bypass delete scoping"
1356 );
1357 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1359 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1360
1361 let stranger = tempfile::tempdir().unwrap();
1363 assert!(
1364 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1365 "a real out-of-root write must still be rejected"
1366 );
1367 assert!(
1369 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1370 "/dev/sda must not be allowed by the standard-device allowlist"
1371 );
1372 assert!(
1373 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1374 "non-numeric /dev/fd/<x> must not be allowed"
1375 );
1376
1377 pop_execution_policy();
1378 }
1379
1380 #[test]
1381 fn is_standard_io_device_matches_only_known_streams() {
1382 assert!(is_standard_io_device_for_access(
1383 Path::new("/dev/stdin"),
1384 FsAccess::Read
1385 ));
1386 assert!(!is_standard_io_device_for_access(
1387 Path::new("/dev/stdin"),
1388 FsAccess::Write
1389 ));
1390 assert!(is_standard_io_device_for_access(
1391 Path::new("/dev/stdout"),
1392 FsAccess::Write
1393 ));
1394 assert!(is_standard_io_device_for_access(
1395 Path::new("/dev/stderr"),
1396 FsAccess::Write
1397 ));
1398 assert!(is_standard_io_device_for_access(
1399 Path::new("/dev/null"),
1400 FsAccess::Write
1401 ));
1402 assert!(is_standard_io_device_for_access(
1403 Path::new("/dev/fd/0"),
1404 FsAccess::Read
1405 ));
1406 assert!(is_standard_io_device_for_access(
1407 Path::new("/dev/fd/12"),
1408 FsAccess::Write
1409 ));
1410 assert!(!is_standard_io_device_for_access(
1411 Path::new("/dev/null"),
1412 FsAccess::Delete
1413 ));
1414 assert!(!is_standard_io_device_for_access(
1415 Path::new("/dev/fd/"),
1416 FsAccess::Write
1417 ));
1418 assert!(!is_standard_io_device_for_access(
1419 Path::new("/dev/fd/1a"),
1420 FsAccess::Write
1421 ));
1422 assert!(!is_standard_io_device_for_access(
1423 Path::new("/dev/stdoutx"),
1424 FsAccess::Write
1425 ));
1426 assert!(!is_standard_io_device_for_access(
1427 Path::new("/dev/random"),
1428 FsAccess::Read
1429 ));
1430 assert!(!is_standard_io_device_for_access(
1431 Path::new("/tmp/dev/null"),
1432 FsAccess::Write
1433 ));
1434 }
1435
1436 #[test]
1437 fn path_within_root_accepts_root_and_children() {
1438 let root = Path::new("/tmp/harn-root");
1439 assert!(path_is_within(root, root));
1440 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1441 assert!(!path_is_within(
1442 Path::new("/tmp/harn-root-other/file"),
1443 root
1444 ));
1445 }
1446
1447 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1448 #[test]
1449 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1450 let temp_home = tempfile::tempdir().expect("temp home");
1451 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1452 let normalized_home = normalize_for_policy(temp_home.path());
1453
1454 for suffix in [
1455 Path::new(".cargo"),
1456 Path::new(".rustup"),
1457 Path::new(".pyenv"),
1458 Path::new(".nvm"),
1459 Path::new(".volta"),
1460 Path::new(".local/share/uv"),
1461 Path::new("go"),
1462 ] {
1463 assert!(
1464 roots.iter().any(|path| path.ends_with(suffix)),
1465 "expected a developer-toolchain grant for {}",
1466 suffix.display()
1467 );
1468 }
1469 assert!(
1470 roots.iter().all(|path| path.starts_with(&normalized_home)),
1471 "developer-toolchain roots must stay under HOME"
1472 );
1473 }
1474
1475 #[test]
1476 fn os_hardened_profile_overrides_fallback_env() {
1477 assert_eq!(
1482 effective_fallback(SandboxProfile::OsHardened),
1483 SandboxFallback::Enforce
1484 );
1485 }
1486
1487 #[test]
1488 fn unrestricted_profile_skips_active_sandbox() {
1489 let policy = CapabilityPolicy {
1490 sandbox_profile: SandboxProfile::Unrestricted,
1491 workspace_roots: vec!["/tmp".to_string()],
1492 ..Default::default()
1493 };
1494 crate::orchestration::push_execution_policy(policy);
1495 let result = active_sandbox_policy();
1496 crate::orchestration::pop_execution_policy();
1497 assert!(
1498 result.is_none(),
1499 "Unrestricted profile must short-circuit sandbox dispatch"
1500 );
1501 }
1502
1503 #[test]
1504 fn worktree_profile_engages_active_sandbox() {
1505 let policy = CapabilityPolicy {
1506 sandbox_profile: SandboxProfile::Worktree,
1507 workspace_roots: vec!["/tmp".to_string()],
1508 ..Default::default()
1509 };
1510 crate::orchestration::push_execution_policy(policy);
1511 let result = active_sandbox_policy();
1512 crate::orchestration::pop_execution_policy();
1513 assert!(
1514 result.is_some(),
1515 "Worktree profile must keep sandbox dispatch active"
1516 );
1517 }
1518}