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