Skip to main content

harn_vm/stdlib/sandbox/
mod.rs

1//! Process sandbox dispatch and per-platform OS confinement.
2//!
3//! The runtime exposes one stable surface — [`command_output`],
4//! [`std_command_for`], [`tokio_command_for`], plus the
5//! `enforce_*` helpers — and dispatches into a per-OS
6//! [`SandboxBackend`] selected at compile time. The backend chooses
7//! how to attach the active capability ceiling to the spawn:
8//!
9//! * **Linux** ([`linux::Backend`]): Landlock LSM filesystem scoping
10//!   plus a default-deny seccomp-bpf syscall blocklist installed via
11//!   `pre_exec`, gated behind `PR_SET_NO_NEW_PRIVS`.
12//! * **macOS** ([`macos::Backend`]): a `sandbox-exec` profile rendered
13//!   from the active capability set wraps the spawn.
14//! * **Windows** ([`windows::Backend`]): low-integrity AppContainer +
15//!   restricted token + Job Object launched directly through
16//!   `CreateProcessW`.
17//! * **OpenBSD** ([`openbsd::Backend`]): pledge/unveil applied via
18//!   `pre_exec` on top of the standard `Command` plumbing.
19//!
20//! The [`SandboxProfile`] selected by the active [`CapabilityPolicy`]
21//! controls how strictly the backend is required:
22//!
23//! * `Unrestricted` — bypass everything (path enforcement and OS
24//!   confinement).
25//! * `Worktree` — workspace path enforcement; OS confinement is
26//!   best-effort (warn-and-skip when unavailable). Honors
27//!   `HARN_HANDLER_SANDBOX={off,warn,enforce}`.
28//! * `OsHardened` — workspace path enforcement; OS confinement is
29//!   required. Spawns fail with `tool_rejected` if the platform
30//!   mechanism is unavailable, regardless of `HARN_HANDLER_SANDBOX`.
31//! * `Wasi` — testbench mode; subprocesses are intercepted by the
32//!   process tape and resolved against recorded WASI modules.
33//!
34//! Per-platform capability → kernel-knob mappings are documented in
35//! `docs/src/sandboxing.md`.
36
37use std::cell::RefCell;
38use std::collections::BTreeSet;
39use std::path::{Component, Path, PathBuf};
40use std::process::{Command, Output, Stdio};
41use std::rc::Rc;
42
43use crate::orchestration::{CapabilityPolicy, SandboxProfile};
44use crate::value::{ErrorCategory, VmError, VmValue};
45use crate::vm::Vm;
46
47#[cfg(target_os = "linux")]
48mod linux;
49#[cfg(target_os = "macos")]
50mod macos;
51#[cfg(target_os = "openbsd")]
52mod openbsd;
53#[cfg(target_os = "windows")]
54mod windows;
55
56const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
57
58thread_local! {
59    static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
60}
61
62#[derive(Clone, Copy)]
63pub(crate) enum FsAccess {
64    Read,
65    Write,
66    Delete,
67}
68
69#[derive(Clone, Debug, Default)]
70pub struct ProcessCommandConfig {
71    pub cwd: Option<PathBuf>,
72    pub env: Vec<(String, String)>,
73    pub stdin_null: bool,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub(crate) enum SandboxFallback {
78    Off,
79    Warn,
80    Enforce,
81}
82
83/// Trait implemented once per supported host OS. Each backend knows
84/// how to attach the active capability ceiling to a `Command` /
85/// `tokio::process::Command`, or — on Windows where the standard
86/// process types cannot carry an AppContainer — how to drive an
87/// equivalent custom spawn that returns an `Output`.
88///
89/// One concrete implementation is selected at compile time via `cfg`
90/// gating in this module. Callers should not reach for the trait
91/// directly; the module-level `command_output` / `std_command_for` /
92/// `tokio_command_for` entry points dispatch through it.
93pub(crate) trait SandboxBackend {
94    /// Stable identifier used in diagnostics and conformance fixtures.
95    fn name() -> &'static str;
96
97    /// Whether the platform mechanism this backend uses is available
98    /// on the running host (e.g. Landlock kernel support, the
99    /// `/usr/bin/sandbox-exec` binary, AppContainer APIs).
100    fn available() -> bool;
101
102    /// Apply the per-spawn confinement to a [`std::process::Command`].
103    /// Returns `Ok(())` if the backend can attach inline (Linux
104    /// `pre_exec`, OpenBSD pledge/unveil), or
105    /// [`PrepareOutcome::WrappedExec`] when the spawn must be
106    /// re-routed through a wrapper binary (macOS `sandbox-exec`).
107    fn prepare_std_command(
108        program: &str,
109        args: &[String],
110        command: &mut Command,
111        policy: &CapabilityPolicy,
112        profile: SandboxProfile,
113    ) -> Result<PrepareOutcome, VmError>;
114
115    /// Same as [`prepare_std_command`], but for `tokio::process::Command`.
116    fn prepare_tokio_command(
117        program: &str,
118        args: &[String],
119        command: &mut tokio::process::Command,
120        policy: &CapabilityPolicy,
121        profile: SandboxProfile,
122    ) -> Result<PrepareOutcome, VmError>;
123
124    /// Direct spawn that returns the captured `Output`. Windows uses
125    /// this because AppContainer cannot be attached to a vanilla
126    /// `Command`; other platforms can fall back to the default
127    /// implementation that builds a `Command` and runs it.
128    fn run_to_output(
129        program: &str,
130        args: &[String],
131        config: &ProcessCommandConfig,
132        policy: &CapabilityPolicy,
133        profile: SandboxProfile,
134    ) -> Result<Output, VmError> {
135        let mut command = build_std_command::<Self>(program, args, policy, profile)?;
136        apply_process_config(&mut command, config);
137        command
138            .output()
139            .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
140    }
141}
142
143/// What [`SandboxBackend::prepare_std_command`] / `_tokio_command`
144/// produced: either the original spawn target with sandboxing applied
145/// inline, or a wrapper binary that should be invoked instead.
146pub(crate) enum PrepareOutcome {
147    /// Use the prepared command unchanged.
148    Direct,
149    /// Replace the spawn target with the wrapper binary and args
150    /// (e.g. `sandbox-exec -p '<profile>' -- <program> <args...>`).
151    /// Only macOS produces this today; on other platforms the variant
152    /// stays defined so the trait surface is portable, but the
153    /// build-time dead-code lint would otherwise flip.
154    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
155    WrappedExec { wrapper: String, args: Vec<String> },
156}
157
158#[cfg(target_os = "linux")]
159type ActiveBackend = linux::Backend;
160#[cfg(target_os = "macos")]
161type ActiveBackend = macos::Backend;
162#[cfg(target_os = "openbsd")]
163type ActiveBackend = openbsd::Backend;
164#[cfg(target_os = "windows")]
165type ActiveBackend = windows::Backend;
166#[cfg(not(any(
167    target_os = "linux",
168    target_os = "macos",
169    target_os = "openbsd",
170    target_os = "windows"
171)))]
172type ActiveBackend = NoopBackend;
173
174#[cfg(not(any(
175    target_os = "linux",
176    target_os = "macos",
177    target_os = "openbsd",
178    target_os = "windows"
179)))]
180pub(crate) struct NoopBackend;
181
182#[cfg(not(any(
183    target_os = "linux",
184    target_os = "macos",
185    target_os = "openbsd",
186    target_os = "windows"
187)))]
188impl SandboxBackend for NoopBackend {
189    fn name() -> &'static str {
190        "noop"
191    }
192    fn available() -> bool {
193        false
194    }
195    fn prepare_std_command(
196        _program: &str,
197        _args: &[String],
198        _command: &mut Command,
199        _policy: &CapabilityPolicy,
200        _profile: SandboxProfile,
201    ) -> Result<PrepareOutcome, VmError> {
202        Ok(PrepareOutcome::Direct)
203    }
204    fn prepare_tokio_command(
205        _program: &str,
206        _args: &[String],
207        _command: &mut tokio::process::Command,
208        _policy: &CapabilityPolicy,
209        _profile: SandboxProfile,
210    ) -> Result<PrepareOutcome, VmError> {
211        Ok(PrepareOutcome::Direct)
212    }
213}
214
215pub(crate) fn reset_sandbox_state() {
216    WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
217}
218
219/// Stable identifier for the platform sandbox backend selected at
220/// compile time. Surfaced for diagnostics and conformance fixtures so
221/// callers can record which backend produced a recorded run.
222pub fn active_backend_name() -> &'static str {
223    ActiveBackend::name()
224}
225
226/// Whether the platform mechanism backing the active sandbox backend
227/// is available on the running host. Used by conformance fixtures and
228/// the `harn doctor` flow to skip OS-hardened checks on hosts without
229/// the required kernel support.
230pub fn active_backend_available() -> bool {
231    ActiveBackend::available()
232}
233
234/// Register Harn-callable introspection builtins for the sandbox.
235/// Intended for diagnostics, `harn doctor`, and conformance fixtures —
236/// not as a way to mutate runtime sandbox behavior from a script.
237pub fn register_sandbox_builtins(vm: &mut Vm) {
238    vm.register_builtin("sandbox_active_backend", |_args, _out| {
239        Ok(VmValue::String(Rc::from(active_backend_name())))
240    });
241    vm.register_builtin("sandbox_backend_available", |_args, _out| {
242        Ok(VmValue::Bool(active_backend_available()))
243    });
244    vm.register_builtin("sandbox_active_profile", |_args, _out| {
245        let profile = crate::orchestration::current_execution_policy()
246            .map(|policy| policy.sandbox_profile)
247            .unwrap_or(SandboxProfile::Unrestricted);
248        Ok(VmValue::String(Rc::from(profile.as_str())))
249    });
250}
251
252pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
253    let Some(policy) = crate::orchestration::current_execution_policy() else {
254        return Ok(());
255    };
256    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
257        return Ok(());
258    }
259    let candidate = normalize_for_policy(path);
260    let roots = normalized_workspace_roots(&policy);
261    if roots.iter().any(|root| path_is_within(&candidate, root)) {
262        return Ok(());
263    }
264    Err(sandbox_rejection(format!(
265        "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
266        access.verb(),
267        candidate.display(),
268        roots
269            .iter()
270            .map(|root| root.display().to_string())
271            .collect::<Vec<_>>()
272            .join(", ")
273    )))
274}
275
276pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
277    let Some(policy) = crate::orchestration::current_execution_policy() else {
278        return Ok(());
279    };
280    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
281        return Ok(());
282    }
283    let candidate = normalize_for_policy(path);
284    let roots = normalized_workspace_roots(&policy);
285    if roots.iter().any(|root| path_is_within(&candidate, root)) {
286        return Ok(());
287    }
288    Err(sandbox_rejection(format!(
289        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
290        candidate.display(),
291        roots
292            .iter()
293            .map(|root| root.display().to_string())
294            .collect::<Vec<_>>()
295            .join(", ")
296    )))
297}
298
299pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
300    let (policy, profile) = match active_sandbox_policy() {
301        Some(value) => value,
302        None => {
303            let mut command = Command::new(program);
304            command.args(args);
305            return Ok(command);
306        }
307    };
308    build_std_command::<ActiveBackend>(program, args, &policy, profile)
309}
310
311pub fn tokio_command_for(
312    program: &str,
313    args: &[String],
314) -> Result<tokio::process::Command, VmError> {
315    let (policy, profile) = match active_sandbox_policy() {
316        Some(value) => value,
317        None => {
318            let mut command = tokio::process::Command::new(program);
319            command.args(args);
320            return Ok(command);
321        }
322    };
323    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
324}
325
326pub fn command_output(
327    program: &str,
328    args: &[String],
329    config: &ProcessCommandConfig,
330) -> Result<Output, VmError> {
331    // Testbench replay mode short-circuits the spawn entirely.
332    // Recording mode falls through; the duration is captured by the
333    // recording handle below using the injected mock clock when one
334    // is active.
335    if let Some(intercepted) =
336        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
337    {
338        return intercepted.map_err(|message| {
339            VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(message)))
340        });
341    }
342
343    let recording =
344        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
345
346    let output = match active_sandbox_policy() {
347        Some((policy, profile)) => {
348            ActiveBackend::run_to_output(program, args, config, &policy, profile)?
349        }
350        None => {
351            let mut command = Command::new(program);
352            command.args(args);
353            apply_process_config(&mut command, config);
354            command.output().map_err(|error| {
355                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
356            })?
357        }
358    };
359    if let Some(error) = process_violation_error(&output) {
360        return Err(error);
361    }
362    if let Some(span) = recording {
363        span.finish(&output);
364    }
365    Ok(output)
366}
367
368fn build_std_command<B: SandboxBackend + ?Sized>(
369    program: &str,
370    args: &[String],
371    policy: &CapabilityPolicy,
372    profile: SandboxProfile,
373) -> Result<Command, VmError> {
374    let mut command = Command::new(program);
375    command.args(args);
376    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
377        PrepareOutcome::Direct => Ok(command),
378        PrepareOutcome::WrappedExec { wrapper, args } => {
379            let mut wrapped = Command::new(wrapper);
380            wrapped.args(args);
381            Ok(wrapped)
382        }
383    }
384}
385
386fn build_tokio_command<B: SandboxBackend + ?Sized>(
387    program: &str,
388    args: &[String],
389    policy: &CapabilityPolicy,
390    profile: SandboxProfile,
391) -> Result<tokio::process::Command, VmError> {
392    let mut command = tokio::process::Command::new(program);
393    command.args(args);
394    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
395        PrepareOutcome::Direct => Ok(command),
396        PrepareOutcome::WrappedExec { wrapper, args } => {
397            let mut wrapped = tokio::process::Command::new(wrapper);
398            wrapped.args(args);
399            Ok(wrapped)
400        }
401    }
402}
403
404pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
405    let policy = crate::orchestration::current_execution_policy()?;
406    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
407        return None;
408    }
409    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
410        || !ActiveBackend::available()
411    {
412        return None;
413    }
414    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
415    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
416    if !output.status.success()
417        && (stderr.contains("operation not permitted")
418            || stderr.contains("permission denied")
419            || stderr.contains("access is denied")
420            || stdout.contains("operation not permitted"))
421    {
422        return Some(sandbox_rejection(format!(
423            "sandbox violation: process was denied by the OS sandbox (status {})",
424            output.status.code().unwrap_or(-1)
425        )));
426    }
427    if sandbox_signal_status(output) {
428        return Some(sandbox_rejection(format!(
429            "sandbox violation: process was terminated by the OS sandbox (status {})",
430            output.status
431        )));
432    }
433    None
434}
435
436pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
437    let policy = crate::orchestration::current_execution_policy()?;
438    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
439        return None;
440    }
441    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
442        || !ActiveBackend::available()
443    {
444        return None;
445    }
446    let message = error.to_string().to_ascii_lowercase();
447    if error.kind() == std::io::ErrorKind::PermissionDenied
448        || message.contains("operation not permitted")
449        || message.contains("permission denied")
450        || message.contains("access is denied")
451    {
452        return Some(sandbox_rejection(format!(
453            "sandbox violation: process was denied by the OS sandbox before exec: {error}"
454        )));
455    }
456    None
457}
458
459#[cfg(unix)]
460fn sandbox_signal_status(output: &std::process::Output) -> bool {
461    use std::os::unix::process::ExitStatusExt;
462
463    matches!(
464        output.status.signal(),
465        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
466    )
467}
468
469#[cfg(not(unix))]
470fn sandbox_signal_status(_output: &std::process::Output) -> bool {
471    false
472}
473
474/// Returns the active capability policy and the resolved sandbox
475/// profile, or `None` if confinement should be skipped entirely. The
476/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
477/// hatch both produce `None`. The `Wasi` profile also produces `None`
478/// on the host spawn path — testbench mode intercepts subprocesses
479/// before they reach this layer, so the host-spawn fallback should be
480/// a normal direct exec.
481pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
482    let policy = crate::orchestration::current_execution_policy()?;
483    let profile = policy.sandbox_profile;
484    match profile {
485        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
486        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
487            if effective_fallback(profile) == SandboxFallback::Off {
488                None
489            } else {
490                Some((policy, profile))
491            }
492        }
493    }
494}
495
496fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
497    if let Some(cwd) = config.cwd.as_ref() {
498        command.current_dir(cwd);
499    }
500    command.envs(config.env.iter().map(|(key, value)| (key, value)));
501    if config.stdin_null {
502        command.stdin(Stdio::null());
503    }
504}
505
506fn spawn_error(error: std::io::Error) -> VmError {
507    VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(format!(
508        "process spawn failed: {error}"
509    ))))
510}
511
512/// Resolve the fallback policy for the requested profile. `OsHardened`
513/// always enforces — that is the entire point of the profile, so the
514/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
515/// the env var (default `warn`).
516pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
517    if matches!(profile, SandboxProfile::OsHardened) {
518        return SandboxFallback::Enforce;
519    }
520    match std::env::var(HANDLER_SANDBOX_ENV)
521        .unwrap_or_else(|_| "warn".to_string())
522        .trim()
523        .to_ascii_lowercase()
524        .as_str()
525    {
526        "0" | "false" | "off" | "none" => SandboxFallback::Off,
527        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
528        _ => SandboxFallback::Warn,
529    }
530}
531
532pub(crate) fn warn_once(key: &str, message: &str) {
533    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
534    if inserted {
535        crate::events::log_warn("handler_sandbox", message);
536    }
537}
538
539pub(crate) fn sandbox_rejection(message: String) -> VmError {
540    VmError::CategorizedError {
541        message,
542        category: ErrorCategory::ToolRejected,
543    }
544}
545
546/// Helper for backends that can't attach confinement at all (macOS
547/// without `/usr/bin/sandbox-exec`, Windows when called through the
548/// `Command`-returning entry points): either fail loudly under
549/// `OsHardened` / `enforce`, or warn once and proceed direct.
550///
551/// Linux and OpenBSD don't reach this path — they install confinement
552/// in `pre_exec` and surface unavailability through `landlock_profile`
553/// directly. The dead-code lint allow keeps the helper compilable on
554/// targets where no backend uses it.
555#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
556pub(crate) fn unavailable(
557    message: &str,
558    profile: SandboxProfile,
559) -> Result<PrepareOutcome, VmError> {
560    match effective_fallback(profile) {
561        SandboxFallback::Off | SandboxFallback::Warn => {
562            warn_once("handler_sandbox_unavailable", message);
563            Ok(PrepareOutcome::Direct)
564        }
565        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
566            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
567        ))),
568    }
569}
570
571fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
572    if policy.workspace_roots.is_empty() {
573        return vec![normalize_for_policy(
574            &crate::stdlib::process::execution_root_path(),
575        )];
576    }
577    policy
578        .workspace_roots
579        .iter()
580        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
581        .collect()
582}
583
584pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
585    normalized_workspace_roots(policy)
586}
587
588fn resolve_policy_path(path: &str) -> PathBuf {
589    let candidate = PathBuf::from(path);
590    if candidate.is_absolute() {
591        candidate
592    } else {
593        crate::stdlib::process::execution_root_path().join(candidate)
594    }
595}
596
597fn normalize_for_policy(path: &Path) -> PathBuf {
598    let absolute = if path.is_absolute() {
599        path.to_path_buf()
600    } else {
601        crate::stdlib::process::execution_root_path().join(path)
602    };
603    let absolute = normalize_lexically(&absolute);
604    if let Ok(canonical) = absolute.canonicalize() {
605        return canonical;
606    }
607
608    let mut existing = absolute.as_path();
609    let mut suffix = Vec::new();
610    while !existing.exists() {
611        let Some(parent) = existing.parent() else {
612            return normalize_lexically(&absolute);
613        };
614        if let Some(name) = existing.file_name() {
615            suffix.push(name.to_os_string());
616        }
617        existing = parent;
618    }
619
620    let mut normalized = existing
621        .canonicalize()
622        .unwrap_or_else(|_| normalize_lexically(existing));
623    for component in suffix.iter().rev() {
624        normalized.push(component);
625    }
626    normalize_lexically(&normalized)
627}
628
629fn normalize_lexically(path: &Path) -> PathBuf {
630    let mut normalized = PathBuf::new();
631    for component in path.components() {
632        match component {
633            Component::CurDir => {}
634            Component::ParentDir => {
635                normalized.pop();
636            }
637            other => normalized.push(other.as_os_str()),
638        }
639    }
640    normalized
641}
642
643fn path_is_within(path: &Path, root: &Path) -> bool {
644    path == root || path.starts_with(root)
645}
646
647#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
648pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
649    fn rank(value: &str) -> usize {
650        match value {
651            "none" => 0,
652            "read_only" => 1,
653            "workspace_write" => 2,
654            "process_exec" => 3,
655            "network" => 4,
656            _ => 5,
657        }
658    }
659    policy
660        .side_effect_level
661        .as_ref()
662        .map(|level| rank(level) >= rank("network"))
663        .unwrap_or(true)
664}
665
666#[cfg(any(target_os = "macos", target_os = "openbsd", target_os = "windows"))]
667pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
668    policy.capabilities.is_empty()
669        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
670}
671
672#[cfg(any(
673    target_os = "linux",
674    target_os = "macos",
675    target_os = "openbsd",
676    target_os = "windows"
677))]
678pub(crate) fn policy_allows_capability(
679    policy: &CapabilityPolicy,
680    capability: &str,
681    ops: &[&str],
682) -> bool {
683    policy
684        .capabilities
685        .get(capability)
686        .map(|allowed| {
687            ops.iter()
688                .any(|op| allowed.iter().any(|candidate| candidate == op))
689        })
690        .unwrap_or(false)
691}
692
693impl FsAccess {
694    fn verb(self) -> &'static str {
695        match self {
696            FsAccess::Read => "read",
697            FsAccess::Write => "write",
698            FsAccess::Delete => "delete",
699        }
700    }
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::orchestration::{pop_execution_policy, push_execution_policy};
707
708    #[test]
709    fn missing_create_path_normalizes_against_existing_parent() {
710        let dir = tempfile::tempdir().unwrap();
711        let nested = dir.path().join("a/../new.txt");
712        let normalized = normalize_for_policy(&nested);
713        assert_eq!(
714            normalized,
715            normalize_for_policy(&dir.path().join("new.txt"))
716        );
717    }
718
719    #[test]
720    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
721        let dir = tempfile::tempdir().unwrap();
722        crate::stdlib::process::set_thread_execution_context(Some(
723            crate::orchestration::RunExecutionRecord {
724                cwd: Some(dir.path().to_string_lossy().into_owned()),
725                source_dir: None,
726                env: Default::default(),
727                adapter: None,
728                repo_path: None,
729                worktree_path: None,
730                branch: None,
731                base_ref: None,
732                cleanup: None,
733            },
734        ));
735        push_execution_policy(CapabilityPolicy {
736            sandbox_profile: SandboxProfile::Worktree,
737            ..CapabilityPolicy::default()
738        });
739
740        assert!(
741            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
742        );
743        let outside = tempfile::tempdir().unwrap();
744        assert!(enforce_fs_path(
745            "read_file",
746            &outside.path().join("outside.txt"),
747            FsAccess::Read
748        )
749        .is_err());
750
751        pop_execution_policy();
752        crate::stdlib::process::set_thread_execution_context(None);
753    }
754
755    #[test]
756    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
757        let dir = tempfile::tempdir().unwrap();
758        crate::stdlib::process::set_thread_execution_context(Some(
759            crate::orchestration::RunExecutionRecord {
760                cwd: Some(dir.path().to_string_lossy().into_owned()),
761                source_dir: None,
762                env: Default::default(),
763                adapter: None,
764                repo_path: None,
765                worktree_path: None,
766                branch: None,
767                base_ref: None,
768                cleanup: None,
769            },
770        ));
771        push_execution_policy(CapabilityPolicy {
772            sandbox_profile: SandboxProfile::Worktree,
773            ..CapabilityPolicy::default()
774        });
775
776        assert!(enforce_process_cwd(dir.path()).is_ok());
777        let outside = tempfile::tempdir().unwrap();
778        assert!(enforce_process_cwd(outside.path()).is_err());
779
780        pop_execution_policy();
781        crate::stdlib::process::set_thread_execution_context(None);
782    }
783
784    #[test]
785    fn path_within_root_accepts_root_and_children() {
786        let root = Path::new("/tmp/harn-root");
787        assert!(path_is_within(root, root));
788        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
789        assert!(!path_is_within(
790            Path::new("/tmp/harn-root-other/file"),
791            root
792        ));
793    }
794
795    #[test]
796    fn os_hardened_profile_overrides_fallback_env() {
797        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
798        // point of the profile is that the OS sandbox is required.
799        // We cannot mutate the env here without races, so just check
800        // the pure resolution function.
801        assert_eq!(
802            effective_fallback(SandboxProfile::OsHardened),
803            SandboxFallback::Enforce
804        );
805    }
806
807    #[test]
808    fn unrestricted_profile_skips_active_sandbox() {
809        let policy = CapabilityPolicy {
810            sandbox_profile: SandboxProfile::Unrestricted,
811            workspace_roots: vec!["/tmp".to_string()],
812            ..Default::default()
813        };
814        crate::orchestration::push_execution_policy(policy);
815        let result = active_sandbox_policy();
816        crate::orchestration::pop_execution_policy();
817        assert!(
818            result.is_none(),
819            "Unrestricted profile must short-circuit sandbox dispatch"
820        );
821    }
822
823    #[test]
824    fn worktree_profile_engages_active_sandbox() {
825        let policy = CapabilityPolicy {
826            sandbox_profile: SandboxProfile::Worktree,
827            workspace_roots: vec!["/tmp".to_string()],
828            ..Default::default()
829        };
830        crate::orchestration::push_execution_policy(policy);
831        let result = active_sandbox_policy();
832        crate::orchestration::pop_execution_policy();
833        assert!(
834            result.is_some(),
835            "Worktree profile must keep sandbox dispatch active"
836        );
837    }
838}