Skip to main content

harn_vm/stdlib/
sandbox.rs

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}