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 Ok(resolved)
498}
499
500fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
501 let roots = normalized_workspace_roots(policy);
502 let current = std::env::current_dir().map_err(|error| {
503 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
504 format!("process cwd resolution failed: {error}"),
505 )))
506 })?;
507 let current = normalize_for_policy(¤t);
508 if roots.iter().any(|root| path_is_within(¤t, root)) {
509 return Ok(current);
510 }
511 roots.first().cloned().ok_or_else(|| {
512 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
513 "process cwd resolution failed: no workspace root available",
514 )))
515 })
516}
517
518fn build_std_command<B: SandboxBackend + ?Sized>(
519 program: &str,
520 args: &[String],
521 policy: &CapabilityPolicy,
522 profile: SandboxProfile,
523) -> Result<Command, VmError> {
524 let mut command = Command::new(program);
525 command.args(args);
526 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
527 PrepareOutcome::Direct => Ok(command),
528 PrepareOutcome::WrappedExec { wrapper, args } => {
529 let mut wrapped = Command::new(wrapper);
530 wrapped.args(args);
531 Ok(wrapped)
532 }
533 }
534}
535
536fn build_tokio_command<B: SandboxBackend + ?Sized>(
537 program: &str,
538 args: &[String],
539 policy: &CapabilityPolicy,
540 profile: SandboxProfile,
541) -> Result<tokio::process::Command, VmError> {
542 let mut command = tokio::process::Command::new(program);
543 command.args(args);
544 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
545 PrepareOutcome::Direct => Ok(command),
546 PrepareOutcome::WrappedExec { wrapper, args } => {
547 let mut wrapped = tokio::process::Command::new(wrapper);
548 wrapped.args(args);
549 Ok(wrapped)
550 }
551 }
552}
553
554pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
555 let policy = crate::orchestration::current_execution_policy()?;
556 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
557 return None;
558 }
559 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
560 || !ActiveBackend::available()
561 {
562 return None;
563 }
564 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
565 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
566 if !output.status.success()
567 && (stderr.contains("operation not permitted")
568 || stderr.contains("permission denied")
569 || stderr.contains("access is denied")
570 || stdout.contains("operation not permitted"))
571 {
572 return Some(sandbox_rejection(sandbox_process_violation_message(
573 format!(
574 "sandbox violation: process was denied by the OS sandbox (status {})",
575 output.status.code().unwrap_or(-1)
576 ),
577 )));
578 }
579 if sandbox_signal_status(output) {
580 return Some(sandbox_rejection(sandbox_process_violation_message(
581 format!(
582 "sandbox violation: process was terminated by the OS sandbox (status {})",
583 output.status
584 ),
585 )));
586 }
587 None
588}
589
590pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
591 let policy = crate::orchestration::current_execution_policy()?;
592 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
593 return None;
594 }
595 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
596 || !ActiveBackend::available()
597 {
598 return None;
599 }
600 let message = error.to_string().to_ascii_lowercase();
601 if error.kind() == std::io::ErrorKind::PermissionDenied
602 || message.contains("operation not permitted")
603 || message.contains("permission denied")
604 || message.contains("access is denied")
605 {
606 return Some(sandbox_rejection(sandbox_process_violation_message(
607 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
608 )));
609 }
610 None
611}
612
613#[cfg(unix)]
614fn sandbox_signal_status(output: &std::process::Output) -> bool {
615 use std::os::unix::process::ExitStatusExt;
616
617 matches!(
618 output.status.signal(),
619 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
620 )
621}
622
623#[cfg(not(unix))]
624fn sandbox_signal_status(_output: &std::process::Output) -> bool {
625 false
626}
627
628pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
636 let policy = crate::orchestration::current_execution_policy()?;
637 let profile = policy.sandbox_profile;
638 match profile {
639 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
640 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
641 if effective_fallback(profile) == SandboxFallback::Off {
642 None
643 } else {
644 Some((policy, profile))
645 }
646 }
647 }
648}
649
650fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
651 if let Some(cwd) = config.cwd.as_ref() {
652 command.current_dir(cwd);
653 }
654 command.envs(config.env.iter().map(|(key, value)| (key, value)));
655 if config.stdin_null {
656 command.stdin(Stdio::null());
657 }
658}
659
660fn spawn_error(error: std::io::Error) -> VmError {
661 VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
662 format!("process spawn failed: {error}"),
663 )))
664}
665
666pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
671 if matches!(profile, SandboxProfile::OsHardened) {
672 return SandboxFallback::Enforce;
673 }
674 match std::env::var(HANDLER_SANDBOX_ENV)
675 .unwrap_or_else(|_| "warn".to_string())
676 .trim()
677 .to_ascii_lowercase()
678 .as_str()
679 {
680 "0" | "false" | "off" | "none" => SandboxFallback::Off,
681 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
682 _ => SandboxFallback::Warn,
683 }
684}
685
686pub(crate) fn warn_once(key: &str, message: &str) {
687 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
688 if inserted {
689 crate::events::log_warn("handler_sandbox", message);
690 }
691}
692
693pub(crate) fn sandbox_rejection(message: String) -> VmError {
694 VmError::CategorizedError {
695 message,
696 category: ErrorCategory::ToolRejected,
697 }
698}
699
700fn sandbox_process_violation_message(summary: String) -> String {
701 format!(
702 "{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"
703 )
704}
705
706#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
716pub(crate) fn unavailable(
717 message: &str,
718 profile: SandboxProfile,
719) -> Result<PrepareOutcome, VmError> {
720 match effective_fallback(profile) {
721 SandboxFallback::Off | SandboxFallback::Warn => {
722 warn_once("handler_sandbox_unavailable", message);
723 Ok(PrepareOutcome::Direct)
724 }
725 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
726 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
727 ))),
728 }
729}
730
731fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
732 if policy.workspace_roots.is_empty() {
733 return vec![normalize_for_policy(
734 &crate::stdlib::process::execution_root_path(),
735 )];
736 }
737 policy
738 .workspace_roots
739 .iter()
740 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
741 .collect()
742}
743
744pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
745 normalized_workspace_roots(policy)
746}
747
748fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
753 policy
754 .read_only_roots
755 .iter()
756 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
757 .collect()
758}
759
760#[cfg(any(
761 target_os = "linux",
762 target_os = "macos",
763 target_os = "openbsd",
764 target_os = "windows"
765))]
766pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
767 normalized_read_only_roots(policy)
768}
769
770#[cfg(any(
771 target_os = "linux",
772 target_os = "macos",
773 target_os = "openbsd",
774 target_os = "windows"
775))]
776pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
777 normalized_process_roots(&policy.process_sandbox.read_roots)
778}
779
780#[cfg(any(
781 target_os = "linux",
782 target_os = "macos",
783 target_os = "openbsd",
784 target_os = "windows"
785))]
786pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
787 normalized_process_roots(&policy.process_sandbox.write_roots)
788}
789
790#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
791pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
792 policy.process_sandbox.effective_presets()
793}
794
795#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
796pub(crate) fn process_sandbox_developer_toolchain_read_roots(
797 policy: &CapabilityPolicy,
798) -> Vec<PathBuf> {
799 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
800 return Vec::new();
801 }
802 let Some(home) = sandbox_user_home_dir() else {
803 return Vec::new();
804 };
805 developer_toolchain_read_roots_for_home(&home)
806}
807
808#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
809pub(crate) fn process_sandbox_package_manager_config_read_roots(
810 policy: &CapabilityPolicy,
811) -> Vec<PathBuf> {
812 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
813 return Vec::new();
814 }
815 let Some(home) = sandbox_user_home_dir() else {
816 return Vec::new();
817 };
818 package_manager_config_read_roots_for_home(&home)
819}
820
821#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
822fn sandbox_user_home_dir() -> Option<PathBuf> {
823 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
826}
827
828#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
829pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
830 let mut roots: Vec<_> = [
831 ".asdf",
832 ".bun",
833 ".cargo",
834 ".fnm",
835 ".juliaup",
836 ".local/bin",
837 ".local/share/mise",
838 ".local/share/uv",
839 ".nvm",
840 ".pyenv",
841 ".rbenv",
842 ".rustup",
843 ".sdkman",
844 ".swiftly",
845 ".volta",
846 "go",
847 ]
848 .into_iter()
849 .map(|entry| normalize_for_policy(&home.join(entry)))
850 .collect();
851 #[cfg(target_os = "windows")]
852 roots.extend(
853 [
854 "AppData/Local/Programs/Python",
855 "AppData/Local/uv",
856 "AppData/Roaming/uv",
857 "scoop",
858 ]
859 .into_iter()
860 .map(|entry| normalize_for_policy(&home.join(entry))),
861 );
862 roots.sort_unstable();
863 roots.dedup();
864 roots
865}
866
867#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
868pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
869 let mut roots: Vec<_> = [
870 ".npmrc",
871 ".gitconfig",
872 ".netrc",
873 ".yarnrc.yml",
874 ".config",
875 ".npm",
876 ".cache",
877 ".pip",
878 ".pypirc",
879 ".cargo/config",
880 ".cargo/config.toml",
881 ".cargo/credentials",
882 ".cargo/credentials.toml",
883 ".cargo/registry",
884 ".cargo/git",
885 ]
886 .into_iter()
887 .map(|entry| normalize_for_policy(&home.join(entry)))
888 .collect();
889 roots.sort_unstable();
890 roots.dedup();
891 roots
892}
893
894#[cfg(any(
895 target_os = "linux",
896 target_os = "macos",
897 target_os = "openbsd",
898 target_os = "windows"
899))]
900fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
901 roots
902 .iter()
903 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
904 .collect()
905}
906
907fn resolve_policy_path(path: &str) -> PathBuf {
908 let candidate = PathBuf::from(path);
909 if candidate.is_absolute() {
910 candidate
911 } else {
912 crate::stdlib::process::execution_root_path().join(candidate)
913 }
914}
915
916fn normalize_for_policy(path: &Path) -> PathBuf {
917 let absolute = if path.is_absolute() {
918 path.to_path_buf()
919 } else {
920 crate::stdlib::process::execution_root_path().join(path)
921 };
922 let absolute = normalize_lexically(&absolute);
923 if let Ok(canonical) = absolute.canonicalize() {
924 return canonical;
925 }
926
927 let mut existing = absolute.as_path();
928 let mut suffix = Vec::new();
929 while !existing.exists() {
930 let Some(parent) = existing.parent() else {
931 return normalize_lexically(&absolute);
932 };
933 if let Some(name) = existing.file_name() {
934 suffix.push(name.to_os_string());
935 }
936 existing = parent;
937 }
938
939 let mut normalized = existing
940 .canonicalize()
941 .unwrap_or_else(|_| normalize_lexically(existing));
942 for component in suffix.iter().rev() {
943 normalized.push(component);
944 }
945 normalize_lexically(&normalized)
946}
947
948fn normalize_lexically(path: &Path) -> PathBuf {
949 let mut normalized = PathBuf::new();
950 for component in path.components() {
951 match component {
952 Component::CurDir => {}
953 Component::ParentDir => {
954 normalized.pop();
955 }
956 other => normalized.push(other.as_os_str()),
957 }
958 }
959 normalized
960}
961
962fn path_is_within(path: &Path, root: &Path) -> bool {
963 path == root || path.starts_with(root)
964}
965
966fn normalize_io_device_path(path: &Path) -> PathBuf {
971 let absolute = if path.is_absolute() {
972 path.to_path_buf()
973 } else {
974 crate::stdlib::process::execution_root_path().join(path)
975 };
976 normalize_lexically(&absolute)
977}
978
979fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
984 match access {
985 FsAccess::Read => {
986 matches!(
987 path.to_str(),
988 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
989 ) || is_dev_fd_descriptor(path)
990 }
991 FsAccess::Write => {
992 matches!(
993 path.to_str(),
994 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
995 ) || is_dev_fd_descriptor(path)
996 }
997 FsAccess::Delete => false,
998 }
999}
1000
1001fn is_dev_fd_descriptor(path: &Path) -> bool {
1004 let Some(text) = path.to_str() else {
1005 return false;
1006 };
1007 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1008 return false;
1009 };
1010 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1011}
1012
1013#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1014pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1015 fn rank(value: &str) -> usize {
1016 match value {
1017 "none" => 0,
1018 "read_only" => 1,
1019 "workspace_write" => 2,
1020 "process_exec" => 3,
1021 "network" => 4,
1022 _ => 5,
1023 }
1024 }
1025 policy
1026 .side_effect_level
1027 .as_ref()
1028 .map(|level| rank(level) >= rank("network"))
1029 .unwrap_or(true)
1030}
1031
1032#[cfg(any(
1033 target_os = "linux",
1034 target_os = "macos",
1035 target_os = "openbsd",
1036 target_os = "windows"
1037))]
1038pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1039 policy.capabilities.is_empty()
1040 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1041}
1042
1043#[cfg(any(
1044 target_os = "linux",
1045 target_os = "macos",
1046 target_os = "openbsd",
1047 target_os = "windows"
1048))]
1049pub(crate) fn policy_allows_capability(
1050 policy: &CapabilityPolicy,
1051 capability: &str,
1052 ops: &[&str],
1053) -> bool {
1054 policy
1055 .capabilities
1056 .get(capability)
1057 .map(|allowed| {
1058 ops.iter()
1059 .any(|op| allowed.iter().any(|candidate| candidate == op))
1060 })
1061 .unwrap_or(false)
1062}
1063
1064impl FsAccess {
1065 fn verb(self) -> &'static str {
1066 match self {
1067 FsAccess::Read => "read",
1068 FsAccess::Write => "write",
1069 FsAccess::Delete => "delete",
1070 }
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1078
1079 #[test]
1080 fn missing_create_path_normalizes_against_existing_parent() {
1081 let dir = tempfile::tempdir().unwrap();
1082 let nested = dir.path().join("a/../new.txt");
1083 let normalized = normalize_for_policy(&nested);
1084 assert_eq!(
1085 normalized,
1086 normalize_for_policy(&dir.path().join("new.txt"))
1087 );
1088 }
1089
1090 #[test]
1091 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1092 let dir = tempfile::tempdir().unwrap();
1093 crate::stdlib::process::set_thread_execution_context(Some(
1094 crate::orchestration::RunExecutionRecord {
1095 cwd: Some(dir.path().to_string_lossy().into_owned()),
1096 source_dir: None,
1097 env: Default::default(),
1098 adapter: None,
1099 repo_path: None,
1100 worktree_path: None,
1101 branch: None,
1102 base_ref: None,
1103 cleanup: None,
1104 },
1105 ));
1106 push_execution_policy(CapabilityPolicy {
1107 sandbox_profile: SandboxProfile::Worktree,
1108 ..CapabilityPolicy::default()
1109 });
1110
1111 assert!(
1112 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1113 );
1114 let outside = tempfile::tempdir().unwrap();
1115 assert!(enforce_fs_path(
1116 "read_file",
1117 &outside.path().join("outside.txt"),
1118 FsAccess::Read
1119 )
1120 .is_err());
1121
1122 pop_execution_policy();
1123 crate::stdlib::process::set_thread_execution_context(None);
1124 }
1125
1126 #[test]
1127 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1128 let dir = tempfile::tempdir().unwrap();
1129 crate::stdlib::process::set_thread_execution_context(Some(
1130 crate::orchestration::RunExecutionRecord {
1131 cwd: Some(dir.path().to_string_lossy().into_owned()),
1132 source_dir: None,
1133 env: Default::default(),
1134 adapter: None,
1135 repo_path: None,
1136 worktree_path: None,
1137 branch: None,
1138 base_ref: None,
1139 cleanup: None,
1140 },
1141 ));
1142 push_execution_policy(CapabilityPolicy {
1143 sandbox_profile: SandboxProfile::Worktree,
1144 ..CapabilityPolicy::default()
1145 });
1146
1147 assert!(enforce_process_cwd(dir.path()).is_ok());
1148 let outside = tempfile::tempdir().unwrap();
1149 assert!(enforce_process_cwd(outside.path()).is_err());
1150
1151 pop_execution_policy();
1152 crate::stdlib::process::set_thread_execution_context(None);
1153 }
1154
1155 #[test]
1156 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1157 let cwd = std::env::current_dir().unwrap();
1158 let policy = CapabilityPolicy {
1159 sandbox_profile: SandboxProfile::Worktree,
1160 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1161 ..CapabilityPolicy::default()
1162 };
1163
1164 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1165
1166 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1167 }
1168
1169 #[test]
1170 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1171 let workspace = tempfile::tempdir().unwrap();
1172 let policy = CapabilityPolicy {
1173 sandbox_profile: SandboxProfile::Worktree,
1174 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1175 ..CapabilityPolicy::default()
1176 };
1177
1178 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1179
1180 assert_eq!(
1181 resolved.cwd.unwrap(),
1182 normalize_for_policy(workspace.path())
1183 );
1184 }
1185
1186 #[test]
1187 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1188 let workspace = tempfile::tempdir().unwrap();
1189 let outside = tempfile::tempdir().unwrap();
1190 let policy = CapabilityPolicy {
1191 sandbox_profile: SandboxProfile::Worktree,
1192 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1193 ..CapabilityPolicy::default()
1194 };
1195 let config = ProcessCommandConfig {
1196 cwd: Some(outside.path().to_path_buf()),
1197 ..ProcessCommandConfig::default()
1198 };
1199
1200 assert!(sandboxed_process_config(&config, &policy).is_err());
1201 }
1202
1203 #[test]
1204 fn read_only_root_outside_workspace_allows_read_denies_write() {
1205 let workspace = tempfile::tempdir().unwrap();
1210 let read_only = tempfile::tempdir().unwrap();
1211 push_execution_policy(CapabilityPolicy {
1212 sandbox_profile: SandboxProfile::Worktree,
1213 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1214 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1215 ..CapabilityPolicy::default()
1216 });
1217
1218 let asset = read_only
1219 .path()
1220 .join("partials/agent-web-tools.harn.prompt");
1221 assert!(
1223 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1224 "read under a configured read-only root must be allowed"
1225 );
1226
1227 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1229 .expect_err("write under a read-only root must be denied");
1230 assert!(write_err.read_only, "write rejection must set read_only");
1231
1232 assert!(
1234 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1235 "delete under a read-only root must be denied"
1236 );
1237
1238 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1240
1241 let stranger = tempfile::tempdir().unwrap();
1244 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1245 .expect_err("read outside all roots must be denied");
1246 assert!(
1247 !outside_err.read_only,
1248 "out-of-scope rejection must not be flagged read_only"
1249 );
1250
1251 pop_execution_policy();
1252 }
1253
1254 #[cfg(unix)]
1255 #[test]
1256 fn standard_io_device_files_allowed_under_restricted_profile() {
1257 let workspace = tempfile::tempdir().unwrap();
1262 push_execution_policy(CapabilityPolicy {
1263 sandbox_profile: SandboxProfile::Worktree,
1264 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1265 ..CapabilityPolicy::default()
1266 });
1267
1268 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1269 assert!(
1270 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1271 "write to standard device {device} must be allowed"
1272 );
1273 assert!(
1275 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1276 "read of standard device {device} must be allowed"
1277 );
1278 }
1279 assert!(
1280 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1281 "read of standard device /dev/stdin must be allowed"
1282 );
1283 assert!(
1284 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1285 "write to /dev/stdin is not a standard output stream"
1286 );
1287 assert!(
1288 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1289 "standard devices must not bypass delete scoping"
1290 );
1291 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1293 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1294
1295 let stranger = tempfile::tempdir().unwrap();
1297 assert!(
1298 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1299 "a real out-of-root write must still be rejected"
1300 );
1301 assert!(
1303 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1304 "/dev/sda must not be allowed by the standard-device allowlist"
1305 );
1306 assert!(
1307 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1308 "non-numeric /dev/fd/<x> must not be allowed"
1309 );
1310
1311 pop_execution_policy();
1312 }
1313
1314 #[test]
1315 fn is_standard_io_device_matches_only_known_streams() {
1316 assert!(is_standard_io_device_for_access(
1317 Path::new("/dev/stdin"),
1318 FsAccess::Read
1319 ));
1320 assert!(!is_standard_io_device_for_access(
1321 Path::new("/dev/stdin"),
1322 FsAccess::Write
1323 ));
1324 assert!(is_standard_io_device_for_access(
1325 Path::new("/dev/stdout"),
1326 FsAccess::Write
1327 ));
1328 assert!(is_standard_io_device_for_access(
1329 Path::new("/dev/stderr"),
1330 FsAccess::Write
1331 ));
1332 assert!(is_standard_io_device_for_access(
1333 Path::new("/dev/null"),
1334 FsAccess::Write
1335 ));
1336 assert!(is_standard_io_device_for_access(
1337 Path::new("/dev/fd/0"),
1338 FsAccess::Read
1339 ));
1340 assert!(is_standard_io_device_for_access(
1341 Path::new("/dev/fd/12"),
1342 FsAccess::Write
1343 ));
1344 assert!(!is_standard_io_device_for_access(
1345 Path::new("/dev/null"),
1346 FsAccess::Delete
1347 ));
1348 assert!(!is_standard_io_device_for_access(
1349 Path::new("/dev/fd/"),
1350 FsAccess::Write
1351 ));
1352 assert!(!is_standard_io_device_for_access(
1353 Path::new("/dev/fd/1a"),
1354 FsAccess::Write
1355 ));
1356 assert!(!is_standard_io_device_for_access(
1357 Path::new("/dev/stdoutx"),
1358 FsAccess::Write
1359 ));
1360 assert!(!is_standard_io_device_for_access(
1361 Path::new("/dev/random"),
1362 FsAccess::Read
1363 ));
1364 assert!(!is_standard_io_device_for_access(
1365 Path::new("/tmp/dev/null"),
1366 FsAccess::Write
1367 ));
1368 }
1369
1370 #[test]
1371 fn path_within_root_accepts_root_and_children() {
1372 let root = Path::new("/tmp/harn-root");
1373 assert!(path_is_within(root, root));
1374 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1375 assert!(!path_is_within(
1376 Path::new("/tmp/harn-root-other/file"),
1377 root
1378 ));
1379 }
1380
1381 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1382 #[test]
1383 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1384 let temp_home = tempfile::tempdir().expect("temp home");
1385 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1386 let normalized_home = normalize_for_policy(temp_home.path());
1387
1388 for suffix in [
1389 Path::new(".cargo"),
1390 Path::new(".rustup"),
1391 Path::new(".pyenv"),
1392 Path::new(".nvm"),
1393 Path::new(".volta"),
1394 Path::new(".local/share/uv"),
1395 Path::new("go"),
1396 ] {
1397 assert!(
1398 roots.iter().any(|path| path.ends_with(suffix)),
1399 "expected a developer-toolchain grant for {}",
1400 suffix.display()
1401 );
1402 }
1403 assert!(
1404 roots.iter().all(|path| path.starts_with(&normalized_home)),
1405 "developer-toolchain roots must stay under HOME"
1406 );
1407 }
1408
1409 #[test]
1410 fn os_hardened_profile_overrides_fallback_env() {
1411 assert_eq!(
1416 effective_fallback(SandboxProfile::OsHardened),
1417 SandboxFallback::Enforce
1418 );
1419 }
1420
1421 #[test]
1422 fn unrestricted_profile_skips_active_sandbox() {
1423 let policy = CapabilityPolicy {
1424 sandbox_profile: SandboxProfile::Unrestricted,
1425 workspace_roots: vec!["/tmp".to_string()],
1426 ..Default::default()
1427 };
1428 crate::orchestration::push_execution_policy(policy);
1429 let result = active_sandbox_policy();
1430 crate::orchestration::pop_execution_policy();
1431 assert!(
1432 result.is_none(),
1433 "Unrestricted profile must short-circuit sandbox dispatch"
1434 );
1435 }
1436
1437 #[test]
1438 fn worktree_profile_engages_active_sandbox() {
1439 let policy = CapabilityPolicy {
1440 sandbox_profile: SandboxProfile::Worktree,
1441 workspace_roots: vec!["/tmp".to_string()],
1442 ..Default::default()
1443 };
1444 crate::orchestration::push_execution_policy(policy);
1445 let result = active_sandbox_policy();
1446 crate::orchestration::pop_execution_policy();
1447 assert!(
1448 result.is_some(),
1449 "Worktree profile must keep sandbox dispatch active"
1450 );
1451 }
1452}