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 use crate::tool_annotations::SideEffectLevel;
1295 policy
1296 .side_effect_level
1297 .as_ref()
1298 .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
1299 .unwrap_or(true)
1300}
1301
1302#[cfg(any(
1303 target_os = "linux",
1304 target_os = "macos",
1305 target_os = "openbsd",
1306 target_os = "windows"
1307))]
1308pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1309 policy.capabilities.is_empty()
1310 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1311}
1312
1313#[cfg(any(
1314 target_os = "linux",
1315 target_os = "macos",
1316 target_os = "openbsd",
1317 target_os = "windows"
1318))]
1319pub(crate) fn policy_allows_capability(
1320 policy: &CapabilityPolicy,
1321 capability: &str,
1322 ops: &[&str],
1323) -> bool {
1324 policy
1325 .capabilities
1326 .get(capability)
1327 .map(|allowed| {
1328 ops.iter()
1329 .any(|op| allowed.iter().any(|candidate| candidate == op))
1330 })
1331 .unwrap_or(false)
1332}
1333
1334impl FsAccess {
1335 fn verb(self) -> &'static str {
1336 match self {
1337 FsAccess::Read => "read",
1338 FsAccess::Write => "write",
1339 FsAccess::Delete => "delete",
1340 }
1341 }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use crate::orchestration::{pop_execution_policy, push_execution_policy};
1348
1349 #[test]
1350 fn missing_create_path_normalizes_against_existing_parent() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let nested = dir.path().join("a/../new.txt");
1353 let normalized = normalize_for_policy(&nested);
1354 assert_eq!(
1355 normalized,
1356 normalize_for_policy(&dir.path().join("new.txt"))
1357 );
1358 }
1359
1360 #[test]
1361 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1362 let _env_lock = crate::runtime_paths::test_env_lock()
1366 .lock()
1367 .unwrap_or_else(|poisoned| poisoned.into_inner());
1368 std::env::remove_var("HARN_PROJECT_ROOT");
1369 let dir = tempfile::tempdir().unwrap();
1370 crate::stdlib::process::set_thread_execution_context(Some(
1371 crate::orchestration::RunExecutionRecord {
1372 cwd: Some(dir.path().to_string_lossy().into_owned()),
1373 source_dir: None,
1374 env: Default::default(),
1375 adapter: None,
1376 repo_path: None,
1377 worktree_path: None,
1378 branch: None,
1379 base_ref: None,
1380 cleanup: None,
1381 },
1382 ));
1383 push_execution_policy(CapabilityPolicy {
1384 sandbox_profile: SandboxProfile::Worktree,
1385 ..CapabilityPolicy::default()
1386 });
1387
1388 assert!(
1389 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1390 );
1391 let outside = tempfile::tempdir().unwrap();
1392 assert!(enforce_fs_path(
1393 "read_file",
1394 &outside.path().join("outside.txt"),
1395 FsAccess::Read
1396 )
1397 .is_err());
1398
1399 pop_execution_policy();
1400 crate::stdlib::process::set_thread_execution_context(None);
1401 }
1402
1403 #[test]
1413 fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
1414 let _env_lock = crate::runtime_paths::test_env_lock()
1415 .lock()
1416 .unwrap_or_else(|poisoned| poisoned.into_inner());
1417 let project = tempfile::tempdir().unwrap();
1418 let execution_cwd = tempfile::tempdir().unwrap();
1419 std::env::set_var("HARN_PROJECT_ROOT", project.path());
1420 crate::stdlib::process::set_thread_execution_context(Some(
1421 crate::orchestration::RunExecutionRecord {
1422 cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
1423 source_dir: None,
1424 env: Default::default(),
1425 adapter: None,
1426 repo_path: None,
1427 worktree_path: None,
1428 branch: None,
1429 base_ref: None,
1430 cleanup: None,
1431 },
1432 ));
1433 push_execution_policy(CapabilityPolicy {
1434 sandbox_profile: SandboxProfile::Worktree,
1435 ..CapabilityPolicy::default()
1436 });
1437
1438 assert!(
1441 enforce_fs_path(
1442 "write_file",
1443 &project.path().join("test/created.ts"),
1444 FsAccess::Write,
1445 )
1446 .is_ok(),
1447 "write into HARN_PROJECT_ROOT must be allowed"
1448 );
1449 assert!(
1453 enforce_fs_path(
1454 "write_file",
1455 &execution_cwd.path().join("escape.ts"),
1456 FsAccess::Write,
1457 )
1458 .is_err(),
1459 "write under the execution cwd (outside the project) must be rejected"
1460 );
1461
1462 pop_execution_policy();
1463 crate::stdlib::process::set_thread_execution_context(None);
1464 std::env::remove_var("HARN_PROJECT_ROOT");
1465 }
1466
1467 #[test]
1468 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1469 let dir = tempfile::tempdir().unwrap();
1470 crate::stdlib::process::set_thread_execution_context(Some(
1471 crate::orchestration::RunExecutionRecord {
1472 cwd: Some(dir.path().to_string_lossy().into_owned()),
1473 source_dir: None,
1474 env: Default::default(),
1475 adapter: None,
1476 repo_path: None,
1477 worktree_path: None,
1478 branch: None,
1479 base_ref: None,
1480 cleanup: None,
1481 },
1482 ));
1483 push_execution_policy(CapabilityPolicy {
1484 sandbox_profile: SandboxProfile::Worktree,
1485 ..CapabilityPolicy::default()
1486 });
1487
1488 assert!(enforce_process_cwd(dir.path()).is_ok());
1489 let outside = tempfile::tempdir().unwrap();
1490 assert!(enforce_process_cwd(outside.path()).is_err());
1491
1492 pop_execution_policy();
1493 crate::stdlib::process::set_thread_execution_context(None);
1494 }
1495
1496 #[test]
1497 fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1498 let cwd = std::env::current_dir().unwrap();
1499 let policy = CapabilityPolicy {
1500 sandbox_profile: SandboxProfile::Worktree,
1501 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1502 ..CapabilityPolicy::default()
1503 };
1504
1505 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1506
1507 assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1508 }
1509
1510 #[test]
1511 fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1512 let workspace = tempfile::tempdir().unwrap();
1513 let policy = CapabilityPolicy {
1514 sandbox_profile: SandboxProfile::Worktree,
1515 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1516 ..CapabilityPolicy::default()
1517 };
1518
1519 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1520
1521 assert_eq!(
1522 resolved.cwd.unwrap(),
1523 normalize_for_policy(workspace.path())
1524 );
1525 }
1526
1527 #[test]
1528 fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1529 let workspace = tempfile::tempdir().unwrap();
1530 let outside = tempfile::tempdir().unwrap();
1531 let policy = CapabilityPolicy {
1532 sandbox_profile: SandboxProfile::Worktree,
1533 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1534 ..CapabilityPolicy::default()
1535 };
1536 let config = ProcessCommandConfig {
1537 cwd: Some(outside.path().to_path_buf()),
1538 ..ProcessCommandConfig::default()
1539 };
1540
1541 assert!(sandboxed_process_config(&config, &policy).is_err());
1542 }
1543
1544 #[test]
1545 fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1546 let cwd = std::env::current_dir().unwrap();
1547 let policy = CapabilityPolicy {
1548 sandbox_profile: SandboxProfile::Worktree,
1549 workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1550 ..CapabilityPolicy::default()
1551 };
1552
1553 let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1556 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1557 assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1558 assert_eq!(
1559 env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1560 Some("")
1561 );
1562 }
1563
1564 #[test]
1565 fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1566 let mut env = vec![
1569 ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1570 ("PATH".to_string(), "/usr/bin".to_string()),
1571 ];
1572 neutralize_rustc_wrapper(&mut env);
1573 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1574 assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1575 assert_eq!(
1576 collected
1577 .get("CARGO_BUILD_RUSTC_WRAPPER")
1578 .map(String::as_str),
1579 Some("")
1580 );
1581 assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1582 assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1584 }
1585
1586 #[test]
1587 fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
1588 let workspace = tempfile::tempdir().unwrap();
1589 let policy = CapabilityPolicy {
1590 sandbox_profile: SandboxProfile::Worktree,
1591 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1592 ..CapabilityPolicy::default()
1593 };
1594
1595 let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
1596
1597 assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
1600 assert!(
1601 path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
1602 "temp dir {tmpdir:?} must be inside the writable workspace root"
1603 );
1604 assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
1605 let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
1607 assert!(
1608 ignore.lines().any(|line| line.trim() == "*"),
1609 "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
1610 );
1611 push_execution_policy(policy);
1614 assert!(
1615 check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
1616 "writes under the workspace-local temp dir must be in sandbox scope"
1617 );
1618 pop_execution_policy();
1619 }
1620
1621 #[test]
1622 fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
1623 let policy = CapabilityPolicy {
1626 sandbox_profile: SandboxProfile::Unrestricted,
1627 workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
1628 ..CapabilityPolicy::default()
1629 };
1630 let mut env = Vec::new();
1631 inject_workspace_tmpdir(&mut env, &policy);
1632 assert!(
1633 env.is_empty(),
1634 "unrestricted profile must not inject a TMPDIR override, got {env:?}"
1635 );
1636 }
1637
1638 #[test]
1639 fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
1640 let workspace = tempfile::tempdir().unwrap();
1641 let policy = CapabilityPolicy {
1642 sandbox_profile: SandboxProfile::Worktree,
1643 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1644 ..CapabilityPolicy::default()
1645 };
1646 let mut env = Vec::new();
1647 inject_workspace_tmpdir(&mut env, &policy);
1648
1649 let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
1650 let expected = workspace_local_tmpdir(&policy)
1651 .unwrap()
1652 .display()
1653 .to_string();
1654 for key in TMPDIR_ENV_KEYS {
1655 assert_eq!(
1656 collected.get(key).map(String::as_str),
1657 Some(expected.as_str()),
1658 "{key} must point at the workspace-local temp dir"
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
1665 let env: std::collections::BTreeMap<_, _> =
1666 deterministic_message_locale_env().into_iter().collect();
1667 assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
1670 assert_eq!(
1672 env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
1673 Some("en")
1674 );
1675 assert!(
1678 !env.contains_key("LC_ALL"),
1679 "must not force LC_ALL (would clobber UTF-8 ctype)"
1680 );
1681 assert!(!env.contains_key("LC_CTYPE"));
1682 assert!(!env.contains_key("LANG"));
1683 assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
1686 }
1687
1688 #[test]
1689 fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
1690 let workspace = tempfile::tempdir().unwrap();
1691 let policy = CapabilityPolicy {
1692 sandbox_profile: SandboxProfile::Worktree,
1693 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1694 ..CapabilityPolicy::default()
1695 };
1696 let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
1698 inject_workspace_tmpdir(&mut env, &policy);
1699
1700 let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1701 assert_eq!(
1702 collected.get("TMPDIR").map(String::as_str),
1703 Some("/caller/explicit/tmp"),
1704 "an explicit caller TMPDIR must be preserved untouched"
1705 );
1706 let expected = workspace_local_tmpdir(&policy)
1707 .unwrap()
1708 .display()
1709 .to_string();
1710 assert_eq!(
1711 collected.get("TMP").map(String::as_str),
1712 Some(expected.as_str())
1713 );
1714 assert_eq!(
1715 collected.get("TEMP").map(String::as_str),
1716 Some(expected.as_str())
1717 );
1718 assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
1720 }
1721
1722 #[test]
1723 fn sandboxed_process_config_injects_workspace_tmpdir() {
1724 let workspace = tempfile::tempdir().unwrap();
1725 let policy = CapabilityPolicy {
1726 sandbox_profile: SandboxProfile::Worktree,
1727 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1728 ..CapabilityPolicy::default()
1729 };
1730 let config = ProcessCommandConfig {
1731 cwd: Some(workspace.path().to_path_buf()),
1732 ..ProcessCommandConfig::default()
1733 };
1734 let resolved = sandboxed_process_config(&config, &policy).unwrap();
1735 let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1736 let expected = workspace_local_tmpdir(&policy)
1737 .unwrap()
1738 .display()
1739 .to_string();
1740 assert_eq!(
1741 env.get("TMPDIR").map(String::as_str),
1742 Some(expected.as_str()),
1743 "the command_output path must inject a workspace-local TMPDIR"
1744 );
1745 }
1746
1747 #[test]
1748 fn read_only_root_outside_workspace_allows_read_denies_write() {
1749 let workspace = tempfile::tempdir().unwrap();
1754 let read_only = tempfile::tempdir().unwrap();
1755 push_execution_policy(CapabilityPolicy {
1756 sandbox_profile: SandboxProfile::Worktree,
1757 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1758 read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1759 ..CapabilityPolicy::default()
1760 });
1761
1762 let asset = read_only
1763 .path()
1764 .join("partials/agent-web-tools.harn.prompt");
1765 assert!(
1767 check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1768 "read under a configured read-only root must be allowed"
1769 );
1770
1771 let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1773 .expect_err("write under a read-only root must be denied");
1774 assert!(write_err.read_only, "write rejection must set read_only");
1775
1776 assert!(
1778 check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1779 "delete under a read-only root must be denied"
1780 );
1781
1782 assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1784
1785 let stranger = tempfile::tempdir().unwrap();
1788 let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1789 .expect_err("read outside all roots must be denied");
1790 assert!(
1791 !outside_err.read_only,
1792 "out-of-scope rejection must not be flagged read_only"
1793 );
1794
1795 pop_execution_policy();
1796 }
1797
1798 #[cfg(unix)]
1799 #[test]
1800 fn standard_io_device_files_allowed_under_restricted_profile() {
1801 let workspace = tempfile::tempdir().unwrap();
1806 push_execution_policy(CapabilityPolicy {
1807 sandbox_profile: SandboxProfile::Worktree,
1808 workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1809 ..CapabilityPolicy::default()
1810 });
1811
1812 for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1813 assert!(
1814 check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1815 "write to standard device {device} must be allowed"
1816 );
1817 assert!(
1819 check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1820 "read of standard device {device} must be allowed"
1821 );
1822 }
1823 assert!(
1824 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1825 "read of standard device /dev/stdin must be allowed"
1826 );
1827 assert!(
1828 check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1829 "write to /dev/stdin is not a standard output stream"
1830 );
1831 assert!(
1832 check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1833 "standard devices must not bypass delete scoping"
1834 );
1835 assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1837 assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1838
1839 let stranger = tempfile::tempdir().unwrap();
1841 assert!(
1842 check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1843 "a real out-of-root write must still be rejected"
1844 );
1845 assert!(
1847 check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1848 "/dev/sda must not be allowed by the standard-device allowlist"
1849 );
1850 assert!(
1851 check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1852 "non-numeric /dev/fd/<x> must not be allowed"
1853 );
1854
1855 pop_execution_policy();
1856 }
1857
1858 #[test]
1859 fn is_standard_io_device_matches_only_known_streams() {
1860 assert!(is_standard_io_device_for_access(
1861 Path::new("/dev/stdin"),
1862 FsAccess::Read
1863 ));
1864 assert!(!is_standard_io_device_for_access(
1865 Path::new("/dev/stdin"),
1866 FsAccess::Write
1867 ));
1868 assert!(is_standard_io_device_for_access(
1869 Path::new("/dev/stdout"),
1870 FsAccess::Write
1871 ));
1872 assert!(is_standard_io_device_for_access(
1873 Path::new("/dev/stderr"),
1874 FsAccess::Write
1875 ));
1876 assert!(is_standard_io_device_for_access(
1877 Path::new("/dev/null"),
1878 FsAccess::Write
1879 ));
1880 assert!(is_standard_io_device_for_access(
1881 Path::new("/dev/fd/0"),
1882 FsAccess::Read
1883 ));
1884 assert!(is_standard_io_device_for_access(
1885 Path::new("/dev/fd/12"),
1886 FsAccess::Write
1887 ));
1888 assert!(!is_standard_io_device_for_access(
1889 Path::new("/dev/null"),
1890 FsAccess::Delete
1891 ));
1892 assert!(!is_standard_io_device_for_access(
1893 Path::new("/dev/fd/"),
1894 FsAccess::Write
1895 ));
1896 assert!(!is_standard_io_device_for_access(
1897 Path::new("/dev/fd/1a"),
1898 FsAccess::Write
1899 ));
1900 assert!(!is_standard_io_device_for_access(
1901 Path::new("/dev/stdoutx"),
1902 FsAccess::Write
1903 ));
1904 assert!(!is_standard_io_device_for_access(
1905 Path::new("/dev/random"),
1906 FsAccess::Read
1907 ));
1908 assert!(!is_standard_io_device_for_access(
1909 Path::new("/tmp/dev/null"),
1910 FsAccess::Write
1911 ));
1912 }
1913
1914 #[test]
1915 fn path_within_root_accepts_root_and_children() {
1916 let root = Path::new("/tmp/harn-root");
1917 assert!(path_is_within(root, root));
1918 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1919 assert!(!path_is_within(
1920 Path::new("/tmp/harn-root-other/file"),
1921 root
1922 ));
1923 }
1924
1925 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1926 #[test]
1927 fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1928 let temp_home = tempfile::tempdir().expect("temp home");
1929 let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1930 let normalized_home = normalize_for_policy(temp_home.path());
1931
1932 for suffix in [
1933 Path::new(".cargo"),
1934 Path::new(".rustup"),
1935 Path::new(".pyenv"),
1936 Path::new(".nvm"),
1937 Path::new(".volta"),
1938 Path::new(".local/share/uv"),
1939 Path::new("go"),
1940 ] {
1941 assert!(
1942 roots.iter().any(|path| path.ends_with(suffix)),
1943 "expected a developer-toolchain grant for {}",
1944 suffix.display()
1945 );
1946 }
1947 assert!(
1948 roots.iter().all(|path| path.starts_with(&normalized_home)),
1949 "developer-toolchain roots must stay under HOME"
1950 );
1951 }
1952
1953 #[cfg(any(target_os = "linux", target_os = "macos"))]
1954 #[test]
1955 fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
1956 let temp_home = tempfile::tempdir().expect("temp home");
1957 let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
1958 let normalized_home = normalize_for_policy(temp_home.path());
1959
1960 for suffix in [
1961 Path::new(".gradle"),
1962 Path::new(".m2"),
1963 Path::new(".konan"),
1964 Path::new("Library/Caches/CocoaPods"),
1965 Path::new("Library/Developer/Xcode/DerivedData"),
1966 ] {
1967 assert!(
1968 roots.iter().any(|path| path.ends_with(suffix)),
1969 "expected a JVM/iOS toolchain cache grant for {}",
1970 suffix.display()
1971 );
1972 }
1973 assert!(
1974 roots.iter().all(|path| path.starts_with(&normalized_home)),
1975 "toolchain cache roots must stay under HOME"
1976 );
1977 }
1978
1979 #[cfg(any(target_os = "linux", target_os = "macos"))]
1980 #[test]
1981 fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
1982 let mut policy = CapabilityPolicy {
1983 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1984 ..CapabilityPolicy::default()
1985 };
1986 if sandbox_user_home_dir().is_some() {
1989 assert!(
1990 !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
1991 "default presets should render JVM/iOS cache roots"
1992 );
1993 }
1994 policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
1996 assert!(
1997 process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
1998 "cache roots must be gated on the DeveloperToolchains preset"
1999 );
2000 }
2001
2002 #[test]
2003 fn os_hardened_profile_overrides_fallback_env() {
2004 assert_eq!(
2009 effective_fallback(SandboxProfile::OsHardened),
2010 SandboxFallback::Enforce
2011 );
2012 }
2013
2014 #[test]
2015 fn unrestricted_profile_skips_active_sandbox() {
2016 let policy = CapabilityPolicy {
2017 sandbox_profile: SandboxProfile::Unrestricted,
2018 workspace_roots: vec!["/tmp".to_string()],
2019 ..Default::default()
2020 };
2021 crate::orchestration::push_execution_policy(policy);
2022 let result = active_sandbox_policy();
2023 crate::orchestration::pop_execution_policy();
2024 assert!(
2025 result.is_none(),
2026 "Unrestricted profile must short-circuit sandbox dispatch"
2027 );
2028 }
2029
2030 #[test]
2031 fn worktree_profile_engages_active_sandbox() {
2032 let policy = CapabilityPolicy {
2033 sandbox_profile: SandboxProfile::Worktree,
2034 workspace_roots: vec!["/tmp".to_string()],
2035 ..Default::default()
2036 };
2037 crate::orchestration::push_execution_policy(policy);
2038 let result = active_sandbox_policy();
2039 crate::orchestration::pop_execution_policy();
2040 assert!(
2041 result.is_some(),
2042 "Worktree profile must keep sandbox dispatch active"
2043 );
2044 }
2045}