1use std::cell::RefCell;
2use std::collections::BTreeSet;
3use std::path::{Component, Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5
6use crate::orchestration::CapabilityPolicy;
7use crate::value::{ErrorCategory, VmError};
8
9#[cfg(any(target_os = "linux", target_os = "openbsd"))]
10use std::io;
11#[cfg(target_os = "linux")]
12use std::os::fd::AsRawFd;
13#[cfg(any(target_os = "linux", target_os = "openbsd"))]
14use std::os::unix::process::CommandExt;
15
16#[cfg(target_os = "windows")]
17#[path = "sandbox/windows.rs"]
18mod windows;
19
20const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
21
22thread_local! {
23 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
24}
25
26#[derive(Clone, Copy)]
27pub(crate) enum FsAccess {
28 Read,
29 Write,
30 Delete,
31}
32
33#[derive(Clone, Debug, Default)]
34pub struct ProcessCommandConfig {
35 pub cwd: Option<PathBuf>,
36 pub env: Vec<(String, String)>,
37 pub stdin_null: bool,
38}
39
40#[derive(Clone, Copy, PartialEq, Eq)]
41enum SandboxFallback {
42 Off,
43 Warn,
44 Enforce,
45}
46
47pub(crate) fn reset_sandbox_state() {
48 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
49}
50
51pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
52 let Some(policy) = crate::orchestration::current_execution_policy() else {
53 return Ok(());
54 };
55 if policy.workspace_roots.is_empty() {
56 return Ok(());
57 }
58 let candidate = normalize_for_policy(path);
59 let roots = normalized_workspace_roots(&policy);
60 if roots.iter().any(|root| path_is_within(&candidate, root)) {
61 return Ok(());
62 }
63 Err(sandbox_rejection(format!(
64 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
65 access.verb(),
66 candidate.display(),
67 roots
68 .iter()
69 .map(|root| root.display().to_string())
70 .collect::<Vec<_>>()
71 .join(", ")
72 )))
73}
74
75pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
76 let Some(policy) = crate::orchestration::current_execution_policy() else {
77 return Ok(());
78 };
79 if policy.workspace_roots.is_empty() {
80 return Ok(());
81 }
82 let candidate = normalize_for_policy(path);
83 let roots = normalized_workspace_roots(&policy);
84 if roots.iter().any(|root| path_is_within(&candidate, root)) {
85 return Ok(());
86 }
87 Err(sandbox_rejection(format!(
88 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
89 candidate.display(),
90 roots
91 .iter()
92 .map(|root| root.display().to_string())
93 .collect::<Vec<_>>()
94 .join(", ")
95 )))
96}
97
98pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
99 let policy = active_sandbox_policy();
100 match command_wrapper(program, args, policy.as_ref())? {
101 CommandWrapper::Direct => {
102 let mut command = Command::new(program);
103 command.args(args);
104 if let Some(policy) = policy {
105 platform_configure_std_command(&mut command, &policy)?;
106 }
107 Ok(command)
108 }
109 #[cfg(target_os = "macos")]
110 CommandWrapper::Sandboxed { wrapper, args } => {
111 let mut command = Command::new(wrapper);
112 command.args(args);
113 Ok(command)
114 }
115 }
116}
117
118pub fn command_output(
119 program: &str,
120 args: &[String],
121 config: &ProcessCommandConfig,
122) -> Result<Output, VmError> {
123 if let Some(intercepted) =
127 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
128 {
129 return intercepted.map_err(|message| {
130 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(message)))
131 });
132 }
133
134 let recording =
135 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
136
137 #[cfg(target_os = "windows")]
138 {
139 if let Some(policy) = active_sandbox_policy() {
140 let output = windows::sandboxed_output(program, args, config, &policy)
141 .map_err(|error| windows_process_error("process sandbox failed", error))?;
142 if let Some(error) = process_violation_error(&output) {
143 return Err(error);
144 }
145 if let Some(span) = recording {
146 span.finish(&output);
147 }
148 return Ok(output);
149 }
150 }
151
152 let mut command = std_command_for(program, args)?;
153 apply_process_config(&mut command, config);
154 let output = command
155 .output()
156 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))?;
157 if let Some(error) = process_violation_error(&output) {
158 return Err(error);
159 }
160 if let Some(span) = recording {
161 span.finish(&output);
162 }
163 Ok(output)
164}
165
166pub fn tokio_command_for(
167 program: &str,
168 args: &[String],
169) -> Result<tokio::process::Command, VmError> {
170 let policy = active_sandbox_policy();
171 match command_wrapper(program, args, policy.as_ref())? {
172 CommandWrapper::Direct => {
173 let mut command = tokio::process::Command::new(program);
174 command.args(args);
175 if let Some(policy) = policy {
176 platform_configure_tokio_command(&mut command, &policy)?;
177 }
178 Ok(command)
179 }
180 #[cfg(target_os = "macos")]
181 CommandWrapper::Sandboxed { wrapper, args } => {
182 let mut command = tokio::process::Command::new(wrapper);
183 command.args(args);
184 Ok(command)
185 }
186 }
187}
188
189pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
190 crate::orchestration::current_execution_policy()?;
191 if fallback_mode() == SandboxFallback::Off || !platform_sandbox_available() {
192 return None;
193 }
194 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
195 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
196 if !output.status.success()
197 && (stderr.contains("operation not permitted")
198 || stderr.contains("permission denied")
199 || stderr.contains("access is denied")
200 || stdout.contains("operation not permitted"))
201 {
202 return Some(sandbox_rejection(format!(
203 "sandbox violation: process was denied by the OS sandbox (status {})",
204 output.status.code().unwrap_or(-1)
205 )));
206 }
207 if sandbox_signal_status(output) {
208 return Some(sandbox_rejection(format!(
209 "sandbox violation: process was terminated by the OS sandbox (status {})",
210 output.status
211 )));
212 }
213 None
214}
215
216pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
217 crate::orchestration::current_execution_policy()?;
218 if fallback_mode() == SandboxFallback::Off || !platform_sandbox_available() {
219 return None;
220 }
221 let message = error.to_string().to_ascii_lowercase();
222 if error.kind() == std::io::ErrorKind::PermissionDenied
223 || message.contains("operation not permitted")
224 || message.contains("permission denied")
225 || message.contains("access is denied")
226 {
227 return Some(sandbox_rejection(format!(
228 "sandbox violation: process was denied by the OS sandbox before exec: {error}"
229 )));
230 }
231 None
232}
233
234#[cfg(unix)]
235fn sandbox_signal_status(output: &std::process::Output) -> bool {
236 use std::os::unix::process::ExitStatusExt;
237
238 matches!(
239 output.status.signal(),
240 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
241 )
242}
243
244#[cfg(not(unix))]
245fn sandbox_signal_status(_output: &std::process::Output) -> bool {
246 false
247}
248
249#[cfg(target_os = "linux")]
250fn platform_sandbox_available() -> bool {
251 linux_seccomp_available() || linux_landlock_abi_version() > 0
252}
253
254#[cfg(target_os = "macos")]
255fn platform_sandbox_available() -> bool {
256 Path::new("/usr/bin/sandbox-exec").exists()
257}
258
259#[cfg(target_os = "openbsd")]
260fn platform_sandbox_available() -> bool {
261 true
262}
263
264#[cfg(target_os = "windows")]
265fn platform_sandbox_available() -> bool {
266 true
267}
268
269#[cfg(not(any(
270 target_os = "linux",
271 target_os = "macos",
272 target_os = "openbsd",
273 target_os = "windows"
274)))]
275fn platform_sandbox_available() -> bool {
276 false
277}
278
279enum CommandWrapper {
280 Direct,
281 #[cfg(target_os = "macos")]
282 Sandboxed {
283 wrapper: String,
284 args: Vec<String>,
285 },
286}
287
288fn command_wrapper(
289 program: &str,
290 args: &[String],
291 policy: Option<&CapabilityPolicy>,
292) -> Result<CommandWrapper, VmError> {
293 let Some(policy) = policy else {
294 return Ok(CommandWrapper::Direct);
295 };
296 platform_command_wrapper(program, args, policy)
297}
298
299fn active_sandbox_policy() -> Option<CapabilityPolicy> {
300 if fallback_mode() == SandboxFallback::Off {
301 return None;
302 }
303 crate::orchestration::current_execution_policy()
304}
305
306#[cfg(any(target_os = "linux", target_os = "openbsd"))]
307fn platform_configure_std_command(
308 command: &mut Command,
309 policy: &CapabilityPolicy,
310) -> Result<(), VmError> {
311 let profile = platform_process_profile(policy)?;
312 unsafe {
313 command.pre_exec(move || platform_apply_process_profile(&profile));
314 }
315 Ok(())
316}
317
318#[cfg(any(target_os = "linux", target_os = "openbsd"))]
319fn platform_configure_tokio_command(
320 command: &mut tokio::process::Command,
321 policy: &CapabilityPolicy,
322) -> Result<(), VmError> {
323 let profile = platform_process_profile(policy)?;
324 unsafe {
325 command.pre_exec(move || platform_apply_process_profile(&profile));
326 }
327 Ok(())
328}
329
330#[cfg(not(any(target_os = "linux", target_os = "openbsd")))]
331fn platform_configure_std_command(
332 _command: &mut Command,
333 _policy: &CapabilityPolicy,
334) -> Result<(), VmError> {
335 Ok(())
336}
337
338#[cfg(not(any(target_os = "linux", target_os = "openbsd")))]
339fn platform_configure_tokio_command(
340 _command: &mut tokio::process::Command,
341 _policy: &CapabilityPolicy,
342) -> Result<(), VmError> {
343 Ok(())
344}
345
346#[cfg(target_os = "macos")]
347fn platform_command_wrapper(
348 program: &str,
349 args: &[String],
350 policy: &CapabilityPolicy,
351) -> Result<CommandWrapper, VmError> {
352 let sandbox_exec = Path::new("/usr/bin/sandbox-exec");
353 if !sandbox_exec.exists() {
354 return unavailable("macOS sandbox-exec is not available");
355 }
356 let mut wrapped_args = vec![
357 "-p".to_string(),
358 macos_sandbox_profile(policy),
359 "--".to_string(),
360 program.to_string(),
361 ];
362 wrapped_args.extend(args.iter().cloned());
363 Ok(CommandWrapper::Sandboxed {
364 wrapper: sandbox_exec.display().to_string(),
365 args: wrapped_args,
366 })
367}
368
369#[cfg(any(target_os = "linux", target_os = "openbsd"))]
370fn platform_command_wrapper(
371 _program: &str,
372 _args: &[String],
373 _policy: &CapabilityPolicy,
374) -> Result<CommandWrapper, VmError> {
375 Ok(CommandWrapper::Direct)
376}
377
378#[cfg(target_os = "windows")]
379fn platform_command_wrapper(
380 _program: &str,
381 _args: &[String],
382 _policy: &CapabilityPolicy,
383) -> Result<CommandWrapper, VmError> {
384 match fallback_mode() {
385 SandboxFallback::Off => Ok(CommandWrapper::Direct),
386 SandboxFallback::Warn => {
387 warn_once(
388 "handler_sandbox_windows_command_for",
389 "Windows process sandboxing requires command_output(); std_command_for() cannot attach an AppContainer to std::process::Command",
390 );
391 Ok(CommandWrapper::Direct)
392 }
393 SandboxFallback::Enforce => Err(sandbox_rejection(
394 "Windows process sandboxing requires command_output(); std_command_for() cannot attach an AppContainer to std::process::Command"
395 .to_string(),
396 )),
397 }
398}
399
400#[cfg(not(any(
401 target_os = "linux",
402 target_os = "macos",
403 target_os = "openbsd",
404 target_os = "windows"
405)))]
406fn platform_command_wrapper(
407 _program: &str,
408 _args: &[String],
409 _policy: &CapabilityPolicy,
410) -> Result<CommandWrapper, VmError> {
411 unavailable(&format!(
412 "handler OS sandbox is not implemented for {}",
413 std::env::consts::OS
414 ))
415}
416
417#[cfg(target_os = "macos")]
418fn macos_sandbox_profile(policy: &CapabilityPolicy) -> String {
419 let roots = process_sandbox_roots(policy);
420 let mut profile = String::from(
421 "(version 1)\n\
422 (deny default)\n\
423 (allow process*)\n\
424 (allow sysctl-read)\n\
425 (allow mach-lookup)\n\
426 (allow file-read-data (literal \"/\"))\n\
427 (allow file-write* (subpath \"/dev\"))\n",
428 );
429 for root in macos_system_read_roots() {
430 profile.push_str(&format!(
431 "(allow file-read* (subpath \"{}\"))\n",
432 sandbox_profile_escape(root)
433 ));
434 }
435 for root in &roots {
436 profile.push_str(&format!(
437 "(allow file-read* (subpath \"{}\"))\n",
438 sandbox_profile_escape(&root.display().to_string())
439 ));
440 }
441 if policy_allows_workspace_write(policy) {
442 profile.push_str(
443 "(allow file-write* (subpath \"/tmp\") (subpath \"/private/tmp\") (subpath \"/var/tmp\"))\n",
444 );
445 for root in roots {
446 profile.push_str(&format!(
447 "(allow file-write* (subpath \"{}\"))\n",
448 sandbox_profile_escape(&root.display().to_string())
449 ));
450 }
451 }
452 if policy_allows_network(policy) {
453 profile.push_str("(allow network*)\n");
454 }
455 profile
456}
457
458#[cfg(target_os = "macos")]
459fn macos_system_read_roots() -> &'static [&'static str] {
460 &[
461 "/bin",
462 "/etc",
463 "/Library",
464 "/opt/homebrew",
465 "/private/etc",
466 "/System",
467 "/usr",
468 ]
469}
470
471#[cfg(target_os = "macos")]
472fn sandbox_profile_escape(value: &str) -> String {
473 value.replace('\\', "\\\\").replace('"', "\\\"")
474}
475
476#[cfg(target_os = "linux")]
477struct PlatformProcessProfile {
478 landlock: Option<LinuxLandlockProfile>,
479 denied_syscalls: Vec<libc::c_long>,
480}
481
482#[cfg(target_os = "linux")]
483struct LinuxLandlockProfile {
484 ruleset_fd: libc::c_int,
485 rules: Vec<LinuxLandlockRule>,
486 handled_access_fs: u64,
487}
488
489#[cfg(target_os = "linux")]
490struct LinuxLandlockRule {
491 file: std::fs::File,
492 allowed_access: u64,
493}
494
495#[cfg(target_os = "linux")]
496impl Drop for LinuxLandlockProfile {
497 fn drop(&mut self) {
498 unsafe {
499 libc::close(self.ruleset_fd);
500 }
501 }
502}
503
504#[cfg(target_os = "linux")]
505fn platform_process_profile(policy: &CapabilityPolicy) -> Result<PlatformProcessProfile, VmError> {
506 Ok(PlatformProcessProfile {
507 landlock: linux_landlock_profile(policy)?,
508 denied_syscalls: linux_denied_syscalls(policy),
509 })
510}
511
512#[cfg(target_os = "linux")]
513fn platform_apply_process_profile(profile: &PlatformProcessProfile) -> io::Result<()> {
514 install_seccomp_filter(&profile.denied_syscalls)?;
515 if let Some(landlock) = &profile.landlock {
516 install_landlock_ruleset(landlock)?;
517 }
518 Ok(())
519}
520
521#[cfg(target_os = "linux")]
522fn linux_seccomp_available() -> bool {
523 true
524}
525
526#[cfg(target_os = "linux")]
527fn linux_landlock_profile(
528 policy: &CapabilityPolicy,
529) -> Result<Option<LinuxLandlockProfile>, VmError> {
530 let abi = linux_landlock_abi_version();
531 if abi == 0 {
532 match fallback_mode() {
533 SandboxFallback::Enforce => {
534 return Err(sandbox_rejection(
535 "Linux Landlock is not available; set HARN_HANDLER_SANDBOX=warn or off to run without filesystem isolation".to_string(),
536 ));
537 }
538 SandboxFallback::Warn => warn_once(
539 "handler_sandbox_linux_landlock_unavailable",
540 "Linux Landlock is not available; process filesystem isolation is disabled",
541 ),
542 SandboxFallback::Off => {}
543 }
544 return Ok(None);
545 }
546
547 let handled_access_fs = linux_landlock_handled_access(abi);
548 let ruleset_attr = LinuxLandlockRulesetAttr { handled_access_fs };
549 let ruleset_fd = unsafe {
550 libc::syscall(
551 libc::SYS_landlock_create_ruleset,
552 &ruleset_attr as *const LinuxLandlockRulesetAttr,
553 std::mem::size_of::<LinuxLandlockRulesetAttr>(),
554 0,
555 ) as libc::c_int
556 };
557 if ruleset_fd < 0 {
558 return Err(sandbox_rejection(format!(
559 "failed to create Linux Landlock ruleset: {}",
560 io::Error::last_os_error()
561 )));
562 }
563
564 let mut profile = LinuxLandlockProfile {
565 ruleset_fd,
566 rules: Vec::new(),
567 handled_access_fs,
568 };
569 for path in linux_system_read_roots() {
570 push_linux_landlock_rule(
571 &mut profile,
572 path,
573 LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE,
574 true,
575 )?;
576 }
577 let workspace_access = linux_workspace_access(policy);
578 for root in process_sandbox_roots(policy) {
579 push_linux_landlock_rule(&mut profile, root, workspace_access, false)?;
580 }
581 Ok(Some(profile))
582}
583
584#[cfg(target_os = "linux")]
585fn linux_system_read_roots() -> Vec<PathBuf> {
586 [
587 "/bin",
588 "/lib",
589 "/lib64",
590 "/usr",
591 "/etc",
592 "/nix/store",
593 "/System",
594 ]
595 .into_iter()
596 .map(PathBuf::from)
597 .collect()
598}
599
600#[cfg(target_os = "linux")]
601fn push_linux_landlock_rule(
602 profile: &mut LinuxLandlockProfile,
603 path: PathBuf,
604 allowed_access: u64,
605 optional: bool,
606) -> Result<(), VmError> {
607 let path = normalize_for_policy(&path);
608 let file = match std::fs::File::open(&path) {
609 Ok(file) => file,
610 Err(error) if optional && error.kind() == io::ErrorKind::NotFound => return Ok(()),
611 Err(error) => {
612 return Err(sandbox_rejection(format!(
613 "failed to open sandbox path '{}': {error}",
614 path.display()
615 )));
616 }
617 };
618 profile.rules.push(LinuxLandlockRule {
619 file,
620 allowed_access: allowed_access & profile.handled_access_fs,
621 });
622 Ok(())
623}
624
625#[cfg(target_os = "linux")]
626fn install_landlock_ruleset(profile: &LinuxLandlockProfile) -> io::Result<()> {
627 for rule in &profile.rules {
628 let path_beneath = LinuxLandlockPathBeneathAttr {
629 allowed_access: rule.allowed_access,
630 parent_fd: rule.file.as_raw_fd(),
631 };
632 let result = unsafe {
633 libc::syscall(
634 libc::SYS_landlock_add_rule,
635 profile.ruleset_fd,
636 LANDLOCK_RULE_PATH_BENEATH,
637 &path_beneath as *const LinuxLandlockPathBeneathAttr,
638 0,
639 )
640 };
641 if result < 0 {
642 return Err(io::Error::last_os_error());
643 }
644 }
645 unsafe {
646 if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
647 return Err(io::Error::last_os_error());
648 }
649 let result = libc::syscall(libc::SYS_landlock_restrict_self, profile.ruleset_fd, 0);
650 if result < 0 {
651 return Err(io::Error::last_os_error());
652 }
653 }
654 Ok(())
655}
656
657#[cfg(target_os = "linux")]
658fn install_seccomp_filter(denied_syscalls: &[libc::c_long]) -> io::Result<()> {
659 unsafe {
660 if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
661 return Err(io::Error::last_os_error());
662 }
663 }
664 let mut filter = Vec::with_capacity(denied_syscalls.len() * 2 + 1);
665 filter.push(bpf_stmt(
666 (libc::BPF_LD | libc::BPF_W | libc::BPF_ABS) as u16,
667 0,
668 ));
669 for syscall in denied_syscalls {
670 filter.push(bpf_jump(
671 (libc::BPF_JMP | libc::BPF_JEQ | libc::BPF_K) as u16,
672 *syscall as u32,
673 0,
674 1,
675 ));
676 filter.push(bpf_stmt(
677 (libc::BPF_RET | libc::BPF_K) as u16,
678 libc::SECCOMP_RET_ERRNO | libc::EPERM as u32,
679 ));
680 }
681 filter.push(bpf_stmt(
682 (libc::BPF_RET | libc::BPF_K) as u16,
683 libc::SECCOMP_RET_ALLOW,
684 ));
685 let mut program = libc::sock_fprog {
686 len: filter.len() as u16,
687 filter: filter.as_mut_ptr(),
688 };
689 unsafe {
690 if libc::prctl(
691 libc::PR_SET_SECCOMP,
692 libc::SECCOMP_MODE_FILTER,
693 &mut program as *mut libc::sock_fprog,
694 0,
695 0,
696 ) != 0
697 {
698 return Err(io::Error::last_os_error());
699 }
700 }
701 Ok(())
702}
703
704#[cfg(target_os = "linux")]
705fn bpf_stmt(code: u16, k: u32) -> libc::sock_filter {
706 libc::sock_filter {
707 code,
708 jt: 0,
709 jf: 0,
710 k,
711 }
712}
713
714#[cfg(target_os = "linux")]
715fn bpf_jump(code: u16, k: u32, jt: u8, jf: u8) -> libc::sock_filter {
716 libc::sock_filter { code, jt, jf, k }
717}
718
719#[cfg(target_os = "linux")]
720fn linux_denied_syscalls(policy: &CapabilityPolicy) -> Vec<libc::c_long> {
721 let mut syscalls = vec![
722 libc::SYS_bpf,
723 libc::SYS_delete_module,
724 libc::SYS_fanotify_init,
725 libc::SYS_finit_module,
726 libc::SYS_init_module,
727 libc::SYS_kexec_file_load,
728 libc::SYS_kexec_load,
729 libc::SYS_mount,
730 libc::SYS_open_by_handle_at,
731 libc::SYS_perf_event_open,
732 libc::SYS_process_vm_readv,
733 libc::SYS_process_vm_writev,
734 libc::SYS_ptrace,
735 libc::SYS_reboot,
736 libc::SYS_swapon,
737 libc::SYS_swapoff,
738 libc::SYS_umount2,
739 libc::SYS_userfaultfd,
740 ];
741 if !policy_allows_network(policy) {
742 syscalls.extend([
743 libc::SYS_accept,
744 libc::SYS_accept4,
745 libc::SYS_bind,
746 libc::SYS_connect,
747 libc::SYS_listen,
748 libc::SYS_recvfrom,
749 libc::SYS_recvmsg,
750 libc::SYS_sendmsg,
751 libc::SYS_sendto,
752 libc::SYS_socket,
753 libc::SYS_socketpair,
754 ]);
755 }
756 syscalls.sort_unstable();
757 syscalls.dedup();
758 syscalls
759}
760
761#[cfg(target_os = "linux")]
762fn linux_workspace_access(policy: &CapabilityPolicy) -> u64 {
763 let read_access =
764 LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE;
765 let write_access = LANDLOCK_ACCESS_FS_WRITE_FILE
766 | LANDLOCK_ACCESS_FS_REMOVE_DIR
767 | LANDLOCK_ACCESS_FS_REMOVE_FILE
768 | LANDLOCK_ACCESS_FS_MAKE_CHAR
769 | LANDLOCK_ACCESS_FS_MAKE_DIR
770 | LANDLOCK_ACCESS_FS_MAKE_REG
771 | LANDLOCK_ACCESS_FS_MAKE_SOCK
772 | LANDLOCK_ACCESS_FS_MAKE_FIFO
773 | LANDLOCK_ACCESS_FS_MAKE_BLOCK
774 | LANDLOCK_ACCESS_FS_MAKE_SYM
775 | LANDLOCK_ACCESS_FS_REFER
776 | LANDLOCK_ACCESS_FS_TRUNCATE;
777 if policy.capabilities.is_empty() {
778 return read_access | write_access;
779 }
780 let mut access = 0;
781 if policy_allows_capability(policy, "workspace", &["read_text", "list", "exists"]) {
782 access |= read_access;
783 }
784 if policy_allows_capability(policy, "workspace", &["write_text"]) {
785 access |= write_access;
786 }
787 if policy_allows_capability(policy, "workspace", &["delete"]) {
788 access |= LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE;
789 }
790 if access == 0 {
791 read_access
792 } else {
793 access
794 }
795}
796
797#[cfg(target_os = "linux")]
798fn linux_landlock_abi_version() -> u32 {
799 let result = unsafe {
800 libc::syscall(
801 libc::SYS_landlock_create_ruleset,
802 std::ptr::null::<libc::c_void>(),
803 0,
804 LANDLOCK_CREATE_RULESET_VERSION,
805 )
806 };
807 if result <= 0 {
808 0
809 } else {
810 result as u32
811 }
812}
813
814#[cfg(target_os = "linux")]
815fn linux_landlock_handled_access(abi: u32) -> u64 {
816 let mut access = LANDLOCK_ACCESS_FS_EXECUTE
817 | LANDLOCK_ACCESS_FS_WRITE_FILE
818 | LANDLOCK_ACCESS_FS_READ_FILE
819 | LANDLOCK_ACCESS_FS_READ_DIR
820 | LANDLOCK_ACCESS_FS_REMOVE_DIR
821 | LANDLOCK_ACCESS_FS_REMOVE_FILE
822 | LANDLOCK_ACCESS_FS_MAKE_CHAR
823 | LANDLOCK_ACCESS_FS_MAKE_DIR
824 | LANDLOCK_ACCESS_FS_MAKE_REG
825 | LANDLOCK_ACCESS_FS_MAKE_SOCK
826 | LANDLOCK_ACCESS_FS_MAKE_FIFO
827 | LANDLOCK_ACCESS_FS_MAKE_BLOCK
828 | LANDLOCK_ACCESS_FS_MAKE_SYM;
829 if abi >= 2 {
830 access |= LANDLOCK_ACCESS_FS_REFER;
831 }
832 if abi >= 3 {
833 access |= LANDLOCK_ACCESS_FS_TRUNCATE;
834 }
835 access
836}
837
838#[cfg(target_os = "linux")]
839#[repr(C)]
840struct LinuxLandlockRulesetAttr {
841 handled_access_fs: u64,
842}
843
844#[cfg(target_os = "linux")]
845#[repr(C)]
846struct LinuxLandlockPathBeneathAttr {
847 allowed_access: u64,
848 parent_fd: libc::c_int,
849}
850
851#[cfg(target_os = "linux")]
852const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1 << 0;
853#[cfg(target_os = "linux")]
854const LANDLOCK_RULE_PATH_BENEATH: libc::c_int = 1;
855#[cfg(target_os = "linux")]
856const LANDLOCK_ACCESS_FS_EXECUTE: u64 = 1 << 0;
857#[cfg(target_os = "linux")]
858const LANDLOCK_ACCESS_FS_WRITE_FILE: u64 = 1 << 1;
859#[cfg(target_os = "linux")]
860const LANDLOCK_ACCESS_FS_READ_FILE: u64 = 1 << 2;
861#[cfg(target_os = "linux")]
862const LANDLOCK_ACCESS_FS_READ_DIR: u64 = 1 << 3;
863#[cfg(target_os = "linux")]
864const LANDLOCK_ACCESS_FS_REMOVE_DIR: u64 = 1 << 4;
865#[cfg(target_os = "linux")]
866const LANDLOCK_ACCESS_FS_REMOVE_FILE: u64 = 1 << 5;
867#[cfg(target_os = "linux")]
868const LANDLOCK_ACCESS_FS_MAKE_CHAR: u64 = 1 << 6;
869#[cfg(target_os = "linux")]
870const LANDLOCK_ACCESS_FS_MAKE_DIR: u64 = 1 << 7;
871#[cfg(target_os = "linux")]
872const LANDLOCK_ACCESS_FS_MAKE_REG: u64 = 1 << 8;
873#[cfg(target_os = "linux")]
874const LANDLOCK_ACCESS_FS_MAKE_SOCK: u64 = 1 << 9;
875#[cfg(target_os = "linux")]
876const LANDLOCK_ACCESS_FS_MAKE_FIFO: u64 = 1 << 10;
877#[cfg(target_os = "linux")]
878const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u64 = 1 << 11;
879#[cfg(target_os = "linux")]
880const LANDLOCK_ACCESS_FS_MAKE_SYM: u64 = 1 << 12;
881#[cfg(target_os = "linux")]
882const LANDLOCK_ACCESS_FS_REFER: u64 = 1 << 13;
883#[cfg(target_os = "linux")]
884const LANDLOCK_ACCESS_FS_TRUNCATE: u64 = 1 << 14;
885
886#[cfg(target_os = "openbsd")]
887struct PlatformProcessProfile {
888 unveil_rules: Vec<(String, String)>,
889 promises: String,
890}
891
892#[cfg(target_os = "openbsd")]
893fn platform_process_profile(policy: &CapabilityPolicy) -> Result<PlatformProcessProfile, VmError> {
894 let workspace_permissions = if policy_allows_workspace_write(policy) {
895 "rwcx"
896 } else {
897 "rx"
898 };
899 let mut unveil_rules = vec![
900 ("/bin".to_string(), "rx".to_string()),
901 ("/usr".to_string(), "rx".to_string()),
902 ("/lib".to_string(), "rx".to_string()),
903 ("/etc".to_string(), "r".to_string()),
904 ("/dev".to_string(), "rw".to_string()),
905 ];
906 for root in process_sandbox_roots(policy) {
907 unveil_rules.push((
908 root.display().to_string(),
909 workspace_permissions.to_string(),
910 ));
911 }
912
913 let mut promises = vec!["stdio", "rpath", "proc", "exec"];
914 if policy_allows_workspace_write(policy) {
915 promises.extend(["wpath", "cpath", "dpath"]);
916 }
917 if policy_allows_network(policy) {
918 promises.extend(["inet", "dns"]);
919 }
920 Ok(PlatformProcessProfile {
921 unveil_rules,
922 promises: promises.join(" "),
923 })
924}
925
926#[cfg(target_os = "openbsd")]
927fn platform_apply_process_profile(profile: &PlatformProcessProfile) -> io::Result<()> {
928 for (path, permissions) in &profile.unveil_rules {
929 let path = std::ffi::CString::new(path.as_str())
930 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "unveil path contains NUL"))?;
931 let permissions = std::ffi::CString::new(permissions.as_str()).map_err(|_| {
932 io::Error::new(
933 io::ErrorKind::InvalidInput,
934 "unveil permissions contain NUL",
935 )
936 })?;
937 unsafe {
938 if unveil(path.as_ptr(), permissions.as_ptr()) != 0 {
939 return Err(io::Error::last_os_error());
940 }
941 }
942 }
943 unsafe {
944 if unveil(std::ptr::null(), std::ptr::null()) != 0 {
945 return Err(io::Error::last_os_error());
946 }
947 }
948 let promises = std::ffi::CString::new(profile.promises.as_str())
949 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "pledge promises contain NUL"))?;
950 unsafe {
951 if pledge(promises.as_ptr(), std::ptr::null()) != 0 {
952 return Err(io::Error::last_os_error());
953 }
954 }
955 Ok(())
956}
957
958#[cfg(target_os = "openbsd")]
959extern "C" {
960 fn pledge(promises: *const libc::c_char, execpromises: *const libc::c_char) -> libc::c_int;
961 fn unveil(path: *const libc::c_char, permissions: *const libc::c_char) -> libc::c_int;
962}
963
964#[cfg(not(any(target_os = "linux", target_os = "openbsd", target_os = "windows")))]
965fn unavailable(message: &str) -> Result<CommandWrapper, VmError> {
966 match fallback_mode() {
967 SandboxFallback::Off | SandboxFallback::Warn => {
968 warn_once("handler_sandbox_unavailable", message);
969 Ok(CommandWrapper::Direct)
970 }
971 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
972 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
973 ))),
974 }
975}
976
977fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
978 if let Some(cwd) = config.cwd.as_ref() {
979 command.current_dir(cwd);
980 }
981 command.envs(config.env.iter().map(|(key, value)| (key, value)));
982 if config.stdin_null {
983 command.stdin(Stdio::null());
984 }
985}
986
987fn spawn_error(error: std::io::Error) -> VmError {
988 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(format!(
989 "process spawn failed: {error}"
990 ))))
991}
992
993#[cfg(target_os = "windows")]
994fn windows_process_error(context: &str, error: std::io::Error) -> VmError {
995 process_spawn_error(&error).unwrap_or_else(|| sandbox_rejection(format!("{context}: {error}")))
996}
997
998fn fallback_mode() -> SandboxFallback {
999 match std::env::var(HANDLER_SANDBOX_ENV)
1000 .unwrap_or_else(|_| "warn".to_string())
1001 .trim()
1002 .to_ascii_lowercase()
1003 .as_str()
1004 {
1005 "0" | "false" | "off" | "none" => SandboxFallback::Off,
1006 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1007 _ => SandboxFallback::Warn,
1008 }
1009}
1010
1011fn warn_once(key: &str, message: &str) {
1012 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1013 if inserted {
1014 crate::events::log_warn("handler_sandbox", message);
1015 }
1016}
1017
1018fn sandbox_rejection(message: String) -> VmError {
1019 VmError::CategorizedError {
1020 message,
1021 category: ErrorCategory::ToolRejected,
1022 }
1023}
1024
1025fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1026 policy
1027 .workspace_roots
1028 .iter()
1029 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1030 .collect()
1031}
1032
1033fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1034 let roots = if policy.workspace_roots.is_empty() {
1035 vec![crate::stdlib::process::execution_root_path()]
1036 } else {
1037 normalized_workspace_roots(policy)
1038 };
1039 roots
1040 .into_iter()
1041 .map(|root| normalize_for_policy(&root))
1042 .collect()
1043}
1044
1045fn resolve_policy_path(path: &str) -> PathBuf {
1046 let candidate = PathBuf::from(path);
1047 if candidate.is_absolute() {
1048 candidate
1049 } else {
1050 crate::stdlib::process::execution_root_path().join(candidate)
1051 }
1052}
1053
1054fn normalize_for_policy(path: &Path) -> PathBuf {
1055 let absolute = if path.is_absolute() {
1056 path.to_path_buf()
1057 } else {
1058 crate::stdlib::process::execution_root_path().join(path)
1059 };
1060 let absolute = normalize_lexically(&absolute);
1061 if let Ok(canonical) = absolute.canonicalize() {
1062 return canonical;
1063 }
1064
1065 let mut existing = absolute.as_path();
1066 let mut suffix = Vec::new();
1067 while !existing.exists() {
1068 let Some(parent) = existing.parent() else {
1069 return normalize_lexically(&absolute);
1070 };
1071 if let Some(name) = existing.file_name() {
1072 suffix.push(name.to_os_string());
1073 }
1074 existing = parent;
1075 }
1076
1077 let mut normalized = existing
1078 .canonicalize()
1079 .unwrap_or_else(|_| normalize_lexically(existing));
1080 for component in suffix.iter().rev() {
1081 normalized.push(component);
1082 }
1083 normalize_lexically(&normalized)
1084}
1085
1086fn normalize_lexically(path: &Path) -> PathBuf {
1087 let mut normalized = PathBuf::new();
1088 for component in path.components() {
1089 match component {
1090 Component::CurDir => {}
1091 Component::ParentDir => {
1092 normalized.pop();
1093 }
1094 other => normalized.push(other.as_os_str()),
1095 }
1096 }
1097 normalized
1098}
1099
1100fn path_is_within(path: &Path, root: &Path) -> bool {
1101 path == root || path.starts_with(root)
1102}
1103
1104#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1105fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1106 fn rank(value: &str) -> usize {
1107 match value {
1108 "none" => 0,
1109 "read_only" => 1,
1110 "workspace_write" => 2,
1111 "process_exec" => 3,
1112 "network" => 4,
1113 _ => 5,
1114 }
1115 }
1116 policy
1117 .side_effect_level
1118 .as_ref()
1119 .map(|level| rank(level) >= rank("network"))
1120 .unwrap_or(true)
1121}
1122
1123#[cfg(any(target_os = "macos", target_os = "openbsd", target_os = "windows"))]
1124fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1125 policy.capabilities.is_empty()
1126 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1127}
1128
1129#[cfg(any(
1130 target_os = "linux",
1131 target_os = "macos",
1132 target_os = "openbsd",
1133 target_os = "windows"
1134))]
1135fn policy_allows_capability(policy: &CapabilityPolicy, capability: &str, ops: &[&str]) -> bool {
1136 policy
1137 .capabilities
1138 .get(capability)
1139 .map(|allowed| {
1140 ops.iter()
1141 .any(|op| allowed.iter().any(|candidate| candidate == op))
1142 })
1143 .unwrap_or(false)
1144}
1145
1146impl FsAccess {
1147 fn verb(self) -> &'static str {
1148 match self {
1149 FsAccess::Read => "read",
1150 FsAccess::Write => "write",
1151 FsAccess::Delete => "delete",
1152 }
1153 }
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159
1160 #[test]
1161 fn missing_create_path_normalizes_against_existing_parent() {
1162 let dir = tempfile::tempdir().unwrap();
1163 let nested = dir.path().join("a/../new.txt");
1164 let normalized = normalize_for_policy(&nested);
1165 assert_eq!(
1166 normalized,
1167 normalize_for_policy(&dir.path().join("new.txt"))
1168 );
1169 }
1170
1171 #[test]
1172 fn path_within_root_accepts_root_and_children() {
1173 let root = Path::new("/tmp/harn-root");
1174 assert!(path_is_within(root, root));
1175 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1176 assert!(!path_is_within(
1177 Path::new("/tmp/harn-root-other/file"),
1178 root
1179 ));
1180 }
1181
1182 #[cfg(target_os = "macos")]
1183 fn macos_policy_with_workspace_ops(ops: &[&str]) -> CapabilityPolicy {
1184 CapabilityPolicy {
1185 tools: Vec::new(),
1186 capabilities: std::collections::BTreeMap::from([(
1187 "workspace".to_string(),
1188 ops.iter().map(|op| op.to_string()).collect(),
1189 )]),
1190 workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1191 side_effect_level: Some("read_only".to_string()),
1192 recursion_limit: None,
1193 tool_arg_constraints: Vec::new(),
1194 tool_annotations: std::collections::BTreeMap::new(),
1195 }
1196 }
1197
1198 #[cfg(target_os = "macos")]
1199 #[test]
1200 fn macos_sandbox_profile_does_not_grant_global_file_read() {
1201 let profile = macos_sandbox_profile(&macos_policy_with_workspace_ops(&["read_text"]));
1202 assert!(
1203 !profile.contains("(allow file-read*)\n"),
1204 "profile must not grant global file reads"
1205 );
1206 assert!(
1207 profile.contains("(allow file-read-data (literal \"/\"))"),
1208 "profile should permit root-directory reads needed to exec common macOS binaries"
1209 );
1210 assert!(
1211 profile.contains("harn-workspace"),
1212 "workspace root should be included in scoped read grants: {profile}"
1213 );
1214 }
1215
1216 #[cfg(target_os = "macos")]
1217 #[test]
1218 fn macos_sandbox_profile_allows_tmp_write_only_with_workspace_write() {
1219 let read_only = macos_sandbox_profile(&macos_policy_with_workspace_ops(&["read_text"]));
1220 assert!(
1221 !read_only.contains("(subpath \"/tmp\") (subpath \"/private/tmp\")"),
1222 "read-only profile must not grant temp writes"
1223 );
1224
1225 let writable = macos_sandbox_profile(&macos_policy_with_workspace_ops(&["write_text"]));
1226 assert!(writable.contains("(subpath \"/tmp\") (subpath \"/private/tmp\")"));
1227 }
1228}