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    // Testbench replay mode short-circuits the spawn entirely. Recording
124    // mode falls through; the duration is captured by the recording
125    // handle below using the injected mock clock when one is active.
126    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}