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 crate::op_interrupt::capture_output_interruptible(&mut command)
142 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
143 }
144}
145
146pub(crate) enum PrepareOutcome {
150 Direct,
152 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
158 WrappedExec { wrapper: String, args: Vec<String> },
159}
160
161#[cfg(target_os = "linux")]
162type ActiveBackend = linux::Backend;
163#[cfg(target_os = "macos")]
164type ActiveBackend = macos::Backend;
165#[cfg(target_os = "openbsd")]
166type ActiveBackend = openbsd::Backend;
167#[cfg(target_os = "windows")]
168type ActiveBackend = windows::Backend;
169#[cfg(not(any(
170 target_os = "linux",
171 target_os = "macos",
172 target_os = "openbsd",
173 target_os = "windows"
174)))]
175type ActiveBackend = NoopBackend;
176
177#[cfg(not(any(
178 target_os = "linux",
179 target_os = "macos",
180 target_os = "openbsd",
181 target_os = "windows"
182)))]
183pub(crate) struct NoopBackend;
184
185#[cfg(not(any(
186 target_os = "linux",
187 target_os = "macos",
188 target_os = "openbsd",
189 target_os = "windows"
190)))]
191impl SandboxBackend for NoopBackend {
192 fn name() -> &'static str {
193 "noop"
194 }
195 fn available() -> bool {
196 false
197 }
198 fn prepare_std_command(
199 _program: &str,
200 _args: &[String],
201 _command: &mut Command,
202 _policy: &CapabilityPolicy,
203 _profile: SandboxProfile,
204 ) -> Result<PrepareOutcome, VmError> {
205 Ok(PrepareOutcome::Direct)
206 }
207 fn prepare_tokio_command(
208 _program: &str,
209 _args: &[String],
210 _command: &mut tokio::process::Command,
211 _policy: &CapabilityPolicy,
212 _profile: SandboxProfile,
213 ) -> Result<PrepareOutcome, VmError> {
214 Ok(PrepareOutcome::Direct)
215 }
216}
217
218pub(crate) fn reset_sandbox_state() {
219 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
220}
221
222pub fn active_backend_name() -> &'static str {
226 ActiveBackend::name()
227}
228
229pub fn active_backend_available() -> bool {
234 ActiveBackend::available()
235}
236
237pub fn register_sandbox_builtins(vm: &mut Vm) {
241 for def in MODULE_BUILTINS {
242 vm.register_builtin_def(def);
243 }
244}
245
246pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
247 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
248 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
249 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
250];
251
252#[crate::stdlib::macros::harn_builtin(
253 sig = "sandbox_active_backend() -> string",
254 category = "sandbox"
255)]
256fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
257 Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
258}
259
260#[crate::stdlib::macros::harn_builtin(
261 sig = "sandbox_backend_available() -> bool",
262 category = "sandbox"
263)]
264fn sandbox_backend_available_impl(
265 _args: &[VmValue],
266 _out: &mut String,
267) -> Result<VmValue, VmError> {
268 Ok(VmValue::Bool(active_backend_available()))
269}
270
271#[crate::stdlib::macros::harn_builtin(
272 sig = "sandbox_active_profile() -> string",
273 category = "sandbox"
274)]
275fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
276 let profile = crate::orchestration::current_execution_policy()
277 .map(|policy| policy.sandbox_profile)
278 .unwrap_or(SandboxProfile::Unrestricted);
279 Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
280}
281
282#[derive(Clone, Debug)]
289pub struct SandboxViolation {
290 pub attempted: PathBuf,
294 pub roots: Vec<PathBuf>,
297 pub access: FsAccess,
299 pub read_only: bool,
303}
304
305impl SandboxViolation {
306 pub fn message(&self, builtin: &str) -> String {
310 if self.read_only {
311 return format!(
312 "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
313 self.access.verb(),
314 self.attempted.display(),
315 );
316 }
317 format!(
318 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
319 self.access.verb(),
320 self.attempted.display(),
321 self.roots
322 .iter()
323 .map(|root| root.display().to_string())
324 .collect::<Vec<_>>()
325 .join(", ")
326 )
327 }
328}
329
330pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
345 let Some(policy) = crate::orchestration::current_execution_policy() else {
346 return Ok(());
347 };
348 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
349 return Ok(());
350 }
351 if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
362 return Ok(());
363 }
364 let candidate = normalize_for_policy(path);
365 let roots = normalized_workspace_roots(&policy);
366 if roots.iter().any(|root| path_is_within(&candidate, root)) {
367 return Ok(());
368 }
369 let read_only_roots = normalized_read_only_roots(&policy);
370 let within_read_only = read_only_roots
371 .iter()
372 .any(|root| path_is_within(&candidate, root));
373 if within_read_only && access == FsAccess::Read {
374 return Ok(());
375 }
376 Err(SandboxViolation {
377 attempted: candidate,
378 roots,
379 access,
380 read_only: within_read_only,
381 })
382}
383
384pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
385 check_fs_path_scope(path, access)
386 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
387}
388
389pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
390 let Some(policy) = crate::orchestration::current_execution_policy() else {
391 return Ok(());
392 };
393 enforce_process_cwd_for_policy(path, &policy)
394}
395
396fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
397 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
398 return Ok(());
399 }
400 let candidate = normalize_for_policy(path);
401 let roots = normalized_workspace_roots(policy);
402 if roots.iter().any(|root| path_is_within(&candidate, root)) {
403 return Ok(());
404 }
405 Err(sandbox_rejection(format!(
406 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
407 candidate.display(),
408 roots
409 .iter()
410 .map(|root| root.display().to_string())
411 .collect::<Vec<_>>()
412 .join(", ")
413 )))
414}
415
416pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
417 let (policy, profile) = match active_sandbox_policy() {
418 Some(value) => value,
419 None => {
420 let mut command = Command::new(program);
421 command.args(args);
422 return Ok(command);
423 }
424 };
425 build_std_command::<ActiveBackend>(program, args, &policy, profile)
426}
427
428pub fn tokio_command_for(
429 program: &str,
430 args: &[String],
431) -> Result<tokio::process::Command, VmError> {
432 let (policy, profile) = match active_sandbox_policy() {
433 Some(value) => value,
434 None => {
435 let mut command = tokio::process::Command::new(program);
436 command.args(args);
437 return Ok(command);
438 }
439 };
440 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
441}
442
443pub fn command_output(
444 program: &str,
445 args: &[String],
446 config: &ProcessCommandConfig,
447) -> Result<Output, VmError> {
448 if let Some(intercepted) =
453 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
454 {
455 return intercepted.map_err(|message| {
456 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
457 });
458 }
459
460 let recording =
461 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
462
463 let output = match active_sandbox_policy() {
464 Some((policy, profile)) => {
465 let config = sandboxed_process_config(config, &policy)?;
466 ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
467 }
468 None => {
469 let mut command = Command::new(program);
470 command.args(args);
471 apply_process_config(&mut command, config);
472 crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
477 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
478 })?
479 }
480 };
481 if let Some(error) = process_violation_error(&output) {
482 return Err(error);
483 }
484 if let Some(span) = recording {
485 span.finish(&output);
486 }
487 Ok(output)
488}
489
490fn sandboxed_process_config(
491 config: &ProcessCommandConfig,
492 policy: &CapabilityPolicy,
493) -> Result<ProcessCommandConfig, VmError> {
494 let mut resolved = config.clone();
495 if let Some(cwd) = resolved.cwd.as_ref() {
496 enforce_process_cwd_for_policy(cwd, policy)?;
497 } else {
498 resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
499 }
500 neutralize_rustc_wrapper(&mut resolved.env);
501 inject_workspace_tmpdir(&mut resolved.env, policy);
502 Ok(resolved)
503}
504
505fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
519 for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
520 if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
521 entry.1.clear();
522 } else {
523 env.push((key.to_string(), String::new()));
524 }
525 }
526}
527
528pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
534
535pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
539
540pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
557 let root = normalized_workspace_roots(policy).into_iter().next()?;
558 let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
559 if let Err(error) = std::fs::create_dir_all(&tmpdir) {
560 warn_once(
561 "handler_sandbox_workspace_tmpdir",
562 &format!(
563 "could not create workspace-local temp dir '{}': {error}; \
564 leaving the child's inherited temp dir in place",
565 tmpdir.display()
566 ),
567 );
568 return None;
569 }
570 let ignore = tmpdir.join(".gitignore");
576 if !ignore.exists() {
577 let _ = std::fs::write(
578 &ignore,
579 "# Created by the Harn sandbox; safe to delete.\n*\n",
580 );
581 }
582 Some(tmpdir)
583}
584
585pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
595 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
596 return;
597 }
598 let Some(tmpdir) = workspace_local_tmpdir(policy) else {
599 return;
600 };
601 let tmpdir = tmpdir.display().to_string();
602 for key in TMPDIR_ENV_KEYS {
603 if env.iter().any(|(existing, _)| existing == key) {
604 continue;
606 }
607 env.push((key.to_string(), tmpdir.clone()));
608 }
609}
610
611pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
626 let Some(policy) = crate::orchestration::current_execution_policy() else {
627 return Vec::new();
628 };
629 let mut env = Vec::new();
630 inject_workspace_tmpdir(&mut env, &policy);
631 env
632}
633
634pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
659 vec![
660 ("LC_MESSAGES".to_string(), "C".to_string()),
661 ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
662 ]
663}
664
665pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
670
671fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
672 let roots = normalized_workspace_roots(policy);
673 let current = std::env::current_dir().map_err(|error| {
674 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
675 format!("process cwd resolution failed: {error}"),
676 )))
677 })?;
678 let current = normalize_for_policy(¤t);
679 if roots.iter().any(|root| path_is_within(¤t, root)) {
680 return Ok(current);
681 }
682 roots.first().cloned().ok_or_else(|| {
683 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
684 "process cwd resolution failed: no workspace root available",
685 )))
686 })
687}
688
689fn build_std_command<B: SandboxBackend + ?Sized>(
690 program: &str,
691 args: &[String],
692 policy: &CapabilityPolicy,
693 profile: SandboxProfile,
694) -> Result<Command, VmError> {
695 let mut command = Command::new(program);
696 command.args(args);
697 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
698 PrepareOutcome::Direct => Ok(command),
699 PrepareOutcome::WrappedExec { wrapper, args } => {
700 let mut wrapped = Command::new(wrapper);
701 wrapped.args(args);
702 Ok(wrapped)
703 }
704 }
705}
706
707fn build_tokio_command<B: SandboxBackend + ?Sized>(
708 program: &str,
709 args: &[String],
710 policy: &CapabilityPolicy,
711 profile: SandboxProfile,
712) -> Result<tokio::process::Command, VmError> {
713 let mut command = tokio::process::Command::new(program);
714 command.args(args);
715 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
716 PrepareOutcome::Direct => Ok(command),
717 PrepareOutcome::WrappedExec { wrapper, args } => {
718 let mut wrapped = tokio::process::Command::new(wrapper);
719 wrapped.args(args);
720 Ok(wrapped)
721 }
722 }
723}
724
725pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
726 let policy = crate::orchestration::current_execution_policy()?;
727 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
728 return None;
729 }
730 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
731 || !ActiveBackend::available()
732 {
733 return None;
734 }
735 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
736 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
737 if !output.status.success()
738 && (stderr.contains("operation not permitted")
739 || stderr.contains("permission denied")
740 || stderr.contains("access is denied")
741 || stdout.contains("operation not permitted"))
742 {
743 return Some(sandbox_rejection(sandbox_process_violation_message(
744 format!(
745 "sandbox violation: process was denied by the OS sandbox (status {})",
746 output.status.code().unwrap_or(-1)
747 ),
748 )));
749 }
750 if sandbox_signal_status(output) {
751 return Some(sandbox_rejection(sandbox_process_violation_message(
752 format!(
753 "sandbox violation: process was terminated by the OS sandbox (status {})",
754 output.status
755 ),
756 )));
757 }
758 None
759}
760
761pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
762 let policy = crate::orchestration::current_execution_policy()?;
763 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
764 return None;
765 }
766 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
767 || !ActiveBackend::available()
768 {
769 return None;
770 }
771 let message = error.to_string().to_ascii_lowercase();
772 if error.kind() == std::io::ErrorKind::PermissionDenied
773 || message.contains("operation not permitted")
774 || message.contains("permission denied")
775 || message.contains("access is denied")
776 {
777 return Some(sandbox_rejection(sandbox_process_violation_message(
778 format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
779 )));
780 }
781 None
782}
783
784#[cfg(unix)]
785fn sandbox_signal_status(output: &std::process::Output) -> bool {
786 use std::os::unix::process::ExitStatusExt;
787
788 matches!(
789 output.status.signal(),
790 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
791 )
792}
793
794#[cfg(not(unix))]
795fn sandbox_signal_status(_output: &std::process::Output) -> bool {
796 false
797}
798
799pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
807 let policy = crate::orchestration::current_execution_policy()?;
808 let profile = policy.sandbox_profile;
809 match profile {
810 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
811 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
812 if effective_fallback(profile) == SandboxFallback::Off {
813 None
814 } else {
815 Some((policy, profile))
816 }
817 }
818 }
819}
820
821fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
822 if let Some(cwd) = config.cwd.as_ref() {
823 command.current_dir(cwd);
824 }
825 command.envs(config.env.iter().map(|(key, value)| (key, value)));
826 if config.stdin_null {
827 command.stdin(Stdio::null());
828 }
829}
830
831fn spawn_error(error: std::io::Error) -> VmError {
832 VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
833 format!("process spawn failed: {error}"),
834 )))
835}
836
837pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
842 if matches!(profile, SandboxProfile::OsHardened) {
843 return SandboxFallback::Enforce;
844 }
845 match std::env::var(HANDLER_SANDBOX_ENV)
846 .unwrap_or_else(|_| "warn".to_string())
847 .trim()
848 .to_ascii_lowercase()
849 .as_str()
850 {
851 "0" | "false" | "off" | "none" => SandboxFallback::Off,
852 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
853 _ => SandboxFallback::Warn,
854 }
855}
856
857pub(crate) fn warn_once(key: &str, message: &str) {
858 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
859 if inserted {
860 crate::events::log_warn("handler_sandbox", message);
861 }
862}
863
864pub(crate) fn sandbox_rejection(message: String) -> VmError {
865 VmError::CategorizedError {
866 message,
867 category: ErrorCategory::ToolRejected,
868 }
869}
870
871fn sandbox_process_violation_message(summary: String) -> String {
872 format!(
873 "{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"
874 )
875}
876
877#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
887pub(crate) fn unavailable(
888 message: &str,
889 profile: SandboxProfile,
890) -> Result<PrepareOutcome, VmError> {
891 match effective_fallback(profile) {
892 SandboxFallback::Off | SandboxFallback::Warn => {
893 warn_once("handler_sandbox_unavailable", message);
894 Ok(PrepareOutcome::Direct)
895 }
896 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
897 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
898 ))),
899 }
900}
901
902fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
910 let session_id = crate::agent_sessions::current_session_id()?;
911 let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
912 let mut roots = vec![anchor.primary.clone()];
913 for mounted in &anchor.additional_roots {
914 if matches!(
915 mounted.mount_mode,
916 crate::workspace_anchor::MountMode::Extend
917 ) {
918 roots.push(mounted.path.clone());
919 }
920 }
921 Some(roots)
922}
923
924fn project_root_env_workspace_root() -> Option<PathBuf> {
932 std::env::var("HARN_PROJECT_ROOT")
933 .ok()
934 .map(|value| value.trim().to_string())
935 .filter(|value| !value.is_empty())
936 .map(PathBuf::from)
937}
938
939fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
940 if policy.workspace_roots.is_empty() {
941 if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
956 return anchor_roots
957 .iter()
958 .map(|root| normalize_for_policy(root))
959 .collect();
960 }
961 if let Some(project_root) = project_root_env_workspace_root() {
962 return vec![normalize_for_policy(&project_root)];
963 }
964 return vec![normalize_for_policy(
965 &crate::stdlib::process::execution_root_path(),
966 )];
967 }
968 policy
969 .workspace_roots
970 .iter()
971 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
972 .collect()
973}
974
975pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
976 normalized_workspace_roots(policy)
977}
978
979fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
984 policy
985 .read_only_roots
986 .iter()
987 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
988 .collect()
989}
990
991#[cfg(any(
992 target_os = "linux",
993 target_os = "macos",
994 target_os = "openbsd",
995 target_os = "windows"
996))]
997pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
998 normalized_read_only_roots(policy)
999}
1000
1001#[cfg(any(
1002 target_os = "linux",
1003 target_os = "macos",
1004 target_os = "openbsd",
1005 target_os = "windows"
1006))]
1007pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1008 normalized_process_roots(&policy.process_sandbox.read_roots)
1009}
1010
1011#[cfg(any(
1012 target_os = "linux",
1013 target_os = "macos",
1014 target_os = "openbsd",
1015 target_os = "windows"
1016))]
1017pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1018 normalized_process_roots(&policy.process_sandbox.write_roots)
1019}
1020
1021#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1022pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1023 policy.process_sandbox.effective_presets()
1024}
1025
1026#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1027pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1028 policy: &CapabilityPolicy,
1029) -> Vec<PathBuf> {
1030 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1031 return Vec::new();
1032 }
1033 let Some(home) = sandbox_user_home_dir() else {
1034 return Vec::new();
1035 };
1036 developer_toolchain_read_roots_for_home(&home)
1037}
1038
1039#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1040pub(crate) fn process_sandbox_package_manager_config_read_roots(
1041 policy: &CapabilityPolicy,
1042) -> Vec<PathBuf> {
1043 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1044 return Vec::new();
1045 }
1046 let Some(home) = sandbox_user_home_dir() else {
1047 return Vec::new();
1048 };
1049 package_manager_config_read_roots_for_home(&home)
1050}
1051
1052#[cfg(any(target_os = "linux", target_os = "macos"))]
1067pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1068 policy: &CapabilityPolicy,
1069) -> Vec<PathBuf> {
1070 if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1071 return Vec::new();
1072 }
1073 let Some(home) = sandbox_user_home_dir() else {
1074 return Vec::new();
1075 };
1076 developer_toolchain_cache_write_roots_for_home(&home)
1077}
1078
1079#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1080fn sandbox_user_home_dir() -> Option<PathBuf> {
1081 crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1084}
1085
1086#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1087pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1088 let mut roots: Vec<_> = [
1089 ".asdf",
1090 ".bun",
1091 ".cargo",
1092 ".fnm",
1093 ".juliaup",
1094 ".local/bin",
1095 ".local/share/mise",
1096 ".local/share/uv",
1097 ".nvm",
1098 ".pyenv",
1099 ".rbenv",
1100 ".rustup",
1101 ".sdkman",
1102 ".swiftly",
1103 ".volta",
1104 "go",
1105 ]
1106 .into_iter()
1107 .map(|entry| normalize_for_policy(&home.join(entry)))
1108 .collect();
1109 #[cfg(target_os = "windows")]
1110 roots.extend(
1111 [
1112 "AppData/Local/Programs/Python",
1113 "AppData/Local/uv",
1114 "AppData/Roaming/uv",
1115 "scoop",
1116 ]
1117 .into_iter()
1118 .map(|entry| normalize_for_policy(&home.join(entry))),
1119 );
1120 roots.sort_unstable();
1121 roots.dedup();
1122 roots
1123}
1124
1125#[cfg(any(target_os = "linux", target_os = "macos"))]
1130pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1131 let mut roots: Vec<_> = [
1132 ".gradle", ".m2", ".konan", "Library/Caches/CocoaPods", "Library/Developer/Xcode/DerivedData", ]
1138 .into_iter()
1139 .map(|entry| normalize_for_policy(&home.join(entry)))
1140 .collect();
1141 roots.sort_unstable();
1142 roots.dedup();
1143 roots
1144}
1145
1146#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1147pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1148 let mut roots: Vec<_> = [
1149 ".npmrc",
1150 ".gitconfig",
1151 ".netrc",
1152 ".yarnrc.yml",
1153 ".config",
1154 ".npm",
1155 ".cache",
1156 ".pip",
1157 ".pypirc",
1158 ".cargo/config",
1159 ".cargo/config.toml",
1160 ".cargo/credentials",
1161 ".cargo/credentials.toml",
1162 ".cargo/registry",
1163 ".cargo/git",
1164 ]
1165 .into_iter()
1166 .map(|entry| normalize_for_policy(&home.join(entry)))
1167 .collect();
1168 roots.sort_unstable();
1169 roots.dedup();
1170 roots
1171}
1172
1173#[cfg(any(
1174 target_os = "linux",
1175 target_os = "macos",
1176 target_os = "openbsd",
1177 target_os = "windows"
1178))]
1179fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1180 roots
1181 .iter()
1182 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1183 .collect()
1184}
1185
1186fn resolve_policy_path(path: &str) -> PathBuf {
1187 let candidate = PathBuf::from(path);
1188 if candidate.is_absolute() {
1189 candidate
1190 } else {
1191 crate::stdlib::process::execution_root_path().join(candidate)
1192 }
1193}
1194
1195fn normalize_for_policy(path: &Path) -> PathBuf {
1196 let absolute = if path.is_absolute() {
1197 path.to_path_buf()
1198 } else {
1199 crate::stdlib::process::execution_root_path().join(path)
1200 };
1201 let absolute = normalize_lexically(&absolute);
1202 if let Ok(canonical) = absolute.canonicalize() {
1203 return canonical;
1204 }
1205
1206 let mut existing = absolute.as_path();
1207 let mut suffix = Vec::new();
1208 while !existing.exists() {
1209 let Some(parent) = existing.parent() else {
1210 return normalize_lexically(&absolute);
1211 };
1212 if let Some(name) = existing.file_name() {
1213 suffix.push(name.to_os_string());
1214 }
1215 existing = parent;
1216 }
1217
1218 let mut normalized = existing
1219 .canonicalize()
1220 .unwrap_or_else(|_| normalize_lexically(existing));
1221 for component in suffix.iter().rev() {
1222 normalized.push(component);
1223 }
1224 normalize_lexically(&normalized)
1225}
1226
1227fn normalize_lexically(path: &Path) -> PathBuf {
1228 let mut normalized = PathBuf::new();
1229 for component in path.components() {
1230 match component {
1231 Component::CurDir => {}
1232 Component::ParentDir => {
1233 normalized.pop();
1234 }
1235 other => normalized.push(other.as_os_str()),
1236 }
1237 }
1238 normalized
1239}
1240
1241fn path_is_within(path: &Path, root: &Path) -> bool {
1242 path == root || path.starts_with(root)
1243}
1244
1245fn normalize_io_device_path(path: &Path) -> PathBuf {
1250 let absolute = if path.is_absolute() {
1251 path.to_path_buf()
1252 } else {
1253 crate::stdlib::process::execution_root_path().join(path)
1254 };
1255 normalize_lexically(&absolute)
1256}
1257
1258fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1263 match access {
1264 FsAccess::Read => {
1265 matches!(
1266 path.to_str(),
1267 Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1268 ) || is_dev_fd_descriptor(path)
1269 }
1270 FsAccess::Write => {
1271 matches!(
1272 path.to_str(),
1273 Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1274 ) || is_dev_fd_descriptor(path)
1275 }
1276 FsAccess::Delete => false,
1277 }
1278}
1279
1280fn is_dev_fd_descriptor(path: &Path) -> bool {
1283 let Some(text) = path.to_str() else {
1284 return false;
1285 };
1286 let Some(fd) = text.strip_prefix("/dev/fd/") else {
1287 return false;
1288 };
1289 !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1290}
1291
1292#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1293pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1294 fn rank(value: &str) -> usize {
1295 match value {
1296 "none" => 0,
1297 "read_only" => 1,
1298 "workspace_write" => 2,
1299 "process_exec" => 3,
1300 "network" => 4,
1301 _ => 5,
1302 }
1303 }
1304 policy
1305 .side_effect_level
1306 .as_ref()
1307 .map(|level| rank(level) >= rank("network"))
1308 .unwrap_or(true)
1309}
1310
1311#[cfg(any(
1312 target_os = "linux",
1313 target_os = "macos",
1314 target_os = "openbsd",
1315 target_os = "windows"
1316))]
1317pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1318 policy.capabilities.is_empty()
1319 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1320}
1321
1322#[cfg(any(
1323 target_os = "linux",
1324 target_os = "macos",
1325 target_os = "openbsd",
1326 target_os = "windows"
1327))]
1328pub(crate) fn policy_allows_capability(
1329 policy: &CapabilityPolicy,
1330 capability: &str,
1331 ops: &[&str],
1332) -> bool {
1333 policy
1334 .capabilities
1335 .get(capability)
1336 .map(|allowed| {
1337 ops.iter()
1338 .any(|op| allowed.iter().any(|candidate| candidate == op))
1339 })
1340 .unwrap_or(false)
1341}
1342
1343impl FsAccess {
1344 fn verb(self) -> &'static str {
1345 match self {
1346 FsAccess::Read => "read",
1347 FsAccess::Write => "write",
1348 FsAccess::Delete => "delete",
1349 }
1350 }
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355 use super::*;
1356 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1357
1358 #[test]
1359 fn missing_create_path_normalizes_against_existing_parent() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let nested = dir.path().join("a/../new.txt");
1362 let normalized = normalize_for_policy(&nested);
1363 assert_eq!(
1364 normalized,
1365 normalize_for_policy(&dir.path().join("new.txt"))
1366 );
1367 }
1368
1369 #[test]
1370 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1371 let _env_lock = crate::runtime_paths::test_env_lock()
1375 .lock()
1376 .unwrap_or_else(|poisoned| poisoned.into_inner());
1377 std::env::remove_var("HARN_PROJECT_ROOT");
1378 let dir = tempfile::tempdir().unwrap();
1379 crate::stdlib::process::set_thread_execution_context(Some(
1380 crate::orchestration::RunExecutionRecord {
1381 cwd: Some(dir.path().to_string_lossy().into_owned()),
1382 source_dir: None,
1383 env: Default::default(),
1384 adapter: None,
1385 repo_path: None,
1386 worktree_path: None,
1387 branch: None,
1388 base_ref: None,
1389 cleanup: None,
1390 },
1391 ));
1392 push_execution_policy(CapabilityPolicy {
1393 sandbox_profile: SandboxProfile::Worktree,
1394 ..CapabilityPolicy::default()
1395 });
1396
1397 assert!(
1398 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1399 );
1400 let outside = tempfile::tempdir().unwrap();
1401 assert!(enforce_fs_path(
1402 "read_file",
1403 &outside.path().join("outside.txt"),
1404 FsAccess::Read
1405 )
1406 .is_err());
1407
1408 pop_execution_policy();
1409 crate::stdlib::process::set_thread_execution_context(None);
1410 }
1411
1412 #[test]
1422 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
1423 let _env_lock = crate::runtime_paths::test_env_lock()
1424 .lock()
1425 .unwrap_or_else(|poisoned| poisoned.into_inner());
1426 let project = tempfile::tempdir().unwrap();
1427 let execution_cwd = tempfile::tempdir().unwrap();
1428 std::env::set_var("HARN_PROJECT_ROOT", project.path());
1429 crate::stdlib::process::set_thread_execution_context(Some(
1430 crate::orchestration::RunExecutionRecord {
1431 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
1432 source_dir: None,
1433 env: Default::default(),
1434 adapter: None,
1435 repo_path: None,
1436 worktree_path: None,
1437 branch: None,
1438 base_ref: None,
1439 cleanup: None,
1440 },
1441 ));
1442 push_execution_policy(CapabilityPolicy {
1443 sandbox_profile: SandboxProfile::Worktree,
1444 ..CapabilityPolicy::default()
1445 });
1446
1447 assert!(
1450 enforce_fs_path(
1451 "write_file",
1452 &project.path().join("test/created.ts"),
1453 FsAccess::Write,
1454 )
1455 .is_ok(),
1456 "write into HARN_PROJECT_ROOT must be allowed"
1457 );
1458 assert!(
1462 enforce_fs_path(
1463 "write_file",
1464 &execution_cwd.path().join("escape.ts"),
1465 FsAccess::Write,
1466 )
1467 .is_err(),
1468 "write under the execution cwd (outside the project) must be rejected"
1469 );
1470
1471 pop_execution_policy();
1472 crate::stdlib::process::set_thread_execution_context(None);
1473 std::env::remove_var("HARN_PROJECT_ROOT");
1474 }
1475
1476 #[test]
1477 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1478 let dir = tempfile::tempdir().unwrap();
1479 crate::stdlib::process::set_thread_execution_context(Some(
1480 crate::orchestration::RunExecutionRecord {
1481 cwd: Some(dir.path().to_string_lossy().into_owned()),
1482 source_dir: None,
1483 env: Default::default(),
1484 adapter: None,
1485 repo_path: None,
1486 worktree_path: None,
1487 branch: None,
1488 base_ref: None,
1489 cleanup: None,
1490 },
1491 ));
1492 push_execution_policy(CapabilityPolicy {
1493 sandbox_profile: SandboxProfile::Worktree,
1494 ..CapabilityPolicy::default()
1495 });
1496
1497 assert!(enforce_process_cwd(dir.path()).is_ok());
1498 let outside = tempfile::tempdir().unwrap();
1499 assert!(enforce_process_cwd(outside.path()).is_err());
1500
1501 pop_execution_policy();
1502 crate::stdlib::process::set_thread_execution_context(None);
1503 }
1504
1505 #[test]
1506 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1507 let cwd = std::env::current_dir().unwrap();
1508 let policy = CapabilityPolicy {
1509 sandbox_profile: SandboxProfile::Worktree,
1510 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1511 ..CapabilityPolicy::default()
1512 };
1513
1514 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1515
1516 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1517 }
1518
1519 #[test]
1520 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1521 let workspace = tempfile::tempdir().unwrap();
1522 let policy = CapabilityPolicy {
1523 sandbox_profile: SandboxProfile::Worktree,
1524 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1525 ..CapabilityPolicy::default()
1526 };
1527
1528 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1529
1530 assert_eq!(
1531 resolved.cwd.unwrap(),
1532 normalize_for_policy(workspace.path())
1533 );
1534 }
1535
1536 #[test]
1537 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1538 let workspace = tempfile::tempdir().unwrap();
1539 let outside = tempfile::tempdir().unwrap();
1540 let policy = CapabilityPolicy {
1541 sandbox_profile: SandboxProfile::Worktree,
1542 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1543 ..CapabilityPolicy::default()
1544 };
1545 let config = ProcessCommandConfig {
1546 cwd: Some(outside.path().to_path_buf()),
1547 ..ProcessCommandConfig::default()
1548 };
1549
1550 assert!(sandboxed_process_config(&config, &policy).is_err());
1551 }
1552
1553 #[test]
1554 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1555 let cwd = std::env::current_dir().unwrap();
1556 let policy = CapabilityPolicy {
1557 sandbox_profile: SandboxProfile::Worktree,
1558 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1559 ..CapabilityPolicy::default()
1560 };
1561
1562 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1565 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1566 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1567 assert_eq!(
1568 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1569 Some("")
1570 );
1571 }
1572
1573 #[test]
1574 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1575 let mut env = vec![
1578 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1579 ("PATH".to_string(), "/usr/bin".to_string()),
1580 ];
1581 neutralize_rustc_wrapper(&mut env);
1582 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1583 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1584 assert_eq!(
1585 collected
1586 .get("CARGO_BUILD_RUSTC_WRAPPER")
1587 .map(String::as_str),
1588 Some("")
1589 );
1590 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1591 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1593 }
1594
1595 #[test]
1596 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
1597 let workspace = tempfile::tempdir().unwrap();
1598 let policy = CapabilityPolicy {
1599 sandbox_profile: SandboxProfile::Worktree,
1600 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1601 ..CapabilityPolicy::default()
1602 };
1603
1604 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
1605
1606 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
1609 assert!(
1610 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
1611 "temp dir {tmpdir:?} must be inside the writable workspace root"
1612 );
1613 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
1614 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
1616 assert!(
1617 ignore.lines().any(|line| line.trim() == "*"),
1618 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
1619 );
1620 push_execution_policy(policy);
1623 assert!(
1624 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
1625 "writes under the workspace-local temp dir must be in sandbox scope"
1626 );
1627 pop_execution_policy();
1628 }
1629
1630 #[test]
1631 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
1632 let policy = CapabilityPolicy {
1635 sandbox_profile: SandboxProfile::Unrestricted,
1636 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
1637 ..CapabilityPolicy::default()
1638 };
1639 let mut env = Vec::new();
1640 inject_workspace_tmpdir(&mut env, &policy);
1641 assert!(
1642 env.is_empty(),
1643 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
1644 );
1645 }
1646
1647 #[test]
1648 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
1649 let workspace = tempfile::tempdir().unwrap();
1650 let policy = CapabilityPolicy {
1651 sandbox_profile: SandboxProfile::Worktree,
1652 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1653 ..CapabilityPolicy::default()
1654 };
1655 let mut env = Vec::new();
1656 inject_workspace_tmpdir(&mut env, &policy);
1657
1658 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
1659 let expected = workspace_local_tmpdir(&policy)
1660 .unwrap()
1661 .display()
1662 .to_string();
1663 for key in TMPDIR_ENV_KEYS {
1664 assert_eq!(
1665 collected.get(key).map(String::as_str),
1666 Some(expected.as_str()),
1667 "{key} must point at the workspace-local temp dir"
1668 );
1669 }
1670 }
1671
1672 #[test]
1673 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
1674 let env: std::collections::BTreeMap<_, _> =
1675 deterministic_message_locale_env().into_iter().collect();
1676 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
1679 assert_eq!(
1681 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
1682 Some("en")
1683 );
1684 assert!(
1687 !env.contains_key("LC_ALL"),
1688 "must not force LC_ALL (would clobber UTF-8 ctype)"
1689 );
1690 assert!(!env.contains_key("LC_CTYPE"));
1691 assert!(!env.contains_key("LANG"));
1692 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
1695 }
1696
1697 #[test]
1698 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
1699 let workspace = tempfile::tempdir().unwrap();
1700 let policy = CapabilityPolicy {
1701 sandbox_profile: SandboxProfile::Worktree,
1702 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1703 ..CapabilityPolicy::default()
1704 };
1705 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
1707 inject_workspace_tmpdir(&mut env, &policy);
1708
1709 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1710 assert_eq!(
1711 collected.get("TMPDIR").map(String::as_str),
1712 Some("/caller/explicit/tmp"),
1713 "an explicit caller TMPDIR must be preserved untouched"
1714 );
1715 let expected = workspace_local_tmpdir(&policy)
1716 .unwrap()
1717 .display()
1718 .to_string();
1719 assert_eq!(
1720 collected.get("TMP").map(String::as_str),
1721 Some(expected.as_str())
1722 );
1723 assert_eq!(
1724 collected.get("TEMP").map(String::as_str),
1725 Some(expected.as_str())
1726 );
1727 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
1729 }
1730
1731 #[test]
1732 fn sandboxed_process_config_injects_workspace_tmpdir() {
1733 let workspace = tempfile::tempdir().unwrap();
1734 let policy = CapabilityPolicy {
1735 sandbox_profile: SandboxProfile::Worktree,
1736 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1737 ..CapabilityPolicy::default()
1738 };
1739 let config = ProcessCommandConfig {
1740 cwd: Some(workspace.path().to_path_buf()),
1741 ..ProcessCommandConfig::default()
1742 };
1743 let resolved = sandboxed_process_config(&config, &policy).unwrap();
1744 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1745 let expected = workspace_local_tmpdir(&policy)
1746 .unwrap()
1747 .display()
1748 .to_string();
1749 assert_eq!(
1750 env.get("TMPDIR").map(String::as_str),
1751 Some(expected.as_str()),
1752 "the command_output path must inject a workspace-local TMPDIR"
1753 );
1754 }
1755
1756 #[test]
1757 fn read_only_root_outside_workspace_allows_read_denies_write() {
1758 let workspace = tempfile::tempdir().unwrap();
1763 let read_only = tempfile::tempdir().unwrap();
1764 push_execution_policy(CapabilityPolicy {
1765 sandbox_profile: SandboxProfile::Worktree,
1766 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1767 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1768 ..CapabilityPolicy::default()
1769 });
1770
1771 let asset = read_only
1772 .path()
1773 .join("partials/agent-web-tools.harn.prompt");
1774 assert!(
1776 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1777 "read under a configured read-only root must be allowed"
1778 );
1779
1780 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1782 .expect_err("write under a read-only root must be denied");
1783 assert!(write_err.read_only, "write rejection must set read_only");
1784
1785 assert!(
1787 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1788 "delete under a read-only root must be denied"
1789 );
1790
1791 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1793
1794 let stranger = tempfile::tempdir().unwrap();
1797 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1798 .expect_err("read outside all roots must be denied");
1799 assert!(
1800 !outside_err.read_only,
1801 "out-of-scope rejection must not be flagged read_only"
1802 );
1803
1804 pop_execution_policy();
1805 }
1806
1807 #[cfg(unix)]
1808 #[test]
1809 fn standard_io_device_files_allowed_under_restricted_profile() {
1810 let workspace = tempfile::tempdir().unwrap();
1815 push_execution_policy(CapabilityPolicy {
1816 sandbox_profile: SandboxProfile::Worktree,
1817 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1818 ..CapabilityPolicy::default()
1819 });
1820
1821 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1822 assert!(
1823 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1824 "write to standard device {device} must be allowed"
1825 );
1826 assert!(
1828 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1829 "read of standard device {device} must be allowed"
1830 );
1831 }
1832 assert!(
1833 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1834 "read of standard device /dev/stdin must be allowed"
1835 );
1836 assert!(
1837 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1838 "write to /dev/stdin is not a standard output stream"
1839 );
1840 assert!(
1841 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1842 "standard devices must not bypass delete scoping"
1843 );
1844 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1846 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1847
1848 let stranger = tempfile::tempdir().unwrap();
1850 assert!(
1851 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1852 "a real out-of-root write must still be rejected"
1853 );
1854 assert!(
1856 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1857 "/dev/sda must not be allowed by the standard-device allowlist"
1858 );
1859 assert!(
1860 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1861 "non-numeric /dev/fd/<x> must not be allowed"
1862 );
1863
1864 pop_execution_policy();
1865 }
1866
1867 #[test]
1868 fn is_standard_io_device_matches_only_known_streams() {
1869 assert!(is_standard_io_device_for_access(
1870 Path::new("/dev/stdin"),
1871 FsAccess::Read
1872 ));
1873 assert!(!is_standard_io_device_for_access(
1874 Path::new("/dev/stdin"),
1875 FsAccess::Write
1876 ));
1877 assert!(is_standard_io_device_for_access(
1878 Path::new("/dev/stdout"),
1879 FsAccess::Write
1880 ));
1881 assert!(is_standard_io_device_for_access(
1882 Path::new("/dev/stderr"),
1883 FsAccess::Write
1884 ));
1885 assert!(is_standard_io_device_for_access(
1886 Path::new("/dev/null"),
1887 FsAccess::Write
1888 ));
1889 assert!(is_standard_io_device_for_access(
1890 Path::new("/dev/fd/0"),
1891 FsAccess::Read
1892 ));
1893 assert!(is_standard_io_device_for_access(
1894 Path::new("/dev/fd/12"),
1895 FsAccess::Write
1896 ));
1897 assert!(!is_standard_io_device_for_access(
1898 Path::new("/dev/null"),
1899 FsAccess::Delete
1900 ));
1901 assert!(!is_standard_io_device_for_access(
1902 Path::new("/dev/fd/"),
1903 FsAccess::Write
1904 ));
1905 assert!(!is_standard_io_device_for_access(
1906 Path::new("/dev/fd/1a"),
1907 FsAccess::Write
1908 ));
1909 assert!(!is_standard_io_device_for_access(
1910 Path::new("/dev/stdoutx"),
1911 FsAccess::Write
1912 ));
1913 assert!(!is_standard_io_device_for_access(
1914 Path::new("/dev/random"),
1915 FsAccess::Read
1916 ));
1917 assert!(!is_standard_io_device_for_access(
1918 Path::new("/tmp/dev/null"),
1919 FsAccess::Write
1920 ));
1921 }
1922
1923 #[test]
1924 fn path_within_root_accepts_root_and_children() {
1925 let root = Path::new("/tmp/harn-root");
1926 assert!(path_is_within(root, root));
1927 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1928 assert!(!path_is_within(
1929 Path::new("/tmp/harn-root-other/file"),
1930 root
1931 ));
1932 }
1933
1934 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1935 #[test]
1936 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1937 let temp_home = tempfile::tempdir().expect("temp home");
1938 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1939 let normalized_home = normalize_for_policy(temp_home.path());
1940
1941 for suffix in [
1942 Path::new(".cargo"),
1943 Path::new(".rustup"),
1944 Path::new(".pyenv"),
1945 Path::new(".nvm"),
1946 Path::new(".volta"),
1947 Path::new(".local/share/uv"),
1948 Path::new("go"),
1949 ] {
1950 assert!(
1951 roots.iter().any(|path| path.ends_with(suffix)),
1952 "expected a developer-toolchain grant for {}",
1953 suffix.display()
1954 );
1955 }
1956 assert!(
1957 roots.iter().all(|path| path.starts_with(&normalized_home)),
1958 "developer-toolchain roots must stay under HOME"
1959 );
1960 }
1961
1962 #[cfg(any(target_os = "linux", target_os = "macos"))]
1963 #[test]
1964 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
1965 let temp_home = tempfile::tempdir().expect("temp home");
1966 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
1967 let normalized_home = normalize_for_policy(temp_home.path());
1968
1969 for suffix in [
1970 Path::new(".gradle"),
1971 Path::new(".m2"),
1972 Path::new(".konan"),
1973 Path::new("Library/Caches/CocoaPods"),
1974 Path::new("Library/Developer/Xcode/DerivedData"),
1975 ] {
1976 assert!(
1977 roots.iter().any(|path| path.ends_with(suffix)),
1978 "expected a JVM/iOS toolchain cache grant for {}",
1979 suffix.display()
1980 );
1981 }
1982 assert!(
1983 roots.iter().all(|path| path.starts_with(&normalized_home)),
1984 "toolchain cache roots must stay under HOME"
1985 );
1986 }
1987
1988 #[cfg(any(target_os = "linux", target_os = "macos"))]
1989 #[test]
1990 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
1991 let mut policy = CapabilityPolicy {
1992 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1993 ..CapabilityPolicy::default()
1994 };
1995 if sandbox_user_home_dir().is_some() {
1998 assert!(
1999 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2000 "default presets should render JVM/iOS cache roots"
2001 );
2002 }
2003 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2005 assert!(
2006 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2007 "cache roots must be gated on the DeveloperToolchains preset"
2008 );
2009 }
2010
2011 #[test]
2012 fn os_hardened_profile_overrides_fallback_env() {
2013 assert_eq!(
2018 effective_fallback(SandboxProfile::OsHardened),
2019 SandboxFallback::Enforce
2020 );
2021 }
2022
2023 #[test]
2024 fn unrestricted_profile_skips_active_sandbox() {
2025 let policy = CapabilityPolicy {
2026 sandbox_profile: SandboxProfile::Unrestricted,
2027 workspace_roots: vec!["/tmp".to_string()],
2028 ..Default::default()
2029 };
2030 crate::orchestration::push_execution_policy(policy);
2031 let result = active_sandbox_policy();
2032 crate::orchestration::pop_execution_policy();
2033 assert!(
2034 result.is_none(),
2035 "Unrestricted profile must short-circuit sandbox dispatch"
2036 );
2037 }
2038
2039 #[test]
2040 fn worktree_profile_engages_active_sandbox() {
2041 let policy = CapabilityPolicy {
2042 sandbox_profile: SandboxProfile::Worktree,
2043 workspace_roots: vec!["/tmp".to_string()],
2044 ..Default::default()
2045 };
2046 crate::orchestration::push_execution_policy(policy);
2047 let result = active_sandbox_policy();
2048 crate::orchestration::pop_execution_policy();
2049 assert!(
2050 result.is_some(),
2051 "Worktree profile must keep sandbox dispatch active"
2052 );
2053 }
2054}