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