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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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 inject_workspace_tmpdir(&mut resolved.env, policy);
499 Ok(resolved)
500}
501
502fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
516 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
517 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
518 entry.1.clear();
519 } else {
520 env.push((key.to_string(), String::new()));
521 }
522 }
523}
524
525pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
531
532pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
536
537pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
554 let root = normalized_workspace_roots(policy).into_iter().next()?;
555 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
556 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
557 warn_once(
558 "handler_sandbox_workspace_tmpdir",
559 &format!(
560 "could not create workspace-local temp dir '{}': {error}; \
561 leaving the child's inherited temp dir in place",
562 tmpdir.display()
563 ),
564 );
565 return None;
566 }
567 let ignore = tmpdir.join(".gitignore");
573 if !ignore.exists() {
574 let _ = std::fs::write(
575 &ignore,
576 "# Created by the Harn sandbox; safe to delete.\n*\n",
577 );
578 }
579 Some(tmpdir)
580}
581
582pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
592 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
593 return;
594 }
595 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
596 return;
597 };
598 let tmpdir = tmpdir.display().to_string();
599 for key in TMPDIR_ENV_KEYS {
600 if env.iter().any(|(existing, _)| existing == key) {
601 continue;
603 }
604 env.push((key.to_string(), tmpdir.clone()));
605 }
606}
607
608pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
623 let Some(policy) = crate::orchestration::current_execution_policy() else {
624 return Vec::new();
625 };
626 let mut env = Vec::new();
627 inject_workspace_tmpdir(&mut env, &policy);
628 env
629}
630
631pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
656 vec![
657 ("LC_MESSAGES".to_string(), "C".to_string()),
658 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
659 ]
660}
661
662pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
667
668fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
669 let roots = normalized_workspace_roots(policy);
670 let current = std::env::current_dir().map_err(|error| {
671 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
672 format!("process cwd resolution failed: {error}"),
673 )))
674 })?;
675 let current = normalize_for_policy(¤t);
676 if roots.iter().any(|root| path_is_within(¤t, root)) {
677 return Ok(current);
678 }
679 roots.first().cloned().ok_or_else(|| {
680 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
681 "process cwd resolution failed: no workspace root available",
682 )))
683 })
684}
685
686fn build_std_command<B: SandboxBackend + ?Sized>(
687 program: &str,
688 args: &[String],
689 policy: &CapabilityPolicy,
690 profile: SandboxProfile,
691) -> Result<Command, VmError> {
692 let mut command = Command::new(program);
693 command.args(args);
694 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
695 PrepareOutcome::Direct => Ok(command),
696 PrepareOutcome::WrappedExec { wrapper, args } => {
697 let mut wrapped = Command::new(wrapper);
698 wrapped.args(args);
699 Ok(wrapped)
700 }
701 }
702}
703
704fn build_tokio_command<B: SandboxBackend + ?Sized>(
705 program: &str,
706 args: &[String],
707 policy: &CapabilityPolicy,
708 profile: SandboxProfile,
709) -> Result<tokio::process::Command, VmError> {
710 let mut command = tokio::process::Command::new(program);
711 command.args(args);
712 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
713 PrepareOutcome::Direct => Ok(command),
714 PrepareOutcome::WrappedExec { wrapper, args } => {
715 let mut wrapped = tokio::process::Command::new(wrapper);
716 wrapped.args(args);
717 Ok(wrapped)
718 }
719 }
720}
721
722pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
723 let policy = crate::orchestration::current_execution_policy()?;
724 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
725 return None;
726 }
727 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
728 || !ActiveBackend::available()
729 {
730 return None;
731 }
732 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
733 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
734 if !output.status.success()
735 && (stderr.contains("operation not permitted")
736 || stderr.contains("permission denied")
737 || stderr.contains("access is denied")
738 || stdout.contains("operation not permitted"))
739 {
740 return Some(sandbox_rejection(sandbox_process_violation_message(
741 format!(
742 "sandbox violation: process was denied by the OS sandbox (status {})",
743 output.status.code().unwrap_or(-1)
744 ),
745 )));
746 }
747 if sandbox_signal_status(output) {
748 return Some(sandbox_rejection(sandbox_process_violation_message(
749 format!(
750 "sandbox violation: process was terminated by the OS sandbox (status {})",
751 output.status
752 ),
753 )));
754 }
755 None
756}
757
758pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
759 let policy = crate::orchestration::current_execution_policy()?;
760 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
761 return None;
762 }
763 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
764 || !ActiveBackend::available()
765 {
766 return None;
767 }
768 let message = error.to_string().to_ascii_lowercase();
769 if error.kind() == std::io::ErrorKind::PermissionDenied
770 || message.contains("operation not permitted")
771 || message.contains("permission denied")
772 || message.contains("access is denied")
773 {
774 return Some(sandbox_rejection(sandbox_process_violation_message(
775 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
776 )));
777 }
778 None
779}
780
781#[cfg(unix)]
782fn sandbox_signal_status(output: &std::process::Output) -> bool {
783 use std::os::unix::process::ExitStatusExt;
784
785 matches!(
786 output.status.signal(),
787 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
788 )
789}
790
791#[cfg(not(unix))]
792fn sandbox_signal_status(_output: &std::process::Output) -> bool {
793 false
794}
795
796pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
804 let policy = crate::orchestration::current_execution_policy()?;
805 let profile = policy.sandbox_profile;
806 match profile {
807 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
808 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
809 if effective_fallback(profile) == SandboxFallback::Off {
810 None
811 } else {
812 Some((policy, profile))
813 }
814 }
815 }
816}
817
818fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
819 if let Some(cwd) = config.cwd.as_ref() {
820 command.current_dir(cwd);
821 }
822 command.envs(config.env.iter().map(|(key, value)| (key, value)));
823 if config.stdin_null {
824 command.stdin(Stdio::null());
825 }
826}
827
828fn spawn_error(error: std::io::Error) -> VmError {
829 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
830 format!("process spawn failed: {error}"),
831 )))
832}
833
834pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
839 if matches!(profile, SandboxProfile::OsHardened) {
840 return SandboxFallback::Enforce;
841 }
842 match std::env::var(HANDLER_SANDBOX_ENV)
843 .unwrap_or_else(|_| "warn".to_string())
844 .trim()
845 .to_ascii_lowercase()
846 .as_str()
847 {
848 "0" | "false" | "off" | "none" => SandboxFallback::Off,
849 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
850 _ => SandboxFallback::Warn,
851 }
852}
853
854pub(crate) fn warn_once(key: &str, message: &str) {
855 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
856 if inserted {
857 crate::events::log_warn("handler_sandbox", message);
858 }
859}
860
861pub(crate) fn sandbox_rejection(message: String) -> VmError {
862 VmError::CategorizedError {
863 message,
864 category: ErrorCategory::ToolRejected,
865 }
866}
867
868fn sandbox_process_violation_message(summary: String) -> String {
869 format!(
870 "{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"
871 )
872}
873
874#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
884pub(crate) fn unavailable(
885 message: &str,
886 profile: SandboxProfile,
887) -> Result<PrepareOutcome, VmError> {
888 match effective_fallback(profile) {
889 SandboxFallback::Off | SandboxFallback::Warn => {
890 warn_once("handler_sandbox_unavailable", message);
891 Ok(PrepareOutcome::Direct)
892 }
893 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
894 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
895 ))),
896 }
897}
898
899fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
907 let session_id = crate::agent_sessions::current_session_id()?;
908 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
909 let mut roots = vec![anchor.primary.clone()];
910 for mounted in &anchor.additional_roots {
911 if matches!(
912 mounted.mount_mode,
913 crate::workspace_anchor::MountMode::Extend
914 ) {
915 roots.push(mounted.path.clone());
916 }
917 }
918 Some(roots)
919}
920
921fn project_root_env_workspace_root() -> Option<PathBuf> {
929 std::env::var("HARN_PROJECT_ROOT")
930 .ok()
931 .map(|value| value.trim().to_string())
932 .filter(|value| !value.is_empty())
933 .map(PathBuf::from)
934}
935
936fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
937 if policy.workspace_roots.is_empty() {
938 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
953 return anchor_roots
954 .iter()
955 .map(|root| normalize_for_policy(root))
956 .collect();
957 }
958 if let Some(project_root) = project_root_env_workspace_root() {
959 return vec![normalize_for_policy(&project_root)];
960 }
961 return vec![normalize_for_policy(
962 &crate::stdlib::process::execution_root_path(),
963 )];
964 }
965 policy
966 .workspace_roots
967 .iter()
968 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
969 .collect()
970}
971
972pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
973 normalized_workspace_roots(policy)
974}
975
976fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
981 policy
982 .read_only_roots
983 .iter()
984 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
985 .collect()
986}
987
988#[cfg(any(
989 target_os = "linux",
990 target_os = "macos",
991 target_os = "openbsd",
992 target_os = "windows"
993))]
994pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
995 normalized_read_only_roots(policy)
996}
997
998#[cfg(any(
999 target_os = "linux",
1000 target_os = "macos",
1001 target_os = "openbsd",
1002 target_os = "windows"
1003))]
1004pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1005 normalized_process_roots(&policy.process_sandbox.read_roots)
1006}
1007
1008#[cfg(any(
1009 target_os = "linux",
1010 target_os = "macos",
1011 target_os = "openbsd",
1012 target_os = "windows"
1013))]
1014pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1015 normalized_process_roots(&policy.process_sandbox.write_roots)
1016}
1017
1018#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1019pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1020 policy.process_sandbox.effective_presets()
1021}
1022
1023#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1024pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1025 policy: &CapabilityPolicy,
1026) -> Vec<PathBuf> {
1027 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1028 return Vec::new();
1029 }
1030 let Some(home) = sandbox_user_home_dir() else {
1031 return Vec::new();
1032 };
1033 developer_toolchain_read_roots_for_home(&home)
1034}
1035
1036#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1037pub(crate) fn process_sandbox_package_manager_config_read_roots(
1038 policy: &CapabilityPolicy,
1039) -> Vec<PathBuf> {
1040 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1041 return Vec::new();
1042 }
1043 let Some(home) = sandbox_user_home_dir() else {
1044 return Vec::new();
1045 };
1046 package_manager_config_read_roots_for_home(&home)
1047}
1048
1049#[cfg(any(target_os = "linux", target_os = "macos"))]
1064pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1065 policy: &CapabilityPolicy,
1066) -> Vec<PathBuf> {
1067 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1068 return Vec::new();
1069 }
1070 let Some(home) = sandbox_user_home_dir() else {
1071 return Vec::new();
1072 };
1073 developer_toolchain_cache_write_roots_for_home(&home)
1074}
1075
1076#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1077fn sandbox_user_home_dir() -> Option<PathBuf> {
1078 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1081}
1082
1083#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1084pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1085 let mut roots: Vec<_> = [
1086 ".asdf",
1087 ".bun",
1088 ".cargo",
1089 ".fnm",
1090 ".juliaup",
1091 ".local/bin",
1092 ".local/share/mise",
1093 ".local/share/uv",
1094 ".nvm",
1095 ".pyenv",
1096 ".rbenv",
1097 ".rustup",
1098 ".sdkman",
1099 ".swiftly",
1100 ".volta",
1101 "go",
1102 ]
1103 .into_iter()
1104 .map(|entry| normalize_for_policy(&home.join(entry)))
1105 .collect();
1106 #[cfg(target_os = "windows")]
1107 roots.extend(
1108 [
1109 "AppData/Local/Programs/Python",
1110 "AppData/Local/uv",
1111 "AppData/Roaming/uv",
1112 "scoop",
1113 ]
1114 .into_iter()
1115 .map(|entry| normalize_for_policy(&home.join(entry))),
1116 );
1117 roots.sort_unstable();
1118 roots.dedup();
1119 roots
1120}
1121
1122#[cfg(any(target_os = "linux", target_os = "macos"))]
1127pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1128 let mut roots: Vec<_> = [
1129 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
1135 .into_iter()
1136 .map(|entry| normalize_for_policy(&home.join(entry)))
1137 .collect();
1138 roots.sort_unstable();
1139 roots.dedup();
1140 roots
1141}
1142
1143#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1144pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1145 let mut roots: Vec<_> = [
1146 ".npmrc",
1147 ".gitconfig",
1148 ".netrc",
1149 ".yarnrc.yml",
1150 ".config",
1151 ".npm",
1152 ".cache",
1153 ".pip",
1154 ".pypirc",
1155 ".cargo/config",
1156 ".cargo/config.toml",
1157 ".cargo/credentials",
1158 ".cargo/credentials.toml",
1159 ".cargo/registry",
1160 ".cargo/git",
1161 ]
1162 .into_iter()
1163 .map(|entry| normalize_for_policy(&home.join(entry)))
1164 .collect();
1165 roots.sort_unstable();
1166 roots.dedup();
1167 roots
1168}
1169
1170#[cfg(any(
1171 target_os = "linux",
1172 target_os = "macos",
1173 target_os = "openbsd",
1174 target_os = "windows"
1175))]
1176fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1177 roots
1178 .iter()
1179 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1180 .collect()
1181}
1182
1183fn resolve_policy_path(path: &str) -> PathBuf {
1184 let candidate = PathBuf::from(path);
1185 if candidate.is_absolute() {
1186 candidate
1187 } else {
1188 crate::stdlib::process::execution_root_path().join(candidate)
1189 }
1190}
1191
1192fn normalize_for_policy(path: &Path) -> PathBuf {
1193 let absolute = if path.is_absolute() {
1194 path.to_path_buf()
1195 } else {
1196 crate::stdlib::process::execution_root_path().join(path)
1197 };
1198 let absolute = normalize_lexically(&absolute);
1199 if let Ok(canonical) = absolute.canonicalize() {
1200 return canonical;
1201 }
1202
1203 let mut existing = absolute.as_path();
1204 let mut suffix = Vec::new();
1205 while !existing.exists() {
1206 let Some(parent) = existing.parent() else {
1207 return normalize_lexically(&absolute);
1208 };
1209 if let Some(name) = existing.file_name() {
1210 suffix.push(name.to_os_string());
1211 }
1212 existing = parent;
1213 }
1214
1215 let mut normalized = existing
1216 .canonicalize()
1217 .unwrap_or_else(|_| normalize_lexically(existing));
1218 for component in suffix.iter().rev() {
1219 normalized.push(component);
1220 }
1221 normalize_lexically(&normalized)
1222}
1223
1224fn normalize_lexically(path: &Path) -> PathBuf {
1225 let mut normalized = PathBuf::new();
1226 for component in path.components() {
1227 match component {
1228 Component::CurDir => {}
1229 Component::ParentDir => {
1230 normalized.pop();
1231 }
1232 other => normalized.push(other.as_os_str()),
1233 }
1234 }
1235 normalized
1236}
1237
1238fn path_is_within(path: &Path, root: &Path) -> bool {
1239 path == root || path.starts_with(root)
1240}
1241
1242fn normalize_io_device_path(path: &Path) -> PathBuf {
1247 let absolute = if path.is_absolute() {
1248 path.to_path_buf()
1249 } else {
1250 crate::stdlib::process::execution_root_path().join(path)
1251 };
1252 normalize_lexically(&absolute)
1253}
1254
1255fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1260 match access {
1261 FsAccess::Read => {
1262 matches!(
1263 path.to_str(),
1264 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1265 ) || is_dev_fd_descriptor(path)
1266 }
1267 FsAccess::Write => {
1268 matches!(
1269 path.to_str(),
1270 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1271 ) || is_dev_fd_descriptor(path)
1272 }
1273 FsAccess::Delete => false,
1274 }
1275}
1276
1277fn is_dev_fd_descriptor(path: &Path) -> bool {
1280 let Some(text) = path.to_str() else {
1281 return false;
1282 };
1283 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1284 return false;
1285 };
1286 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1287}
1288
1289#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1290pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1291 fn rank(value: &str) -> usize {
1292 match value {
1293 "none" => 0,
1294 "read_only" => 1,
1295 "workspace_write" => 2,
1296 "process_exec" => 3,
1297 "network" => 4,
1298 _ => 5,
1299 }
1300 }
1301 policy
1302 .side_effect_level
1303 .as_ref()
1304 .map(|level| rank(level) >= rank("network"))
1305 .unwrap_or(true)
1306}
1307
1308#[cfg(any(
1309 target_os = "linux",
1310 target_os = "macos",
1311 target_os = "openbsd",
1312 target_os = "windows"
1313))]
1314pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1315 policy.capabilities.is_empty()
1316 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1317}
1318
1319#[cfg(any(
1320 target_os = "linux",
1321 target_os = "macos",
1322 target_os = "openbsd",
1323 target_os = "windows"
1324))]
1325pub(crate) fn policy_allows_capability(
1326 policy: &CapabilityPolicy,
1327 capability: &str,
1328 ops: &[&str],
1329) -> bool {
1330 policy
1331 .capabilities
1332 .get(capability)
1333 .map(|allowed| {
1334 ops.iter()
1335 .any(|op| allowed.iter().any(|candidate| candidate == op))
1336 })
1337 .unwrap_or(false)
1338}
1339
1340impl FsAccess {
1341 fn verb(self) -> &'static str {
1342 match self {
1343 FsAccess::Read => "read",
1344 FsAccess::Write => "write",
1345 FsAccess::Delete => "delete",
1346 }
1347 }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1354
1355 #[test]
1356 fn missing_create_path_normalizes_against_existing_parent() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let nested = dir.path().join("a/../new.txt");
1359 let normalized = normalize_for_policy(&nested);
1360 assert_eq!(
1361 normalized,
1362 normalize_for_policy(&dir.path().join("new.txt"))
1363 );
1364 }
1365
1366 #[test]
1367 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1368 let _env_lock = crate::runtime_paths::test_env_lock()
1372 .lock()
1373 .unwrap_or_else(|poisoned| poisoned.into_inner());
1374 std::env::remove_var("HARN_PROJECT_ROOT");
1375 let dir = tempfile::tempdir().unwrap();
1376 crate::stdlib::process::set_thread_execution_context(Some(
1377 crate::orchestration::RunExecutionRecord {
1378 cwd: Some(dir.path().to_string_lossy().into_owned()),
1379 source_dir: None,
1380 env: Default::default(),
1381 adapter: None,
1382 repo_path: None,
1383 worktree_path: None,
1384 branch: None,
1385 base_ref: None,
1386 cleanup: None,
1387 },
1388 ));
1389 push_execution_policy(CapabilityPolicy {
1390 sandbox_profile: SandboxProfile::Worktree,
1391 ..CapabilityPolicy::default()
1392 });
1393
1394 assert!(
1395 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1396 );
1397 let outside = tempfile::tempdir().unwrap();
1398 assert!(enforce_fs_path(
1399 "read_file",
1400 &outside.path().join("outside.txt"),
1401 FsAccess::Read
1402 )
1403 .is_err());
1404
1405 pop_execution_policy();
1406 crate::stdlib::process::set_thread_execution_context(None);
1407 }
1408
1409 #[test]
1419 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
1420 let _env_lock = crate::runtime_paths::test_env_lock()
1421 .lock()
1422 .unwrap_or_else(|poisoned| poisoned.into_inner());
1423 let project = tempfile::tempdir().unwrap();
1424 let execution_cwd = tempfile::tempdir().unwrap();
1425 std::env::set_var("HARN_PROJECT_ROOT", project.path());
1426 crate::stdlib::process::set_thread_execution_context(Some(
1427 crate::orchestration::RunExecutionRecord {
1428 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
1429 source_dir: None,
1430 env: Default::default(),
1431 adapter: None,
1432 repo_path: None,
1433 worktree_path: None,
1434 branch: None,
1435 base_ref: None,
1436 cleanup: None,
1437 },
1438 ));
1439 push_execution_policy(CapabilityPolicy {
1440 sandbox_profile: SandboxProfile::Worktree,
1441 ..CapabilityPolicy::default()
1442 });
1443
1444 assert!(
1447 enforce_fs_path(
1448 "write_file",
1449 &project.path().join("test/created.ts"),
1450 FsAccess::Write,
1451 )
1452 .is_ok(),
1453 "write into HARN_PROJECT_ROOT must be allowed"
1454 );
1455 assert!(
1459 enforce_fs_path(
1460 "write_file",
1461 &execution_cwd.path().join("escape.ts"),
1462 FsAccess::Write,
1463 )
1464 .is_err(),
1465 "write under the execution cwd (outside the project) must be rejected"
1466 );
1467
1468 pop_execution_policy();
1469 crate::stdlib::process::set_thread_execution_context(None);
1470 std::env::remove_var("HARN_PROJECT_ROOT");
1471 }
1472
1473 #[test]
1474 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1475 let dir = tempfile::tempdir().unwrap();
1476 crate::stdlib::process::set_thread_execution_context(Some(
1477 crate::orchestration::RunExecutionRecord {
1478 cwd: Some(dir.path().to_string_lossy().into_owned()),
1479 source_dir: None,
1480 env: Default::default(),
1481 adapter: None,
1482 repo_path: None,
1483 worktree_path: None,
1484 branch: None,
1485 base_ref: None,
1486 cleanup: None,
1487 },
1488 ));
1489 push_execution_policy(CapabilityPolicy {
1490 sandbox_profile: SandboxProfile::Worktree,
1491 ..CapabilityPolicy::default()
1492 });
1493
1494 assert!(enforce_process_cwd(dir.path()).is_ok());
1495 let outside = tempfile::tempdir().unwrap();
1496 assert!(enforce_process_cwd(outside.path()).is_err());
1497
1498 pop_execution_policy();
1499 crate::stdlib::process::set_thread_execution_context(None);
1500 }
1501
1502 #[test]
1503 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1504 let cwd = std::env::current_dir().unwrap();
1505 let policy = CapabilityPolicy {
1506 sandbox_profile: SandboxProfile::Worktree,
1507 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1508 ..CapabilityPolicy::default()
1509 };
1510
1511 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1512
1513 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1514 }
1515
1516 #[test]
1517 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1518 let workspace = tempfile::tempdir().unwrap();
1519 let policy = CapabilityPolicy {
1520 sandbox_profile: SandboxProfile::Worktree,
1521 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1522 ..CapabilityPolicy::default()
1523 };
1524
1525 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1526
1527 assert_eq!(
1528 resolved.cwd.unwrap(),
1529 normalize_for_policy(workspace.path())
1530 );
1531 }
1532
1533 #[test]
1534 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1535 let workspace = tempfile::tempdir().unwrap();
1536 let outside = tempfile::tempdir().unwrap();
1537 let policy = CapabilityPolicy {
1538 sandbox_profile: SandboxProfile::Worktree,
1539 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1540 ..CapabilityPolicy::default()
1541 };
1542 let config = ProcessCommandConfig {
1543 cwd: Some(outside.path().to_path_buf()),
1544 ..ProcessCommandConfig::default()
1545 };
1546
1547 assert!(sandboxed_process_config(&config, &policy).is_err());
1548 }
1549
1550 #[test]
1551 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1552 let cwd = std::env::current_dir().unwrap();
1553 let policy = CapabilityPolicy {
1554 sandbox_profile: SandboxProfile::Worktree,
1555 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1556 ..CapabilityPolicy::default()
1557 };
1558
1559 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1562 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1563 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1564 assert_eq!(
1565 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1566 Some("")
1567 );
1568 }
1569
1570 #[test]
1571 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1572 let mut env = vec![
1575 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1576 ("PATH".to_string(), "/usr/bin".to_string()),
1577 ];
1578 neutralize_rustc_wrapper(&mut env);
1579 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1580 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1581 assert_eq!(
1582 collected
1583 .get("CARGO_BUILD_RUSTC_WRAPPER")
1584 .map(String::as_str),
1585 Some("")
1586 );
1587 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1588 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1590 }
1591
1592 #[test]
1593 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
1594 let workspace = tempfile::tempdir().unwrap();
1595 let policy = CapabilityPolicy {
1596 sandbox_profile: SandboxProfile::Worktree,
1597 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1598 ..CapabilityPolicy::default()
1599 };
1600
1601 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
1602
1603 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
1606 assert!(
1607 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
1608 "temp dir {tmpdir:?} must be inside the writable workspace root"
1609 );
1610 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
1611 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
1613 assert!(
1614 ignore.lines().any(|line| line.trim() == "*"),
1615 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
1616 );
1617 push_execution_policy(policy);
1620 assert!(
1621 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
1622 "writes under the workspace-local temp dir must be in sandbox scope"
1623 );
1624 pop_execution_policy();
1625 }
1626
1627 #[test]
1628 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
1629 let policy = CapabilityPolicy {
1632 sandbox_profile: SandboxProfile::Unrestricted,
1633 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
1634 ..CapabilityPolicy::default()
1635 };
1636 let mut env = Vec::new();
1637 inject_workspace_tmpdir(&mut env, &policy);
1638 assert!(
1639 env.is_empty(),
1640 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
1641 );
1642 }
1643
1644 #[test]
1645 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
1646 let workspace = tempfile::tempdir().unwrap();
1647 let policy = CapabilityPolicy {
1648 sandbox_profile: SandboxProfile::Worktree,
1649 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1650 ..CapabilityPolicy::default()
1651 };
1652 let mut env = Vec::new();
1653 inject_workspace_tmpdir(&mut env, &policy);
1654
1655 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
1656 let expected = workspace_local_tmpdir(&policy)
1657 .unwrap()
1658 .display()
1659 .to_string();
1660 for key in TMPDIR_ENV_KEYS {
1661 assert_eq!(
1662 collected.get(key).map(String::as_str),
1663 Some(expected.as_str()),
1664 "{key} must point at the workspace-local temp dir"
1665 );
1666 }
1667 }
1668
1669 #[test]
1670 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
1671 let env: std::collections::BTreeMap<_, _> =
1672 deterministic_message_locale_env().into_iter().collect();
1673 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
1676 assert_eq!(
1678 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
1679 Some("en")
1680 );
1681 assert!(
1684 !env.contains_key("LC_ALL"),
1685 "must not force LC_ALL (would clobber UTF-8 ctype)"
1686 );
1687 assert!(!env.contains_key("LC_CTYPE"));
1688 assert!(!env.contains_key("LANG"));
1689 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
1692 }
1693
1694 #[test]
1695 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
1696 let workspace = tempfile::tempdir().unwrap();
1697 let policy = CapabilityPolicy {
1698 sandbox_profile: SandboxProfile::Worktree,
1699 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1700 ..CapabilityPolicy::default()
1701 };
1702 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
1704 inject_workspace_tmpdir(&mut env, &policy);
1705
1706 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1707 assert_eq!(
1708 collected.get("TMPDIR").map(String::as_str),
1709 Some("/caller/explicit/tmp"),
1710 "an explicit caller TMPDIR must be preserved untouched"
1711 );
1712 let expected = workspace_local_tmpdir(&policy)
1713 .unwrap()
1714 .display()
1715 .to_string();
1716 assert_eq!(
1717 collected.get("TMP").map(String::as_str),
1718 Some(expected.as_str())
1719 );
1720 assert_eq!(
1721 collected.get("TEMP").map(String::as_str),
1722 Some(expected.as_str())
1723 );
1724 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
1726 }
1727
1728 #[test]
1729 fn sandboxed_process_config_injects_workspace_tmpdir() {
1730 let workspace = tempfile::tempdir().unwrap();
1731 let policy = CapabilityPolicy {
1732 sandbox_profile: SandboxProfile::Worktree,
1733 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1734 ..CapabilityPolicy::default()
1735 };
1736 let config = ProcessCommandConfig {
1737 cwd: Some(workspace.path().to_path_buf()),
1738 ..ProcessCommandConfig::default()
1739 };
1740 let resolved = sandboxed_process_config(&config, &policy).unwrap();
1741 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1742 let expected = workspace_local_tmpdir(&policy)
1743 .unwrap()
1744 .display()
1745 .to_string();
1746 assert_eq!(
1747 env.get("TMPDIR").map(String::as_str),
1748 Some(expected.as_str()),
1749 "the command_output path must inject a workspace-local TMPDIR"
1750 );
1751 }
1752
1753 #[test]
1754 fn read_only_root_outside_workspace_allows_read_denies_write() {
1755 let workspace = tempfile::tempdir().unwrap();
1760 let read_only = tempfile::tempdir().unwrap();
1761 push_execution_policy(CapabilityPolicy {
1762 sandbox_profile: SandboxProfile::Worktree,
1763 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1764 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1765 ..CapabilityPolicy::default()
1766 });
1767
1768 let asset = read_only
1769 .path()
1770 .join("partials/agent-web-tools.harn.prompt");
1771 assert!(
1773 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1774 "read under a configured read-only root must be allowed"
1775 );
1776
1777 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1779 .expect_err("write under a read-only root must be denied");
1780 assert!(write_err.read_only, "write rejection must set read_only");
1781
1782 assert!(
1784 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1785 "delete under a read-only root must be denied"
1786 );
1787
1788 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1790
1791 let stranger = tempfile::tempdir().unwrap();
1794 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1795 .expect_err("read outside all roots must be denied");
1796 assert!(
1797 !outside_err.read_only,
1798 "out-of-scope rejection must not be flagged read_only"
1799 );
1800
1801 pop_execution_policy();
1802 }
1803
1804 #[cfg(unix)]
1805 #[test]
1806 fn standard_io_device_files_allowed_under_restricted_profile() {
1807 let workspace = tempfile::tempdir().unwrap();
1812 push_execution_policy(CapabilityPolicy {
1813 sandbox_profile: SandboxProfile::Worktree,
1814 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1815 ..CapabilityPolicy::default()
1816 });
1817
1818 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1819 assert!(
1820 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1821 "write to standard device {device} must be allowed"
1822 );
1823 assert!(
1825 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1826 "read of standard device {device} must be allowed"
1827 );
1828 }
1829 assert!(
1830 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1831 "read of standard device /dev/stdin must be allowed"
1832 );
1833 assert!(
1834 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1835 "write to /dev/stdin is not a standard output stream"
1836 );
1837 assert!(
1838 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1839 "standard devices must not bypass delete scoping"
1840 );
1841 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1843 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1844
1845 let stranger = tempfile::tempdir().unwrap();
1847 assert!(
1848 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1849 "a real out-of-root write must still be rejected"
1850 );
1851 assert!(
1853 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1854 "/dev/sda must not be allowed by the standard-device allowlist"
1855 );
1856 assert!(
1857 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1858 "non-numeric /dev/fd/<x> must not be allowed"
1859 );
1860
1861 pop_execution_policy();
1862 }
1863
1864 #[test]
1865 fn is_standard_io_device_matches_only_known_streams() {
1866 assert!(is_standard_io_device_for_access(
1867 Path::new("/dev/stdin"),
1868 FsAccess::Read
1869 ));
1870 assert!(!is_standard_io_device_for_access(
1871 Path::new("/dev/stdin"),
1872 FsAccess::Write
1873 ));
1874 assert!(is_standard_io_device_for_access(
1875 Path::new("/dev/stdout"),
1876 FsAccess::Write
1877 ));
1878 assert!(is_standard_io_device_for_access(
1879 Path::new("/dev/stderr"),
1880 FsAccess::Write
1881 ));
1882 assert!(is_standard_io_device_for_access(
1883 Path::new("/dev/null"),
1884 FsAccess::Write
1885 ));
1886 assert!(is_standard_io_device_for_access(
1887 Path::new("/dev/fd/0"),
1888 FsAccess::Read
1889 ));
1890 assert!(is_standard_io_device_for_access(
1891 Path::new("/dev/fd/12"),
1892 FsAccess::Write
1893 ));
1894 assert!(!is_standard_io_device_for_access(
1895 Path::new("/dev/null"),
1896 FsAccess::Delete
1897 ));
1898 assert!(!is_standard_io_device_for_access(
1899 Path::new("/dev/fd/"),
1900 FsAccess::Write
1901 ));
1902 assert!(!is_standard_io_device_for_access(
1903 Path::new("/dev/fd/1a"),
1904 FsAccess::Write
1905 ));
1906 assert!(!is_standard_io_device_for_access(
1907 Path::new("/dev/stdoutx"),
1908 FsAccess::Write
1909 ));
1910 assert!(!is_standard_io_device_for_access(
1911 Path::new("/dev/random"),
1912 FsAccess::Read
1913 ));
1914 assert!(!is_standard_io_device_for_access(
1915 Path::new("/tmp/dev/null"),
1916 FsAccess::Write
1917 ));
1918 }
1919
1920 #[test]
1921 fn path_within_root_accepts_root_and_children() {
1922 let root = Path::new("/tmp/harn-root");
1923 assert!(path_is_within(root, root));
1924 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1925 assert!(!path_is_within(
1926 Path::new("/tmp/harn-root-other/file"),
1927 root
1928 ));
1929 }
1930
1931 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1932 #[test]
1933 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1934 let temp_home = tempfile::tempdir().expect("temp home");
1935 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1936 let normalized_home = normalize_for_policy(temp_home.path());
1937
1938 for suffix in [
1939 Path::new(".cargo"),
1940 Path::new(".rustup"),
1941 Path::new(".pyenv"),
1942 Path::new(".nvm"),
1943 Path::new(".volta"),
1944 Path::new(".local/share/uv"),
1945 Path::new("go"),
1946 ] {
1947 assert!(
1948 roots.iter().any(|path| path.ends_with(suffix)),
1949 "expected a developer-toolchain grant for {}",
1950 suffix.display()
1951 );
1952 }
1953 assert!(
1954 roots.iter().all(|path| path.starts_with(&normalized_home)),
1955 "developer-toolchain roots must stay under HOME"
1956 );
1957 }
1958
1959 #[cfg(any(target_os = "linux", target_os = "macos"))]
1960 #[test]
1961 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
1962 let temp_home = tempfile::tempdir().expect("temp home");
1963 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
1964 let normalized_home = normalize_for_policy(temp_home.path());
1965
1966 for suffix in [
1967 Path::new(".gradle"),
1968 Path::new(".m2"),
1969 Path::new(".konan"),
1970 Path::new("Library/Caches/CocoaPods"),
1971 Path::new("Library/Developer/Xcode/DerivedData"),
1972 ] {
1973 assert!(
1974 roots.iter().any(|path| path.ends_with(suffix)),
1975 "expected a JVM/iOS toolchain cache grant for {}",
1976 suffix.display()
1977 );
1978 }
1979 assert!(
1980 roots.iter().all(|path| path.starts_with(&normalized_home)),
1981 "toolchain cache roots must stay under HOME"
1982 );
1983 }
1984
1985 #[cfg(any(target_os = "linux", target_os = "macos"))]
1986 #[test]
1987 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
1988 let mut policy = CapabilityPolicy {
1989 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1990 ..CapabilityPolicy::default()
1991 };
1992 if sandbox_user_home_dir().is_some() {
1995 assert!(
1996 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
1997 "default presets should render JVM/iOS cache roots"
1998 );
1999 }
2000 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2002 assert!(
2003 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2004 "cache roots must be gated on the DeveloperToolchains preset"
2005 );
2006 }
2007
2008 #[test]
2009 fn os_hardened_profile_overrides_fallback_env() {
2010 assert_eq!(
2015 effective_fallback(SandboxProfile::OsHardened),
2016 SandboxFallback::Enforce
2017 );
2018 }
2019
2020 #[test]
2021 fn unrestricted_profile_skips_active_sandbox() {
2022 let policy = CapabilityPolicy {
2023 sandbox_profile: SandboxProfile::Unrestricted,
2024 workspace_roots: vec!["/tmp".to_string()],
2025 ..Default::default()
2026 };
2027 crate::orchestration::push_execution_policy(policy);
2028 let result = active_sandbox_policy();
2029 crate::orchestration::pop_execution_policy();
2030 assert!(
2031 result.is_none(),
2032 "Unrestricted profile must short-circuit sandbox dispatch"
2033 );
2034 }
2035
2036 #[test]
2037 fn worktree_profile_engages_active_sandbox() {
2038 let policy = CapabilityPolicy {
2039 sandbox_profile: SandboxProfile::Worktree,
2040 workspace_roots: vec!["/tmp".to_string()],
2041 ..Default::default()
2042 };
2043 crate::orchestration::push_execution_policy(policy);
2044 let result = active_sandbox_policy();
2045 crate::orchestration::pop_execution_policy();
2046 assert!(
2047 result.is_some(),
2048 "Worktree profile must keep sandbox dispatch active"
2049 );
2050 }
2051}