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};
41
42#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
43use crate::orchestration::ProcessSandboxPreset;
44use crate::orchestration::{CapabilityPolicy, SandboxProfile};
45use crate::value::{ErrorCategory, VmError, VmValue};
46use crate::vm::Vm;
47
48#[cfg(target_os = "linux")]
49mod linux;
50#[cfg(target_os = "macos")]
51mod macos;
52#[cfg(target_os = "openbsd")]
53mod openbsd;
54#[cfg(target_os = "windows")]
55mod windows;
56
57const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
58
59thread_local! {
60    static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
61}
62
63/// The kind of filesystem access a path-scope check is guarding. This drives
64/// the verb rendered in rejection messages and the narrow standard-device
65/// exception; ordinary files are otherwise scoped by the same workspace roots.
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(std::sync::Arc::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(std::sync::Arc::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 writable workspace roots the path was checked against,
296    /// normalized the 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    /// True when the path resolved *inside* a read-only root: it is in
301    /// scope for reads, and only the attempted mutation is denied. False
302    /// when the path fell outside every configured root entirely.
303    pub read_only: bool,
304}
305
306impl SandboxViolation {
307    /// Render the canonical rejection message. Matches the text produced
308    /// by [`enforce_fs_path`] so the `harness.fs.*` and hostlib surfaces
309    /// reject an out-of-root path identically.
310    pub fn message(&self, builtin: &str) -> String {
311        if self.read_only {
312            return format!(
313                "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
314                self.access.verb(),
315                self.attempted.display(),
316            );
317        }
318        format!(
319            "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
320            self.access.verb(),
321            self.attempted.display(),
322            self.roots
323                .iter()
324                .map(|root| root.display().to_string())
325                .collect::<Vec<_>>()
326                .join(", ")
327        )
328    }
329}
330
331/// Check whether `path` is inside the active policy's workspace roots.
332///
333/// Returns `Ok(())` when no execution policy is active, when the active
334/// profile is [`SandboxProfile::Unrestricted`], when the normalized path
335/// falls within a writable workspace root, or — for [`FsAccess::Read`]
336/// only — when it falls within a read-only root. A write/delete that
337/// resolves under a read-only root is rejected with `read_only` set, as
338/// is any access that falls outside every configured root.
339///
340/// This is the public, `VmError`-free entry point embedders use to apply
341/// workspace-root scoping to their own host calls. The in-crate
342/// `harness.fs.*` builtins funnel through [`enforce_fs_path`], which wraps
343/// this with a `VmError`; both share the same path normalization and
344/// rejection text.
345pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
346    let Some(policy) = crate::orchestration::current_execution_policy() else {
347        return Ok(());
348    };
349    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
350        return Ok(());
351    }
352    // Standard process I/O device files are not workspace filesystem
353    // mutations: writing to /dev/stdout, /dev/stderr, or /dev/null (and the
354    // numeric /dev/fd/<N> descriptors they alias) targets the process's own
355    // output streams, not the sandboxed tree. A pipeline that falls back to
356    // /dev/stdout for debug output must not read as a sandbox violation, so
357    // allow these regardless of the configured roots. Matched on the
358    // lexically-normalized path (not the canonicalized form): canonicalize()
359    // rewrites /dev/stdout to a per-process /dev/fd/<…>.output alias that no
360    // longer looks like a standard device. Kept deliberately narrow — only
361    // the well-known device files, no broader /dev access.
362    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
363        return Ok(());
364    }
365    let candidate = normalize_for_policy(path);
366    let roots = normalized_workspace_roots(&policy);
367    if roots.iter().any(|root| path_is_within(&candidate, root)) {
368        return Ok(());
369    }
370    let read_only_roots = normalized_read_only_roots(&policy);
371    let within_read_only = read_only_roots
372        .iter()
373        .any(|root| path_is_within(&candidate, root));
374    if within_read_only && access == FsAccess::Read {
375        return Ok(());
376    }
377    Err(SandboxViolation {
378        attempted: candidate,
379        roots,
380        access,
381        read_only: within_read_only,
382    })
383}
384
385pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
386    check_fs_path_scope(path, access)
387        .map_err(|violation| sandbox_rejection(violation.message(builtin)))
388}
389
390pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
391    let Some(policy) = crate::orchestration::current_execution_policy() else {
392        return Ok(());
393    };
394    enforce_process_cwd_for_policy(path, &policy)
395}
396
397fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
398    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
399        return Ok(());
400    }
401    let candidate = normalize_for_policy(path);
402    let roots = normalized_workspace_roots(policy);
403    if roots.iter().any(|root| path_is_within(&candidate, root)) {
404        return Ok(());
405    }
406    Err(sandbox_rejection(format!(
407        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
408        candidate.display(),
409        roots
410            .iter()
411            .map(|root| root.display().to_string())
412            .collect::<Vec<_>>()
413            .join(", ")
414    )))
415}
416
417pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
418    let (policy, profile) = match active_sandbox_policy() {
419        Some(value) => value,
420        None => {
421            let mut command = Command::new(program);
422            command.args(args);
423            return Ok(command);
424        }
425    };
426    build_std_command::<ActiveBackend>(program, args, &policy, profile)
427}
428
429pub fn tokio_command_for(
430    program: &str,
431    args: &[String],
432) -> Result<tokio::process::Command, VmError> {
433    let (policy, profile) = match active_sandbox_policy() {
434        Some(value) => value,
435        None => {
436            let mut command = tokio::process::Command::new(program);
437            command.args(args);
438            return Ok(command);
439        }
440    };
441    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
442}
443
444pub fn command_output(
445    program: &str,
446    args: &[String],
447    config: &ProcessCommandConfig,
448) -> Result<Output, VmError> {
449    // Testbench replay mode short-circuits the spawn entirely.
450    // Recording mode falls through; the duration is captured by the
451    // recording handle below using the injected mock clock when one
452    // is active.
453    if let Some(intercepted) =
454        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
455    {
456        return intercepted.map_err(|message| {
457            VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(message)))
458        });
459    }
460
461    let recording =
462        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
463
464    let output = match active_sandbox_policy() {
465        Some((policy, profile)) => {
466            let config = sandboxed_process_config(config, &policy)?;
467            ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
468        }
469        None => {
470            let mut command = Command::new(program);
471            command.args(args);
472            apply_process_config(&mut command, config);
473            command.output().map_err(|error| {
474                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
475            })?
476        }
477    };
478    if let Some(error) = process_violation_error(&output) {
479        return Err(error);
480    }
481    if let Some(span) = recording {
482        span.finish(&output);
483    }
484    Ok(output)
485}
486
487fn sandboxed_process_config(
488    config: &ProcessCommandConfig,
489    policy: &CapabilityPolicy,
490) -> Result<ProcessCommandConfig, VmError> {
491    let mut resolved = config.clone();
492    if let Some(cwd) = resolved.cwd.as_ref() {
493        enforce_process_cwd_for_policy(cwd, policy)?;
494    } else {
495        resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
496    }
497    neutralize_rustc_wrapper(&mut resolved.env);
498    Ok(resolved)
499}
500
501/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
502///
503/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
504/// cargo build is the first caller to spawn it, the daemon inherits the
505/// `sandbox-exec` confinement permanently — even after it reparents to
506/// launchd — and then fails *every* later build machine-wide with
507/// `Operation not permitted` (it can no longer read build inputs outside the
508/// sandbox root nor write its cache dir under `~/Library/Caches`). A
509/// per-command sandbox must never be allowed to poison a cross-workspace
510/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
511/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
512/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
513/// on-disk cache and all unsandboxed builds are unaffected.
514fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
515    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
516        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
517            entry.1.clear();
518        } else {
519            env.push((key.to_string(), String::new()));
520        }
521    }
522}
523
524fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
525    let roots = normalized_workspace_roots(policy);
526    let current = std::env::current_dir().map_err(|error| {
527        VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
528            format!("process cwd resolution failed: {error}"),
529        )))
530    })?;
531    let current = normalize_for_policy(&current);
532    if roots.iter().any(|root| path_is_within(&current, root)) {
533        return Ok(current);
534    }
535    roots.first().cloned().ok_or_else(|| {
536        VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
537            "process cwd resolution failed: no workspace root available",
538        )))
539    })
540}
541
542fn build_std_command<B: SandboxBackend + ?Sized>(
543    program: &str,
544    args: &[String],
545    policy: &CapabilityPolicy,
546    profile: SandboxProfile,
547) -> Result<Command, VmError> {
548    let mut command = Command::new(program);
549    command.args(args);
550    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
551        PrepareOutcome::Direct => Ok(command),
552        PrepareOutcome::WrappedExec { wrapper, args } => {
553            let mut wrapped = Command::new(wrapper);
554            wrapped.args(args);
555            Ok(wrapped)
556        }
557    }
558}
559
560fn build_tokio_command<B: SandboxBackend + ?Sized>(
561    program: &str,
562    args: &[String],
563    policy: &CapabilityPolicy,
564    profile: SandboxProfile,
565) -> Result<tokio::process::Command, VmError> {
566    let mut command = tokio::process::Command::new(program);
567    command.args(args);
568    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
569        PrepareOutcome::Direct => Ok(command),
570        PrepareOutcome::WrappedExec { wrapper, args } => {
571            let mut wrapped = tokio::process::Command::new(wrapper);
572            wrapped.args(args);
573            Ok(wrapped)
574        }
575    }
576}
577
578pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
579    let policy = crate::orchestration::current_execution_policy()?;
580    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
581        return None;
582    }
583    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
584        || !ActiveBackend::available()
585    {
586        return None;
587    }
588    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
589    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
590    if !output.status.success()
591        && (stderr.contains("operation not permitted")
592            || stderr.contains("permission denied")
593            || stderr.contains("access is denied")
594            || stdout.contains("operation not permitted"))
595    {
596        return Some(sandbox_rejection(sandbox_process_violation_message(
597            format!(
598                "sandbox violation: process was denied by the OS sandbox (status {})",
599                output.status.code().unwrap_or(-1)
600            ),
601        )));
602    }
603    if sandbox_signal_status(output) {
604        return Some(sandbox_rejection(sandbox_process_violation_message(
605            format!(
606                "sandbox violation: process was terminated by the OS sandbox (status {})",
607                output.status
608            ),
609        )));
610    }
611    None
612}
613
614pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
615    let policy = crate::orchestration::current_execution_policy()?;
616    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
617        return None;
618    }
619    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
620        || !ActiveBackend::available()
621    {
622        return None;
623    }
624    let message = error.to_string().to_ascii_lowercase();
625    if error.kind() == std::io::ErrorKind::PermissionDenied
626        || message.contains("operation not permitted")
627        || message.contains("permission denied")
628        || message.contains("access is denied")
629    {
630        return Some(sandbox_rejection(sandbox_process_violation_message(
631            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
632        )));
633    }
634    None
635}
636
637#[cfg(unix)]
638fn sandbox_signal_status(output: &std::process::Output) -> bool {
639    use std::os::unix::process::ExitStatusExt;
640
641    matches!(
642        output.status.signal(),
643        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
644    )
645}
646
647#[cfg(not(unix))]
648fn sandbox_signal_status(_output: &std::process::Output) -> bool {
649    false
650}
651
652/// Returns the active capability policy and the resolved sandbox
653/// profile, or `None` if confinement should be skipped entirely. The
654/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
655/// hatch both produce `None`. The `Wasi` profile also produces `None`
656/// on the host spawn path — testbench mode intercepts subprocesses
657/// before they reach this layer, so the host-spawn fallback should be
658/// a normal direct exec.
659pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
660    let policy = crate::orchestration::current_execution_policy()?;
661    let profile = policy.sandbox_profile;
662    match profile {
663        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
664        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
665            if effective_fallback(profile) == SandboxFallback::Off {
666                None
667            } else {
668                Some((policy, profile))
669            }
670        }
671    }
672}
673
674fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
675    if let Some(cwd) = config.cwd.as_ref() {
676        command.current_dir(cwd);
677    }
678    command.envs(config.env.iter().map(|(key, value)| (key, value)));
679    if config.stdin_null {
680        command.stdin(Stdio::null());
681    }
682}
683
684fn spawn_error(error: std::io::Error) -> VmError {
685    VmError::Thrown(crate::value::VmValue::String(std::sync::Arc::from(
686        format!("process spawn failed: {error}"),
687    )))
688}
689
690/// Resolve the fallback policy for the requested profile. `OsHardened`
691/// always enforces — that is the entire point of the profile, so the
692/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
693/// the env var (default `warn`).
694pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
695    if matches!(profile, SandboxProfile::OsHardened) {
696        return SandboxFallback::Enforce;
697    }
698    match std::env::var(HANDLER_SANDBOX_ENV)
699        .unwrap_or_else(|_| "warn".to_string())
700        .trim()
701        .to_ascii_lowercase()
702        .as_str()
703    {
704        "0" | "false" | "off" | "none" => SandboxFallback::Off,
705        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
706        _ => SandboxFallback::Warn,
707    }
708}
709
710pub(crate) fn warn_once(key: &str, message: &str) {
711    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
712    if inserted {
713        crate::events::log_warn("handler_sandbox", message);
714    }
715}
716
717pub(crate) fn sandbox_rejection(message: String) -> VmError {
718    VmError::CategorizedError {
719        message,
720        category: ErrorCategory::ToolRejected,
721    }
722}
723
724fn sandbox_process_violation_message(summary: String) -> String {
725    format!(
726        "{summary}; if the command depends on a user-managed toolchain or cache outside the workspace, add that root to process_sandbox.read_roots or process_sandbox.write_roots"
727    )
728}
729
730/// Helper for backends that can't attach confinement at all (macOS
731/// without `/usr/bin/sandbox-exec`, Windows when called through the
732/// `Command`-returning entry points): either fail loudly under
733/// `OsHardened` / `enforce`, or warn once and proceed direct.
734///
735/// Linux and OpenBSD don't reach this path — they install confinement
736/// in `pre_exec` and surface unavailability through `landlock_profile`
737/// directly. The dead-code lint allow keeps the helper compilable on
738/// targets where no backend uses it.
739#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
740pub(crate) fn unavailable(
741    message: &str,
742    profile: SandboxProfile,
743) -> Result<PrepareOutcome, VmError> {
744    match effective_fallback(profile) {
745        SandboxFallback::Off | SandboxFallback::Warn => {
746            warn_once("handler_sandbox_unavailable", message);
747            Ok(PrepareOutcome::Direct)
748        }
749        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
750            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
751        ))),
752    }
753}
754
755fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
756    if policy.workspace_roots.is_empty() {
757        return vec![normalize_for_policy(
758            &crate::stdlib::process::execution_root_path(),
759        )];
760    }
761    policy
762        .workspace_roots
763        .iter()
764        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
765        .collect()
766}
767
768pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
769    normalized_workspace_roots(policy)
770}
771
772/// Normalize the policy's read-only roots. Unlike
773/// [`normalized_workspace_roots`], an empty list stays empty — read-only
774/// scope is purely additive, so there is no execution-root fallback to
775/// synthesize.
776fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
777    policy
778        .read_only_roots
779        .iter()
780        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
781        .collect()
782}
783
784#[cfg(any(
785    target_os = "linux",
786    target_os = "macos",
787    target_os = "openbsd",
788    target_os = "windows"
789))]
790pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
791    normalized_read_only_roots(policy)
792}
793
794#[cfg(any(
795    target_os = "linux",
796    target_os = "macos",
797    target_os = "openbsd",
798    target_os = "windows"
799))]
800pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
801    normalized_process_roots(&policy.process_sandbox.read_roots)
802}
803
804#[cfg(any(
805    target_os = "linux",
806    target_os = "macos",
807    target_os = "openbsd",
808    target_os = "windows"
809))]
810pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
811    normalized_process_roots(&policy.process_sandbox.write_roots)
812}
813
814#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
815pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
816    policy.process_sandbox.effective_presets()
817}
818
819#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
820pub(crate) fn process_sandbox_developer_toolchain_read_roots(
821    policy: &CapabilityPolicy,
822) -> Vec<PathBuf> {
823    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
824        return Vec::new();
825    }
826    let Some(home) = sandbox_user_home_dir() else {
827        return Vec::new();
828    };
829    developer_toolchain_read_roots_for_home(&home)
830}
831
832#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
833pub(crate) fn process_sandbox_package_manager_config_read_roots(
834    policy: &CapabilityPolicy,
835) -> Vec<PathBuf> {
836    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
837        return Vec::new();
838    }
839    let Some(home) = sandbox_user_home_dir() else {
840        return Vec::new();
841    };
842    package_manager_config_read_roots_for_home(&home)
843}
844
845#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
846fn sandbox_user_home_dir() -> Option<PathBuf> {
847    // Only an absolute home grounds the user-scope read-roots below; a
848    // relative or unset home yields no extra roots (the safe direction).
849    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
850}
851
852#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
853pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
854    let mut roots: Vec<_> = [
855        ".asdf",
856        ".bun",
857        ".cargo",
858        ".fnm",
859        ".juliaup",
860        ".local/bin",
861        ".local/share/mise",
862        ".local/share/uv",
863        ".nvm",
864        ".pyenv",
865        ".rbenv",
866        ".rustup",
867        ".sdkman",
868        ".swiftly",
869        ".volta",
870        "go",
871    ]
872    .into_iter()
873    .map(|entry| normalize_for_policy(&home.join(entry)))
874    .collect();
875    #[cfg(target_os = "windows")]
876    roots.extend(
877        [
878            "AppData/Local/Programs/Python",
879            "AppData/Local/uv",
880            "AppData/Roaming/uv",
881            "scoop",
882        ]
883        .into_iter()
884        .map(|entry| normalize_for_policy(&home.join(entry))),
885    );
886    roots.sort_unstable();
887    roots.dedup();
888    roots
889}
890
891#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
892pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
893    let mut roots: Vec<_> = [
894        ".npmrc",
895        ".gitconfig",
896        ".netrc",
897        ".yarnrc.yml",
898        ".config",
899        ".npm",
900        ".cache",
901        ".pip",
902        ".pypirc",
903        ".cargo/config",
904        ".cargo/config.toml",
905        ".cargo/credentials",
906        ".cargo/credentials.toml",
907        ".cargo/registry",
908        ".cargo/git",
909    ]
910    .into_iter()
911    .map(|entry| normalize_for_policy(&home.join(entry)))
912    .collect();
913    roots.sort_unstable();
914    roots.dedup();
915    roots
916}
917
918#[cfg(any(
919    target_os = "linux",
920    target_os = "macos",
921    target_os = "openbsd",
922    target_os = "windows"
923))]
924fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
925    roots
926        .iter()
927        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
928        .collect()
929}
930
931fn resolve_policy_path(path: &str) -> PathBuf {
932    let candidate = PathBuf::from(path);
933    if candidate.is_absolute() {
934        candidate
935    } else {
936        crate::stdlib::process::execution_root_path().join(candidate)
937    }
938}
939
940fn normalize_for_policy(path: &Path) -> PathBuf {
941    let absolute = if path.is_absolute() {
942        path.to_path_buf()
943    } else {
944        crate::stdlib::process::execution_root_path().join(path)
945    };
946    let absolute = normalize_lexically(&absolute);
947    if let Ok(canonical) = absolute.canonicalize() {
948        return canonical;
949    }
950
951    let mut existing = absolute.as_path();
952    let mut suffix = Vec::new();
953    while !existing.exists() {
954        let Some(parent) = existing.parent() else {
955            return normalize_lexically(&absolute);
956        };
957        if let Some(name) = existing.file_name() {
958            suffix.push(name.to_os_string());
959        }
960        existing = parent;
961    }
962
963    let mut normalized = existing
964        .canonicalize()
965        .unwrap_or_else(|_| normalize_lexically(existing));
966    for component in suffix.iter().rev() {
967        normalized.push(component);
968    }
969    normalize_lexically(&normalized)
970}
971
972fn normalize_lexically(path: &Path) -> PathBuf {
973    let mut normalized = PathBuf::new();
974    for component in path.components() {
975        match component {
976            Component::CurDir => {}
977            Component::ParentDir => {
978                normalized.pop();
979            }
980            other => normalized.push(other.as_os_str()),
981        }
982    }
983    normalized
984}
985
986fn path_is_within(path: &Path, root: &Path) -> bool {
987    path == root || path.starts_with(root)
988}
989
990/// Resolve `path` to an absolute, lexically-normalized form for the standard
991/// I/O device check. Unlike [`normalize_for_policy`] this never calls
992/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
993/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
994fn normalize_io_device_path(path: &Path) -> PathBuf {
995    let absolute = if path.is_absolute() {
996        path.to_path_buf()
997    } else {
998        crate::stdlib::process::execution_root_path().join(path)
999    };
1000    normalize_lexically(&absolute)
1001}
1002
1003/// Whether `path` is one of the standard process I/O device files that the
1004/// sandbox treats as a stream rather than a workspace mutation for this access:
1005/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
1006/// stream operation. `path` must already be absolute and lexically normalized.
1007fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1008    match access {
1009        FsAccess::Read => {
1010            matches!(
1011                path.to_str(),
1012                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1013            ) || is_dev_fd_descriptor(path)
1014        }
1015        FsAccess::Write => {
1016            matches!(
1017                path.to_str(),
1018                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1019            ) || is_dev_fd_descriptor(path)
1020        }
1021        FsAccess::Delete => false,
1022    }
1023}
1024
1025/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
1026/// digits (the numeric file-descriptor aliases for the standard streams).
1027fn is_dev_fd_descriptor(path: &Path) -> bool {
1028    let Some(text) = path.to_str() else {
1029        return false;
1030    };
1031    let Some(fd) = text.strip_prefix("/dev/fd/") else {
1032        return false;
1033    };
1034    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1035}
1036
1037#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1038pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1039    fn rank(value: &str) -> usize {
1040        match value {
1041            "none" => 0,
1042            "read_only" => 1,
1043            "workspace_write" => 2,
1044            "process_exec" => 3,
1045            "network" => 4,
1046            _ => 5,
1047        }
1048    }
1049    policy
1050        .side_effect_level
1051        .as_ref()
1052        .map(|level| rank(level) >= rank("network"))
1053        .unwrap_or(true)
1054}
1055
1056#[cfg(any(
1057    target_os = "linux",
1058    target_os = "macos",
1059    target_os = "openbsd",
1060    target_os = "windows"
1061))]
1062pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1063    policy.capabilities.is_empty()
1064        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1065}
1066
1067#[cfg(any(
1068    target_os = "linux",
1069    target_os = "macos",
1070    target_os = "openbsd",
1071    target_os = "windows"
1072))]
1073pub(crate) fn policy_allows_capability(
1074    policy: &CapabilityPolicy,
1075    capability: &str,
1076    ops: &[&str],
1077) -> bool {
1078    policy
1079        .capabilities
1080        .get(capability)
1081        .map(|allowed| {
1082            ops.iter()
1083                .any(|op| allowed.iter().any(|candidate| candidate == op))
1084        })
1085        .unwrap_or(false)
1086}
1087
1088impl FsAccess {
1089    fn verb(self) -> &'static str {
1090        match self {
1091            FsAccess::Read => "read",
1092            FsAccess::Write => "write",
1093            FsAccess::Delete => "delete",
1094        }
1095    }
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101    use crate::orchestration::{pop_execution_policy, push_execution_policy};
1102
1103    #[test]
1104    fn missing_create_path_normalizes_against_existing_parent() {
1105        let dir = tempfile::tempdir().unwrap();
1106        let nested = dir.path().join("a/../new.txt");
1107        let normalized = normalize_for_policy(&nested);
1108        assert_eq!(
1109            normalized,
1110            normalize_for_policy(&dir.path().join("new.txt"))
1111        );
1112    }
1113
1114    #[test]
1115    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1116        let dir = tempfile::tempdir().unwrap();
1117        crate::stdlib::process::set_thread_execution_context(Some(
1118            crate::orchestration::RunExecutionRecord {
1119                cwd: Some(dir.path().to_string_lossy().into_owned()),
1120                source_dir: None,
1121                env: Default::default(),
1122                adapter: None,
1123                repo_path: None,
1124                worktree_path: None,
1125                branch: None,
1126                base_ref: None,
1127                cleanup: None,
1128            },
1129        ));
1130        push_execution_policy(CapabilityPolicy {
1131            sandbox_profile: SandboxProfile::Worktree,
1132            ..CapabilityPolicy::default()
1133        });
1134
1135        assert!(
1136            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1137        );
1138        let outside = tempfile::tempdir().unwrap();
1139        assert!(enforce_fs_path(
1140            "read_file",
1141            &outside.path().join("outside.txt"),
1142            FsAccess::Read
1143        )
1144        .is_err());
1145
1146        pop_execution_policy();
1147        crate::stdlib::process::set_thread_execution_context(None);
1148    }
1149
1150    #[test]
1151    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1152        let dir = tempfile::tempdir().unwrap();
1153        crate::stdlib::process::set_thread_execution_context(Some(
1154            crate::orchestration::RunExecutionRecord {
1155                cwd: Some(dir.path().to_string_lossy().into_owned()),
1156                source_dir: None,
1157                env: Default::default(),
1158                adapter: None,
1159                repo_path: None,
1160                worktree_path: None,
1161                branch: None,
1162                base_ref: None,
1163                cleanup: None,
1164            },
1165        ));
1166        push_execution_policy(CapabilityPolicy {
1167            sandbox_profile: SandboxProfile::Worktree,
1168            ..CapabilityPolicy::default()
1169        });
1170
1171        assert!(enforce_process_cwd(dir.path()).is_ok());
1172        let outside = tempfile::tempdir().unwrap();
1173        assert!(enforce_process_cwd(outside.path()).is_err());
1174
1175        pop_execution_policy();
1176        crate::stdlib::process::set_thread_execution_context(None);
1177    }
1178
1179    #[test]
1180    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1181        let cwd = std::env::current_dir().unwrap();
1182        let policy = CapabilityPolicy {
1183            sandbox_profile: SandboxProfile::Worktree,
1184            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1185            ..CapabilityPolicy::default()
1186        };
1187
1188        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1189
1190        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1191    }
1192
1193    #[test]
1194    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1195        let workspace = tempfile::tempdir().unwrap();
1196        let policy = CapabilityPolicy {
1197            sandbox_profile: SandboxProfile::Worktree,
1198            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1199            ..CapabilityPolicy::default()
1200        };
1201
1202        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1203
1204        assert_eq!(
1205            resolved.cwd.unwrap(),
1206            normalize_for_policy(workspace.path())
1207        );
1208    }
1209
1210    #[test]
1211    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1212        let workspace = tempfile::tempdir().unwrap();
1213        let outside = tempfile::tempdir().unwrap();
1214        let policy = CapabilityPolicy {
1215            sandbox_profile: SandboxProfile::Worktree,
1216            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1217            ..CapabilityPolicy::default()
1218        };
1219        let config = ProcessCommandConfig {
1220            cwd: Some(outside.path().to_path_buf()),
1221            ..ProcessCommandConfig::default()
1222        };
1223
1224        assert!(sandboxed_process_config(&config, &policy).is_err());
1225    }
1226
1227    #[test]
1228    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1229        let cwd = std::env::current_dir().unwrap();
1230        let policy = CapabilityPolicy {
1231            sandbox_profile: SandboxProfile::Worktree,
1232            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1233            ..CapabilityPolicy::default()
1234        };
1235
1236        // A sandboxed spawn must bypass sccache so it can never spawn (and
1237        // thereby permanently confine) the shared daemon.
1238        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1239        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1240        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1241        assert_eq!(
1242            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1243            Some("")
1244        );
1245    }
1246
1247    #[test]
1248    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1249        // Even if a caller (or inherited env) asked for sccache, the sandboxed
1250        // config forces it off rather than appending a duplicate entry.
1251        let mut env = vec![
1252            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1253            ("PATH".to_string(), "/usr/bin".to_string()),
1254        ];
1255        neutralize_rustc_wrapper(&mut env);
1256        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1257        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1258        assert_eq!(
1259            collected
1260                .get("CARGO_BUILD_RUSTC_WRAPPER")
1261                .map(String::as_str),
1262            Some("")
1263        );
1264        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1265        // No duplicate RUSTC_WRAPPER entries.
1266        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1267    }
1268
1269    #[test]
1270    fn read_only_root_outside_workspace_allows_read_denies_write() {
1271        // Models an embedder (burin's in-process TUI) that grants a
1272        // read-only root R holding bundled pipelines/partials outside the
1273        // user's writable workspace. A read under R passes; a write under R
1274        // is denied; a read outside both R and the workspace is denied.
1275        let workspace = tempfile::tempdir().unwrap();
1276        let read_only = tempfile::tempdir().unwrap();
1277        push_execution_policy(CapabilityPolicy {
1278            sandbox_profile: SandboxProfile::Worktree,
1279            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1280            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1281            ..CapabilityPolicy::default()
1282        });
1283
1284        let asset = read_only
1285            .path()
1286            .join("partials/agent-web-tools.harn.prompt");
1287        // READ under the read-only root is allowed.
1288        assert!(
1289            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1290            "read under a configured read-only root must be allowed"
1291        );
1292
1293        // WRITE under the read-only root is denied, flagged read_only.
1294        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1295            .expect_err("write under a read-only root must be denied");
1296        assert!(write_err.read_only, "write rejection must set read_only");
1297
1298        // DELETE under the read-only root is likewise denied.
1299        assert!(
1300            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1301            "delete under a read-only root must be denied"
1302        );
1303
1304        // A read inside the writable workspace still passes.
1305        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1306
1307        // A read outside BOTH the workspace and the read-only root is denied
1308        // and is NOT flagged read_only (it fell outside every root).
1309        let stranger = tempfile::tempdir().unwrap();
1310        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1311            .expect_err("read outside all roots must be denied");
1312        assert!(
1313            !outside_err.read_only,
1314            "out-of-scope rejection must not be flagged read_only"
1315        );
1316
1317        pop_execution_policy();
1318    }
1319
1320    #[cfg(unix)]
1321    #[test]
1322    fn standard_io_device_files_allowed_under_restricted_profile() {
1323        // Writing to the standard process I/O streams is not a workspace
1324        // mutation, so a restricted profile with a workspace root that does
1325        // not contain /dev must still allow them — while a genuine
1326        // out-of-root write is still rejected.
1327        let workspace = tempfile::tempdir().unwrap();
1328        push_execution_policy(CapabilityPolicy {
1329            sandbox_profile: SandboxProfile::Worktree,
1330            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1331            ..CapabilityPolicy::default()
1332        });
1333
1334        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1335            assert!(
1336                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1337                "write to standard device {device} must be allowed"
1338            );
1339            // Reads of the same devices are likewise allowed.
1340            assert!(
1341                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1342                "read of standard device {device} must be allowed"
1343            );
1344        }
1345        assert!(
1346            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1347            "read of standard device /dev/stdin must be allowed"
1348        );
1349        assert!(
1350            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1351            "write to /dev/stdin is not a standard output stream"
1352        );
1353        assert!(
1354            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1355            "standard devices must not bypass delete scoping"
1356        );
1357        // Numeric /dev/fd/<N> descriptors are allowed.
1358        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1359        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1360
1361        // A non-device path outside the workspace is still rejected.
1362        let stranger = tempfile::tempdir().unwrap();
1363        assert!(
1364            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1365            "a real out-of-root write must still be rejected"
1366        );
1367        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
1368        assert!(
1369            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1370            "/dev/sda must not be allowed by the standard-device allowlist"
1371        );
1372        assert!(
1373            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1374            "non-numeric /dev/fd/<x> must not be allowed"
1375        );
1376
1377        pop_execution_policy();
1378    }
1379
1380    #[test]
1381    fn is_standard_io_device_matches_only_known_streams() {
1382        assert!(is_standard_io_device_for_access(
1383            Path::new("/dev/stdin"),
1384            FsAccess::Read
1385        ));
1386        assert!(!is_standard_io_device_for_access(
1387            Path::new("/dev/stdin"),
1388            FsAccess::Write
1389        ));
1390        assert!(is_standard_io_device_for_access(
1391            Path::new("/dev/stdout"),
1392            FsAccess::Write
1393        ));
1394        assert!(is_standard_io_device_for_access(
1395            Path::new("/dev/stderr"),
1396            FsAccess::Write
1397        ));
1398        assert!(is_standard_io_device_for_access(
1399            Path::new("/dev/null"),
1400            FsAccess::Write
1401        ));
1402        assert!(is_standard_io_device_for_access(
1403            Path::new("/dev/fd/0"),
1404            FsAccess::Read
1405        ));
1406        assert!(is_standard_io_device_for_access(
1407            Path::new("/dev/fd/12"),
1408            FsAccess::Write
1409        ));
1410        assert!(!is_standard_io_device_for_access(
1411            Path::new("/dev/null"),
1412            FsAccess::Delete
1413        ));
1414        assert!(!is_standard_io_device_for_access(
1415            Path::new("/dev/fd/"),
1416            FsAccess::Write
1417        ));
1418        assert!(!is_standard_io_device_for_access(
1419            Path::new("/dev/fd/1a"),
1420            FsAccess::Write
1421        ));
1422        assert!(!is_standard_io_device_for_access(
1423            Path::new("/dev/stdoutx"),
1424            FsAccess::Write
1425        ));
1426        assert!(!is_standard_io_device_for_access(
1427            Path::new("/dev/random"),
1428            FsAccess::Read
1429        ));
1430        assert!(!is_standard_io_device_for_access(
1431            Path::new("/tmp/dev/null"),
1432            FsAccess::Write
1433        ));
1434    }
1435
1436    #[test]
1437    fn path_within_root_accepts_root_and_children() {
1438        let root = Path::new("/tmp/harn-root");
1439        assert!(path_is_within(root, root));
1440        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1441        assert!(!path_is_within(
1442            Path::new("/tmp/harn-root-other/file"),
1443            root
1444        ));
1445    }
1446
1447    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1448    #[test]
1449    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1450        let temp_home = tempfile::tempdir().expect("temp home");
1451        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1452        let normalized_home = normalize_for_policy(temp_home.path());
1453
1454        for suffix in [
1455            Path::new(".cargo"),
1456            Path::new(".rustup"),
1457            Path::new(".pyenv"),
1458            Path::new(".nvm"),
1459            Path::new(".volta"),
1460            Path::new(".local/share/uv"),
1461            Path::new("go"),
1462        ] {
1463            assert!(
1464                roots.iter().any(|path| path.ends_with(suffix)),
1465                "expected a developer-toolchain grant for {}",
1466                suffix.display()
1467            );
1468        }
1469        assert!(
1470            roots.iter().all(|path| path.starts_with(&normalized_home)),
1471            "developer-toolchain roots must stay under HOME"
1472        );
1473    }
1474
1475    #[test]
1476    fn os_hardened_profile_overrides_fallback_env() {
1477        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
1478        // point of the profile is that the OS sandbox is required.
1479        // We cannot mutate the env here without races, so just check
1480        // the pure resolution function.
1481        assert_eq!(
1482            effective_fallback(SandboxProfile::OsHardened),
1483            SandboxFallback::Enforce
1484        );
1485    }
1486
1487    #[test]
1488    fn unrestricted_profile_skips_active_sandbox() {
1489        let policy = CapabilityPolicy {
1490            sandbox_profile: SandboxProfile::Unrestricted,
1491            workspace_roots: vec!["/tmp".to_string()],
1492            ..Default::default()
1493        };
1494        crate::orchestration::push_execution_policy(policy);
1495        let result = active_sandbox_policy();
1496        crate::orchestration::pop_execution_policy();
1497        assert!(
1498            result.is_none(),
1499            "Unrestricted profile must short-circuit sandbox dispatch"
1500        );
1501    }
1502
1503    #[test]
1504    fn worktree_profile_engages_active_sandbox() {
1505        let policy = CapabilityPolicy {
1506            sandbox_profile: SandboxProfile::Worktree,
1507            workspace_roots: vec!["/tmp".to_string()],
1508            ..Default::default()
1509        };
1510        crate::orchestration::push_execution_policy(policy);
1511        let result = active_sandbox_policy();
1512        crate::orchestration::pop_execution_policy();
1513        assert!(
1514            result.is_some(),
1515            "Worktree profile must keep sandbox dispatch active"
1516        );
1517    }
1518}