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(arcstr::ArcStr::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(arcstr::ArcStr::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(arcstr::ArcStr::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    inject_workspace_tmpdir(&mut resolved.env, policy);
499    Ok(resolved)
500}
501
502/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
503///
504/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
505/// cargo build is the first caller to spawn it, the daemon inherits the
506/// `sandbox-exec` confinement permanently — even after it reparents to
507/// launchd — and then fails *every* later build machine-wide with
508/// `Operation not permitted` (it can no longer read build inputs outside the
509/// sandbox root nor write its cache dir under `~/Library/Caches`). A
510/// per-command sandbox must never be allowed to poison a cross-workspace
511/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
512/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
513/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
514/// on-disk cache and all unsandboxed builds are unaffected.
515fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
516    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
517        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
518            entry.1.clear();
519        } else {
520            env.push((key.to_string(), String::new()));
521        }
522    }
523}
524
525/// Workspace-relative directory name for the sandbox-writable temp dir that
526/// [`workspace_local_tmpdir`] points `TMPDIR`/`TMP`/`TEMP` at. Lives inside a
527/// writable workspace root (which both OS backends already grant) so any
528/// toolchain that honors `TMPDIR` writes its intermediates somewhere the
529/// sandbox permits, instead of the unwritable system `/tmp`.
530pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
531
532/// The environment keys a workspace-local temp dir is exported under. `TMPDIR`
533/// is the POSIX/Rust/clang/gcc/Go/Swift convention; `TMP`/`TEMP` cover tools
534/// (and Windows toolchains) that read those instead.
535pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
536
537/// Resolve the sandbox-writable, workspace-local temp directory for `policy`,
538/// creating it lazily.
539///
540/// Compiler linkers (`rustc`/`cc`/`ld`, Go, Swift, …) and countless other
541/// toolchains write intermediate object/temp files to `$TMPDIR`, defaulting to
542/// the system `/tmp` when it is unset. Under a restricted profile `/tmp` is
543/// outside the writable workspace roots, so those writes are denied and a build
544/// that would otherwise succeed FALSE-FAILS for an infrastructure reason. By
545/// pointing the child's temp dir at a directory *inside* the first writable
546/// workspace root — which the OS sandbox already grants write access to — the
547/// build's temp writes land somewhere permitted without widening the sandbox.
548///
549/// Returns `None` when the policy declares no writable workspace root (there is
550/// nowhere sandbox-writable to anchor the temp dir) or when the directory could
551/// not be created (the caller then leaves the child's inherited temp dir
552/// untouched rather than failing the spawn).
553pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
554    let root = normalized_workspace_roots(policy).into_iter().next()?;
555    let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
556    if let Err(error) = std::fs::create_dir_all(&tmpdir) {
557        warn_once(
558            "handler_sandbox_workspace_tmpdir",
559            &format!(
560                "could not create workspace-local temp dir '{}': {error}; \
561                 leaving the child's inherited temp dir in place",
562                tmpdir.display()
563            ),
564        );
565        return None;
566    }
567    // Keep the temp dir's churn out of every git-based diff/status (so it never
568    // leaks into an agent's view, a PR, or eval grading) by self-ignoring its
569    // own contents. A `.gitignore` of `*` inside the dir excludes everything,
570    // including itself, regardless of whether the workspace tracks it. Written
571    // best-effort and only when absent so we don't thrash an existing file.
572    let ignore = tmpdir.join(".gitignore");
573    if !ignore.exists() {
574        let _ = std::fs::write(
575            &ignore,
576            "# Created by the Harn sandbox; safe to delete.\n*\n",
577        );
578    }
579    Some(tmpdir)
580}
581
582/// Overlay `TMPDIR`/`TMP`/`TEMP` onto a child's env so a sandboxed toolchain
583/// writes its intermediates to a workspace-local, sandbox-writable directory
584/// instead of the unwritable system `/tmp` (see [`workspace_local_tmpdir`]).
585///
586/// A key the caller set explicitly in `env` is left untouched — an intentional
587/// per-call `TMPDIR` is honored. The inherited-from-parent value is *not*
588/// preserved: that is exactly the non-writable `/tmp` (or empty) we must
589/// override. No-op under an unrestricted/absent policy or when no writable
590/// workspace root is available.
591pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
592    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
593        return;
594    }
595    let Some(tmpdir) = workspace_local_tmpdir(policy) else {
596        return;
597    };
598    let tmpdir = tmpdir.display().to_string();
599    for key in TMPDIR_ENV_KEYS {
600        if env.iter().any(|(existing, _)| existing == key) {
601            // The caller pinned this key explicitly; respect it.
602            continue;
603        }
604        env.push((key.to_string(), tmpdir.clone()));
605    }
606}
607
608/// The `TMPDIR`/`TMP`/`TEMP` overrides for the *currently active* execution
609/// policy, as `(key, value)` pairs, or an empty vec when no restricted policy
610/// is active or no writable workspace root exists.
611///
612/// This reads the active execution policy directly (gating only on a restricted
613/// `sandbox_profile`), deliberately *not* through [`active_sandbox_policy`]:
614/// the workspace-local temp dir is a benefit of the child env, independent of
615/// whether OS confinement is enforced, so it must still engage under
616/// `HARN_HANDLER_SANDBOX=warn`/`off` (which only weaken *enforcement*, not the
617/// profile). [`inject_workspace_tmpdir`] still no-ops under `Unrestricted`.
618///
619/// This is the entry point the `host_call("process", …)` exec/spawn builder and
620/// the `harn-hostlib` real spawner use to overlay the keys onto a
621/// `Command`/`tokio::process::Command`, skipping any the caller already pinned.
622pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
623    let Some(policy) = crate::orchestration::current_execution_policy() else {
624        return Vec::new();
625    };
626    let mut env = Vec::new();
627    inject_workspace_tmpdir(&mut env, &policy);
628    env
629}
630
631/// Environment overlay that pins a child tool's *message* output to a
632/// deterministic, English, UTF-8-preserving locale, as `(key, value)` pairs.
633///
634/// Build/test/verify commands inherit the parent environment, so a user whose
635/// shell sets `LC_ALL=ja_JP.UTF-8` (or `LANG=de_DE.UTF-8`) would otherwise get
636/// *localized* compiler/test output. Every downstream matcher that keys on
637/// English diagnostics — deterministic syntax repair, error-signature
638/// grounding, completion/pass-fail classification — would then silently
639/// misfire for a non-Anglosphere user. Forcing a stable message locale is the
640/// root-cause fix: it keeps the English matchers correct by construction,
641/// without shipping per-locale translations of every toolchain.
642///
643/// `LC_MESSAGES=C` forces untranslated (English) messages for gettext-based
644/// tools (gcc/clang, git-l10n, GNU coreutils, gradle) while deliberately *not*
645/// touching `LC_CTYPE`/`LANG`, so UTF-8 handling of non-ASCII source and
646/// identifiers is preserved (unlike the blunt `LC_ALL=C`, which forces an ASCII
647/// ctype and can mangle non-ASCII identifiers in diagnostics). The .NET CLI
648/// ignores `LC_*` and localizes from its own variable / the OS UI language, so
649/// `DOTNET_CLI_UI_LANGUAGE=en` is required in addition.
650///
651/// A user-inherited `LC_ALL` would override `LC_MESSAGES`, so the spawn sites
652/// additionally strip `LC_ALL` (unless the caller pinned it) before applying
653/// this overlay. Both are subject to the caller-pinned-key rule (like the
654/// `TMPDIR` overlay): an explicit `env`/`env_remove` still wins.
655pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
656    vec![
657        ("LC_MESSAGES".to_string(), "C".to_string()),
658        ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
659    ]
660}
661
662/// The environment variable a user-inherited value of which would override
663/// [`deterministic_message_locale_env`]'s `LC_MESSAGES`. Spawn sites strip this
664/// (unless the caller pinned it) so the forced message locale actually takes
665/// effect. Kept as a named constant so both spawn paths stay in sync.
666pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
667
668fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
669    let roots = normalized_workspace_roots(policy);
670    let current = std::env::current_dir().map_err(|error| {
671        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
672            format!("process cwd resolution failed: {error}"),
673        )))
674    })?;
675    let current = normalize_for_policy(&current);
676    if roots.iter().any(|root| path_is_within(&current, root)) {
677        return Ok(current);
678    }
679    roots.first().cloned().ok_or_else(|| {
680        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
681            "process cwd resolution failed: no workspace root available",
682        )))
683    })
684}
685
686fn build_std_command<B: SandboxBackend + ?Sized>(
687    program: &str,
688    args: &[String],
689    policy: &CapabilityPolicy,
690    profile: SandboxProfile,
691) -> Result<Command, VmError> {
692    let mut command = Command::new(program);
693    command.args(args);
694    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
695        PrepareOutcome::Direct => Ok(command),
696        PrepareOutcome::WrappedExec { wrapper, args } => {
697            let mut wrapped = Command::new(wrapper);
698            wrapped.args(args);
699            Ok(wrapped)
700        }
701    }
702}
703
704fn build_tokio_command<B: SandboxBackend + ?Sized>(
705    program: &str,
706    args: &[String],
707    policy: &CapabilityPolicy,
708    profile: SandboxProfile,
709) -> Result<tokio::process::Command, VmError> {
710    let mut command = tokio::process::Command::new(program);
711    command.args(args);
712    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
713        PrepareOutcome::Direct => Ok(command),
714        PrepareOutcome::WrappedExec { wrapper, args } => {
715            let mut wrapped = tokio::process::Command::new(wrapper);
716            wrapped.args(args);
717            Ok(wrapped)
718        }
719    }
720}
721
722pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
723    let policy = crate::orchestration::current_execution_policy()?;
724    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
725        return None;
726    }
727    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
728        || !ActiveBackend::available()
729    {
730        return None;
731    }
732    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
733    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
734    if !output.status.success()
735        && (stderr.contains("operation not permitted")
736            || stderr.contains("permission denied")
737            || stderr.contains("access is denied")
738            || stdout.contains("operation not permitted"))
739    {
740        return Some(sandbox_rejection(sandbox_process_violation_message(
741            format!(
742                "sandbox violation: process was denied by the OS sandbox (status {})",
743                output.status.code().unwrap_or(-1)
744            ),
745        )));
746    }
747    if sandbox_signal_status(output) {
748        return Some(sandbox_rejection(sandbox_process_violation_message(
749            format!(
750                "sandbox violation: process was terminated by the OS sandbox (status {})",
751                output.status
752            ),
753        )));
754    }
755    None
756}
757
758pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
759    let policy = crate::orchestration::current_execution_policy()?;
760    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
761        return None;
762    }
763    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
764        || !ActiveBackend::available()
765    {
766        return None;
767    }
768    let message = error.to_string().to_ascii_lowercase();
769    if error.kind() == std::io::ErrorKind::PermissionDenied
770        || message.contains("operation not permitted")
771        || message.contains("permission denied")
772        || message.contains("access is denied")
773    {
774        return Some(sandbox_rejection(sandbox_process_violation_message(
775            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
776        )));
777    }
778    None
779}
780
781#[cfg(unix)]
782fn sandbox_signal_status(output: &std::process::Output) -> bool {
783    use std::os::unix::process::ExitStatusExt;
784
785    matches!(
786        output.status.signal(),
787        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
788    )
789}
790
791#[cfg(not(unix))]
792fn sandbox_signal_status(_output: &std::process::Output) -> bool {
793    false
794}
795
796/// Returns the active capability policy and the resolved sandbox
797/// profile, or `None` if confinement should be skipped entirely. The
798/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
799/// hatch both produce `None`. The `Wasi` profile also produces `None`
800/// on the host spawn path — testbench mode intercepts subprocesses
801/// before they reach this layer, so the host-spawn fallback should be
802/// a normal direct exec.
803pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
804    let policy = crate::orchestration::current_execution_policy()?;
805    let profile = policy.sandbox_profile;
806    match profile {
807        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
808        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
809            if effective_fallback(profile) == SandboxFallback::Off {
810                None
811            } else {
812                Some((policy, profile))
813            }
814        }
815    }
816}
817
818fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
819    if let Some(cwd) = config.cwd.as_ref() {
820        command.current_dir(cwd);
821    }
822    command.envs(config.env.iter().map(|(key, value)| (key, value)));
823    if config.stdin_null {
824        command.stdin(Stdio::null());
825    }
826}
827
828fn spawn_error(error: std::io::Error) -> VmError {
829    VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
830        format!("process spawn failed: {error}"),
831    )))
832}
833
834/// Resolve the fallback policy for the requested profile. `OsHardened`
835/// always enforces — that is the entire point of the profile, so the
836/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
837/// the env var (default `warn`).
838pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
839    if matches!(profile, SandboxProfile::OsHardened) {
840        return SandboxFallback::Enforce;
841    }
842    match std::env::var(HANDLER_SANDBOX_ENV)
843        .unwrap_or_else(|_| "warn".to_string())
844        .trim()
845        .to_ascii_lowercase()
846        .as_str()
847    {
848        "0" | "false" | "off" | "none" => SandboxFallback::Off,
849        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
850        _ => SandboxFallback::Warn,
851    }
852}
853
854pub(crate) fn warn_once(key: &str, message: &str) {
855    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
856    if inserted {
857        crate::events::log_warn("handler_sandbox", message);
858    }
859}
860
861pub(crate) fn sandbox_rejection(message: String) -> VmError {
862    VmError::CategorizedError {
863        message,
864        category: ErrorCategory::ToolRejected,
865    }
866}
867
868fn sandbox_process_violation_message(summary: String) -> String {
869    format!(
870        "{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"
871    )
872}
873
874/// Helper for backends that can't attach confinement at all (macOS
875/// without `/usr/bin/sandbox-exec`, Windows when called through the
876/// `Command`-returning entry points): either fail loudly under
877/// `OsHardened` / `enforce`, or warn once and proceed direct.
878///
879/// Linux and OpenBSD don't reach this path — they install confinement
880/// in `pre_exec` and surface unavailability through `landlock_profile`
881/// directly. The dead-code lint allow keeps the helper compilable on
882/// targets where no backend uses it.
883#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
884pub(crate) fn unavailable(
885    message: &str,
886    profile: SandboxProfile,
887) -> Result<PrepareOutcome, VmError> {
888    match effective_fallback(profile) {
889        SandboxFallback::Off | SandboxFallback::Warn => {
890            warn_once("handler_sandbox_unavailable", message);
891            Ok(PrepareOutcome::Direct)
892        }
893        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
894            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
895        ))),
896    }
897}
898
899/// Writable workspace roots derived from the active agent session's
900/// workspace anchor: the anchor `primary` plus any `Extend` (writable)
901/// mounts. Read-only mounts are intentionally excluded — they are not
902/// writable jail roots (a read of one is permitted via the read-only-roots
903/// path, but a write must not be). Returns `None` when there is no current
904/// session or the session has no anchor, so the caller falls back to the
905/// process execution root.
906fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
907    let session_id = crate::agent_sessions::current_session_id()?;
908    let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
909    let mut roots = vec![anchor.primary.clone()];
910    for mounted in &anchor.additional_roots {
911        if matches!(
912            mounted.mount_mode,
913            crate::workspace_anchor::MountMode::Extend
914        ) {
915            roots.push(mounted.path.clone());
916        }
917    }
918    Some(roots)
919}
920
921/// The host-declared project root (`HARN_PROJECT_ROOT`), if set and
922/// non-empty. This mirrors the standalone project-root fallback the
923/// `workspace.project_root` host capability already uses
924/// (`stdlib::host`), so the write jail and the reported project root
925/// agree. It is the project a run is bound to even when the OS process
926/// cwd differs (the eval harness runs `burin-headless` from the repo with
927/// `--project <fixture>`, and the real IDE may launch from elsewhere).
928fn project_root_env_workspace_root() -> Option<PathBuf> {
929    std::env::var("HARN_PROJECT_ROOT")
930        .ok()
931        .map(|value| value.trim().to_string())
932        .filter(|value| !value.is_empty())
933        .map(PathBuf::from)
934}
935
936fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
937    if policy.workspace_roots.is_empty() {
938        // An empty `policy.workspace_roots` means no explicit write-jail was
939        // configured for this call. Historically this fell straight back to the
940        // process execution root, but under the eval pattern (process cwd !=
941        // `--project`) and dispatch fan-out children, the process cwd is the
942        // repo, not the project the run is bound to — so a write that correctly
943        // resolved INTO the project was rejected as outside the jail
944        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
945        // silently compensated. Prefer, in order: (1) the active agent
946        // session's workspace anchor (primary + writable `Extend` mounts) when
947        // the session is anchored; (2) the host-declared `HARN_PROJECT_ROOT`
948        // project root (robust across the session nesting that an unanchored
949        // dispatch child sees); (3) the process execution root, the historical
950        // default. Explicit `policy.workspace_roots` still take precedence
951        // (handled in the non-empty branch below).
952        if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
953            return anchor_roots
954                .iter()
955                .map(|root| normalize_for_policy(root))
956                .collect();
957        }
958        if let Some(project_root) = project_root_env_workspace_root() {
959            return vec![normalize_for_policy(&project_root)];
960        }
961        return vec![normalize_for_policy(
962            &crate::stdlib::process::execution_root_path(),
963        )];
964    }
965    policy
966        .workspace_roots
967        .iter()
968        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
969        .collect()
970}
971
972pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
973    normalized_workspace_roots(policy)
974}
975
976/// Normalize the policy's read-only roots. Unlike
977/// [`normalized_workspace_roots`], an empty list stays empty — read-only
978/// scope is purely additive, so there is no execution-root fallback to
979/// synthesize.
980fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
981    policy
982        .read_only_roots
983        .iter()
984        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
985        .collect()
986}
987
988#[cfg(any(
989    target_os = "linux",
990    target_os = "macos",
991    target_os = "openbsd",
992    target_os = "windows"
993))]
994pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
995    normalized_read_only_roots(policy)
996}
997
998#[cfg(any(
999    target_os = "linux",
1000    target_os = "macos",
1001    target_os = "openbsd",
1002    target_os = "windows"
1003))]
1004pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1005    normalized_process_roots(&policy.process_sandbox.read_roots)
1006}
1007
1008#[cfg(any(
1009    target_os = "linux",
1010    target_os = "macos",
1011    target_os = "openbsd",
1012    target_os = "windows"
1013))]
1014pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1015    normalized_process_roots(&policy.process_sandbox.write_roots)
1016}
1017
1018#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1019pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1020    policy.process_sandbox.effective_presets()
1021}
1022
1023#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1024pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1025    policy: &CapabilityPolicy,
1026) -> Vec<PathBuf> {
1027    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1028        return Vec::new();
1029    }
1030    let Some(home) = sandbox_user_home_dir() else {
1031        return Vec::new();
1032    };
1033    developer_toolchain_read_roots_for_home(&home)
1034}
1035
1036#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1037pub(crate) fn process_sandbox_package_manager_config_read_roots(
1038    policy: &CapabilityPolicy,
1039) -> Vec<PathBuf> {
1040    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1041        return Vec::new();
1042    }
1043    let Some(home) = sandbox_user_home_dir() else {
1044        return Vec::new();
1045    };
1046    package_manager_config_read_roots_for_home(&home)
1047}
1048
1049/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
1050/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
1051/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
1052/// read-only: a build legitimately populates `~/.gradle/caches`,
1053/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
1054/// gated on the `DeveloperToolchains` preset and granted *write* only when the
1055/// active policy already permits workspace writes (mirroring `UserTemp`); under
1056/// a read-only policy they fall back to read access so dependency resolution
1057/// still works.
1058// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
1059// (Landlock) sandbox backends; the Windows backend deliberately does not grant
1060// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
1061// targets keeps `-D warnings` happy on Windows, where this would otherwise be
1062// dead code.
1063#[cfg(any(target_os = "linux", target_os = "macos"))]
1064pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1065    policy: &CapabilityPolicy,
1066) -> Vec<PathBuf> {
1067    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1068        return Vec::new();
1069    }
1070    let Some(home) = sandbox_user_home_dir() else {
1071        return Vec::new();
1072    };
1073    developer_toolchain_cache_write_roots_for_home(&home)
1074}
1075
1076#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1077fn sandbox_user_home_dir() -> Option<PathBuf> {
1078    // Only an absolute home grounds the user-scope read-roots below; a
1079    // relative or unset home yields no extra roots (the safe direction).
1080    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1081}
1082
1083#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1084pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1085    let mut roots: Vec<_> = [
1086        ".asdf",
1087        ".bun",
1088        ".cargo",
1089        ".fnm",
1090        ".juliaup",
1091        ".local/bin",
1092        ".local/share/mise",
1093        ".local/share/uv",
1094        ".nvm",
1095        ".pyenv",
1096        ".rbenv",
1097        ".rustup",
1098        ".sdkman",
1099        ".swiftly",
1100        ".volta",
1101        "go",
1102    ]
1103    .into_iter()
1104    .map(|entry| normalize_for_policy(&home.join(entry)))
1105    .collect();
1106    #[cfg(target_os = "windows")]
1107    roots.extend(
1108        [
1109            "AppData/Local/Programs/Python",
1110            "AppData/Local/uv",
1111            "AppData/Roaming/uv",
1112            "scoop",
1113        ]
1114        .into_iter()
1115        .map(|entry| normalize_for_policy(&home.join(entry))),
1116    );
1117    roots.sort_unstable();
1118    roots.dedup();
1119    roots
1120}
1121
1122/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
1123/// the macOS seatbelt and Linux Landlock backends render the same set; the
1124/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
1125/// `optional`/NotFound handling in each backend skips roots that do not exist.
1126#[cfg(any(target_os = "linux", target_os = "macos"))]
1127pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1128    let mut roots: Vec<_> = [
1129        ".gradle",                             // Gradle (JVM/Android/Kotlin)
1130        ".m2",                                 // Maven (JVM)
1131        ".konan",                              // Kotlin/Native
1132        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
1133        "Library/Developer/Xcode/DerivedData", // Xcode build products
1134    ]
1135    .into_iter()
1136    .map(|entry| normalize_for_policy(&home.join(entry)))
1137    .collect();
1138    roots.sort_unstable();
1139    roots.dedup();
1140    roots
1141}
1142
1143#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1144pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1145    let mut roots: Vec<_> = [
1146        ".npmrc",
1147        ".gitconfig",
1148        ".netrc",
1149        ".yarnrc.yml",
1150        ".config",
1151        ".npm",
1152        ".cache",
1153        ".pip",
1154        ".pypirc",
1155        ".cargo/config",
1156        ".cargo/config.toml",
1157        ".cargo/credentials",
1158        ".cargo/credentials.toml",
1159        ".cargo/registry",
1160        ".cargo/git",
1161    ]
1162    .into_iter()
1163    .map(|entry| normalize_for_policy(&home.join(entry)))
1164    .collect();
1165    roots.sort_unstable();
1166    roots.dedup();
1167    roots
1168}
1169
1170#[cfg(any(
1171    target_os = "linux",
1172    target_os = "macos",
1173    target_os = "openbsd",
1174    target_os = "windows"
1175))]
1176fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1177    roots
1178        .iter()
1179        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1180        .collect()
1181}
1182
1183fn resolve_policy_path(path: &str) -> PathBuf {
1184    let candidate = PathBuf::from(path);
1185    if candidate.is_absolute() {
1186        candidate
1187    } else {
1188        crate::stdlib::process::execution_root_path().join(candidate)
1189    }
1190}
1191
1192fn normalize_for_policy(path: &Path) -> PathBuf {
1193    let absolute = if path.is_absolute() {
1194        path.to_path_buf()
1195    } else {
1196        crate::stdlib::process::execution_root_path().join(path)
1197    };
1198    let absolute = normalize_lexically(&absolute);
1199    if let Ok(canonical) = absolute.canonicalize() {
1200        return canonical;
1201    }
1202
1203    let mut existing = absolute.as_path();
1204    let mut suffix = Vec::new();
1205    while !existing.exists() {
1206        let Some(parent) = existing.parent() else {
1207            return normalize_lexically(&absolute);
1208        };
1209        if let Some(name) = existing.file_name() {
1210            suffix.push(name.to_os_string());
1211        }
1212        existing = parent;
1213    }
1214
1215    let mut normalized = existing
1216        .canonicalize()
1217        .unwrap_or_else(|_| normalize_lexically(existing));
1218    for component in suffix.iter().rev() {
1219        normalized.push(component);
1220    }
1221    normalize_lexically(&normalized)
1222}
1223
1224fn normalize_lexically(path: &Path) -> PathBuf {
1225    let mut normalized = PathBuf::new();
1226    for component in path.components() {
1227        match component {
1228            Component::CurDir => {}
1229            Component::ParentDir => {
1230                normalized.pop();
1231            }
1232            other => normalized.push(other.as_os_str()),
1233        }
1234    }
1235    normalized
1236}
1237
1238fn path_is_within(path: &Path, root: &Path) -> bool {
1239    path == root || path.starts_with(root)
1240}
1241
1242/// Resolve `path` to an absolute, lexically-normalized form for the standard
1243/// I/O device check. Unlike [`normalize_for_policy`] this never calls
1244/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
1245/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
1246fn normalize_io_device_path(path: &Path) -> PathBuf {
1247    let absolute = if path.is_absolute() {
1248        path.to_path_buf()
1249    } else {
1250        crate::stdlib::process::execution_root_path().join(path)
1251    };
1252    normalize_lexically(&absolute)
1253}
1254
1255/// Whether `path` is one of the standard process I/O device files that the
1256/// sandbox treats as a stream rather than a workspace mutation for this access:
1257/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
1258/// stream operation. `path` must already be absolute and lexically normalized.
1259fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1260    match access {
1261        FsAccess::Read => {
1262            matches!(
1263                path.to_str(),
1264                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1265            ) || is_dev_fd_descriptor(path)
1266        }
1267        FsAccess::Write => {
1268            matches!(
1269                path.to_str(),
1270                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1271            ) || is_dev_fd_descriptor(path)
1272        }
1273        FsAccess::Delete => false,
1274    }
1275}
1276
1277/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
1278/// digits (the numeric file-descriptor aliases for the standard streams).
1279fn is_dev_fd_descriptor(path: &Path) -> bool {
1280    let Some(text) = path.to_str() else {
1281        return false;
1282    };
1283    let Some(fd) = text.strip_prefix("/dev/fd/") else {
1284        return false;
1285    };
1286    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1287}
1288
1289#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1290pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1291    fn rank(value: &str) -> usize {
1292        match value {
1293            "none" => 0,
1294            "read_only" => 1,
1295            "workspace_write" => 2,
1296            "process_exec" => 3,
1297            "network" => 4,
1298            _ => 5,
1299        }
1300    }
1301    policy
1302        .side_effect_level
1303        .as_ref()
1304        .map(|level| rank(level) >= rank("network"))
1305        .unwrap_or(true)
1306}
1307
1308#[cfg(any(
1309    target_os = "linux",
1310    target_os = "macos",
1311    target_os = "openbsd",
1312    target_os = "windows"
1313))]
1314pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1315    policy.capabilities.is_empty()
1316        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1317}
1318
1319#[cfg(any(
1320    target_os = "linux",
1321    target_os = "macos",
1322    target_os = "openbsd",
1323    target_os = "windows"
1324))]
1325pub(crate) fn policy_allows_capability(
1326    policy: &CapabilityPolicy,
1327    capability: &str,
1328    ops: &[&str],
1329) -> bool {
1330    policy
1331        .capabilities
1332        .get(capability)
1333        .map(|allowed| {
1334            ops.iter()
1335                .any(|op| allowed.iter().any(|candidate| candidate == op))
1336        })
1337        .unwrap_or(false)
1338}
1339
1340impl FsAccess {
1341    fn verb(self) -> &'static str {
1342        match self {
1343            FsAccess::Read => "read",
1344            FsAccess::Write => "write",
1345            FsAccess::Delete => "delete",
1346        }
1347    }
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352    use super::*;
1353    use crate::orchestration::{pop_execution_policy, push_execution_policy};
1354
1355    #[test]
1356    fn missing_create_path_normalizes_against_existing_parent() {
1357        let dir = tempfile::tempdir().unwrap();
1358        let nested = dir.path().join("a/../new.txt");
1359        let normalized = normalize_for_policy(&nested);
1360        assert_eq!(
1361            normalized,
1362            normalize_for_policy(&dir.path().join("new.txt"))
1363        );
1364    }
1365
1366    #[test]
1367    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1368        // Serialize env mutation and clear HARN_PROJECT_ROOT so this asserts the
1369        // pure execution-root fallback (the project-root-env preference is
1370        // covered by the next test).
1371        let _env_lock = crate::runtime_paths::test_env_lock()
1372            .lock()
1373            .unwrap_or_else(|poisoned| poisoned.into_inner());
1374        std::env::remove_var("HARN_PROJECT_ROOT");
1375        let dir = tempfile::tempdir().unwrap();
1376        crate::stdlib::process::set_thread_execution_context(Some(
1377            crate::orchestration::RunExecutionRecord {
1378                cwd: Some(dir.path().to_string_lossy().into_owned()),
1379                source_dir: None,
1380                env: Default::default(),
1381                adapter: None,
1382                repo_path: None,
1383                worktree_path: None,
1384                branch: None,
1385                base_ref: None,
1386                cleanup: None,
1387            },
1388        ));
1389        push_execution_policy(CapabilityPolicy {
1390            sandbox_profile: SandboxProfile::Worktree,
1391            ..CapabilityPolicy::default()
1392        });
1393
1394        assert!(
1395            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1396        );
1397        let outside = tempfile::tempdir().unwrap();
1398        assert!(enforce_fs_path(
1399            "read_file",
1400            &outside.path().join("outside.txt"),
1401            FsAccess::Read
1402        )
1403        .is_err());
1404
1405        pop_execution_policy();
1406        crate::stdlib::process::set_thread_execution_context(None);
1407    }
1408
1409    /// Regression for burin-labs/burin-code#3288. When a restricted policy has
1410    /// no explicit `workspace_roots`, the write jail must follow the
1411    /// host-declared `HARN_PROJECT_ROOT` project — NOT the process/execution
1412    /// cwd. This is the eval/dispatch pattern: `burin-headless` runs from the
1413    /// repo (`execution cwd = repo`) with `--project <fixture>` + matching
1414    /// `HARN_PROJECT_ROOT`, and a dispatched sub-agent worker's writes resolve
1415    /// into the fixture. Before the fix the empty-roots fallback used the
1416    /// execution cwd (the repo), so the in-project write was rejected
1417    /// (HARN-CAP-201) and the dispatched child wrote nothing.
1418    #[test]
1419    fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
1420        let _env_lock = crate::runtime_paths::test_env_lock()
1421            .lock()
1422            .unwrap_or_else(|poisoned| poisoned.into_inner());
1423        let project = tempfile::tempdir().unwrap();
1424        let execution_cwd = tempfile::tempdir().unwrap();
1425        std::env::set_var("HARN_PROJECT_ROOT", project.path());
1426        crate::stdlib::process::set_thread_execution_context(Some(
1427            crate::orchestration::RunExecutionRecord {
1428                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
1429                source_dir: None,
1430                env: Default::default(),
1431                adapter: None,
1432                repo_path: None,
1433                worktree_path: None,
1434                branch: None,
1435                base_ref: None,
1436                cleanup: None,
1437            },
1438        ));
1439        push_execution_policy(CapabilityPolicy {
1440            sandbox_profile: SandboxProfile::Worktree,
1441            ..CapabilityPolicy::default()
1442        });
1443
1444        // A write that resolves INTO the project is allowed even though the
1445        // process/execution cwd is elsewhere.
1446        assert!(
1447            enforce_fs_path(
1448                "write_file",
1449                &project.path().join("test/created.ts"),
1450                FsAccess::Write,
1451            )
1452            .is_ok(),
1453            "write into HARN_PROJECT_ROOT must be allowed"
1454        );
1455        // A write under the execution cwd (the repo, in the eval pattern) is NOT
1456        // the project and must still be rejected — the jail moved to the
1457        // project, it did not widen to both.
1458        assert!(
1459            enforce_fs_path(
1460                "write_file",
1461                &execution_cwd.path().join("escape.ts"),
1462                FsAccess::Write,
1463            )
1464            .is_err(),
1465            "write under the execution cwd (outside the project) must be rejected"
1466        );
1467
1468        pop_execution_policy();
1469        crate::stdlib::process::set_thread_execution_context(None);
1470        std::env::remove_var("HARN_PROJECT_ROOT");
1471    }
1472
1473    #[test]
1474    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1475        let dir = tempfile::tempdir().unwrap();
1476        crate::stdlib::process::set_thread_execution_context(Some(
1477            crate::orchestration::RunExecutionRecord {
1478                cwd: Some(dir.path().to_string_lossy().into_owned()),
1479                source_dir: None,
1480                env: Default::default(),
1481                adapter: None,
1482                repo_path: None,
1483                worktree_path: None,
1484                branch: None,
1485                base_ref: None,
1486                cleanup: None,
1487            },
1488        ));
1489        push_execution_policy(CapabilityPolicy {
1490            sandbox_profile: SandboxProfile::Worktree,
1491            ..CapabilityPolicy::default()
1492        });
1493
1494        assert!(enforce_process_cwd(dir.path()).is_ok());
1495        let outside = tempfile::tempdir().unwrap();
1496        assert!(enforce_process_cwd(outside.path()).is_err());
1497
1498        pop_execution_policy();
1499        crate::stdlib::process::set_thread_execution_context(None);
1500    }
1501
1502    #[test]
1503    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1504        let cwd = std::env::current_dir().unwrap();
1505        let policy = CapabilityPolicy {
1506            sandbox_profile: SandboxProfile::Worktree,
1507            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1508            ..CapabilityPolicy::default()
1509        };
1510
1511        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1512
1513        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1514    }
1515
1516    #[test]
1517    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1518        let workspace = tempfile::tempdir().unwrap();
1519        let policy = CapabilityPolicy {
1520            sandbox_profile: SandboxProfile::Worktree,
1521            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1522            ..CapabilityPolicy::default()
1523        };
1524
1525        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1526
1527        assert_eq!(
1528            resolved.cwd.unwrap(),
1529            normalize_for_policy(workspace.path())
1530        );
1531    }
1532
1533    #[test]
1534    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1535        let workspace = tempfile::tempdir().unwrap();
1536        let outside = tempfile::tempdir().unwrap();
1537        let policy = CapabilityPolicy {
1538            sandbox_profile: SandboxProfile::Worktree,
1539            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1540            ..CapabilityPolicy::default()
1541        };
1542        let config = ProcessCommandConfig {
1543            cwd: Some(outside.path().to_path_buf()),
1544            ..ProcessCommandConfig::default()
1545        };
1546
1547        assert!(sandboxed_process_config(&config, &policy).is_err());
1548    }
1549
1550    #[test]
1551    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1552        let cwd = std::env::current_dir().unwrap();
1553        let policy = CapabilityPolicy {
1554            sandbox_profile: SandboxProfile::Worktree,
1555            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1556            ..CapabilityPolicy::default()
1557        };
1558
1559        // A sandboxed spawn must bypass sccache so it can never spawn (and
1560        // thereby permanently confine) the shared daemon.
1561        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1562        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1563        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1564        assert_eq!(
1565            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1566            Some("")
1567        );
1568    }
1569
1570    #[test]
1571    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1572        // Even if a caller (or inherited env) asked for sccache, the sandboxed
1573        // config forces it off rather than appending a duplicate entry.
1574        let mut env = vec![
1575            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1576            ("PATH".to_string(), "/usr/bin".to_string()),
1577        ];
1578        neutralize_rustc_wrapper(&mut env);
1579        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1580        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1581        assert_eq!(
1582            collected
1583                .get("CARGO_BUILD_RUSTC_WRAPPER")
1584                .map(String::as_str),
1585            Some("")
1586        );
1587        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1588        // No duplicate RUSTC_WRAPPER entries.
1589        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1590    }
1591
1592    #[test]
1593    fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
1594        let workspace = tempfile::tempdir().unwrap();
1595        let policy = CapabilityPolicy {
1596            sandbox_profile: SandboxProfile::Worktree,
1597            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1598            ..CapabilityPolicy::default()
1599        };
1600
1601        let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
1602
1603        // The temp dir is created, lives under the writable workspace root, and
1604        // is named by the documented convention.
1605        assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
1606        assert!(
1607            path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
1608            "temp dir {tmpdir:?} must be inside the writable workspace root"
1609        );
1610        assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
1611        // It self-ignores so its churn never shows in a git diff.
1612        let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
1613        assert!(
1614            ignore.lines().any(|line| line.trim() == "*"),
1615            "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
1616        );
1617        // It is within the sandbox's writable scope: a write under it passes the
1618        // same path-scope check the OS sandbox enforces.
1619        push_execution_policy(policy);
1620        assert!(
1621            check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
1622            "writes under the workspace-local temp dir must be in sandbox scope"
1623        );
1624        pop_execution_policy();
1625    }
1626
1627    #[test]
1628    fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
1629        // The unrestricted profile short-circuits the injection helper: an
1630        // unsandboxed child keeps whatever TMPDIR it would otherwise inherit.
1631        let policy = CapabilityPolicy {
1632            sandbox_profile: SandboxProfile::Unrestricted,
1633            workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
1634            ..CapabilityPolicy::default()
1635        };
1636        let mut env = Vec::new();
1637        inject_workspace_tmpdir(&mut env, &policy);
1638        assert!(
1639            env.is_empty(),
1640            "unrestricted profile must not inject a TMPDIR override, got {env:?}"
1641        );
1642    }
1643
1644    #[test]
1645    fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
1646        let workspace = tempfile::tempdir().unwrap();
1647        let policy = CapabilityPolicy {
1648            sandbox_profile: SandboxProfile::Worktree,
1649            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1650            ..CapabilityPolicy::default()
1651        };
1652        let mut env = Vec::new();
1653        inject_workspace_tmpdir(&mut env, &policy);
1654
1655        let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
1656        let expected = workspace_local_tmpdir(&policy)
1657            .unwrap()
1658            .display()
1659            .to_string();
1660        for key in TMPDIR_ENV_KEYS {
1661            assert_eq!(
1662                collected.get(key).map(String::as_str),
1663                Some(expected.as_str()),
1664                "{key} must point at the workspace-local temp dir"
1665            );
1666        }
1667    }
1668
1669    #[test]
1670    fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
1671        let env: std::collections::BTreeMap<_, _> =
1672            deterministic_message_locale_env().into_iter().collect();
1673        // gettext tools (gcc/clang, git-l10n, coreutils, gradle) honor
1674        // LC_MESSAGES; `C` yields untranslated English.
1675        assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
1676        // .NET ignores LC_* and localizes from its own variable.
1677        assert_eq!(
1678            env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
1679            Some("en")
1680        );
1681        // Deliberately NOT setting LC_ALL/LC_CTYPE/LANG so UTF-8 handling of
1682        // non-ASCII source and identifiers is preserved (unlike `LC_ALL=C`).
1683        assert!(
1684            !env.contains_key("LC_ALL"),
1685            "must not force LC_ALL (would clobber UTF-8 ctype)"
1686        );
1687        assert!(!env.contains_key("LC_CTYPE"));
1688        assert!(!env.contains_key("LANG"));
1689        // The override-strip constant names the one variable that would defeat
1690        // LC_MESSAGES if inherited.
1691        assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
1692    }
1693
1694    #[test]
1695    fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
1696        let workspace = tempfile::tempdir().unwrap();
1697        let policy = CapabilityPolicy {
1698            sandbox_profile: SandboxProfile::Worktree,
1699            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1700            ..CapabilityPolicy::default()
1701        };
1702        // Caller already pinned TMPDIR; only the untouched siblings get filled.
1703        let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
1704        inject_workspace_tmpdir(&mut env, &policy);
1705
1706        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1707        assert_eq!(
1708            collected.get("TMPDIR").map(String::as_str),
1709            Some("/caller/explicit/tmp"),
1710            "an explicit caller TMPDIR must be preserved untouched"
1711        );
1712        let expected = workspace_local_tmpdir(&policy)
1713            .unwrap()
1714            .display()
1715            .to_string();
1716        assert_eq!(
1717            collected.get("TMP").map(String::as_str),
1718            Some(expected.as_str())
1719        );
1720        assert_eq!(
1721            collected.get("TEMP").map(String::as_str),
1722            Some(expected.as_str())
1723        );
1724        // And no duplicate TMPDIR entry was appended.
1725        assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
1726    }
1727
1728    #[test]
1729    fn sandboxed_process_config_injects_workspace_tmpdir() {
1730        let workspace = tempfile::tempdir().unwrap();
1731        let policy = CapabilityPolicy {
1732            sandbox_profile: SandboxProfile::Worktree,
1733            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1734            ..CapabilityPolicy::default()
1735        };
1736        let config = ProcessCommandConfig {
1737            cwd: Some(workspace.path().to_path_buf()),
1738            ..ProcessCommandConfig::default()
1739        };
1740        let resolved = sandboxed_process_config(&config, &policy).unwrap();
1741        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1742        let expected = workspace_local_tmpdir(&policy)
1743            .unwrap()
1744            .display()
1745            .to_string();
1746        assert_eq!(
1747            env.get("TMPDIR").map(String::as_str),
1748            Some(expected.as_str()),
1749            "the command_output path must inject a workspace-local TMPDIR"
1750        );
1751    }
1752
1753    #[test]
1754    fn read_only_root_outside_workspace_allows_read_denies_write() {
1755        // Models an embedder (burin's in-process TUI) that grants a
1756        // read-only root R holding bundled pipelines/partials outside the
1757        // user's writable workspace. A read under R passes; a write under R
1758        // is denied; a read outside both R and the workspace is denied.
1759        let workspace = tempfile::tempdir().unwrap();
1760        let read_only = tempfile::tempdir().unwrap();
1761        push_execution_policy(CapabilityPolicy {
1762            sandbox_profile: SandboxProfile::Worktree,
1763            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1764            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1765            ..CapabilityPolicy::default()
1766        });
1767
1768        let asset = read_only
1769            .path()
1770            .join("partials/agent-web-tools.harn.prompt");
1771        // READ under the read-only root is allowed.
1772        assert!(
1773            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1774            "read under a configured read-only root must be allowed"
1775        );
1776
1777        // WRITE under the read-only root is denied, flagged read_only.
1778        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1779            .expect_err("write under a read-only root must be denied");
1780        assert!(write_err.read_only, "write rejection must set read_only");
1781
1782        // DELETE under the read-only root is likewise denied.
1783        assert!(
1784            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1785            "delete under a read-only root must be denied"
1786        );
1787
1788        // A read inside the writable workspace still passes.
1789        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1790
1791        // A read outside BOTH the workspace and the read-only root is denied
1792        // and is NOT flagged read_only (it fell outside every root).
1793        let stranger = tempfile::tempdir().unwrap();
1794        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1795            .expect_err("read outside all roots must be denied");
1796        assert!(
1797            !outside_err.read_only,
1798            "out-of-scope rejection must not be flagged read_only"
1799        );
1800
1801        pop_execution_policy();
1802    }
1803
1804    #[cfg(unix)]
1805    #[test]
1806    fn standard_io_device_files_allowed_under_restricted_profile() {
1807        // Writing to the standard process I/O streams is not a workspace
1808        // mutation, so a restricted profile with a workspace root that does
1809        // not contain /dev must still allow them — while a genuine
1810        // out-of-root write is still rejected.
1811        let workspace = tempfile::tempdir().unwrap();
1812        push_execution_policy(CapabilityPolicy {
1813            sandbox_profile: SandboxProfile::Worktree,
1814            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1815            ..CapabilityPolicy::default()
1816        });
1817
1818        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1819            assert!(
1820                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1821                "write to standard device {device} must be allowed"
1822            );
1823            // Reads of the same devices are likewise allowed.
1824            assert!(
1825                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1826                "read of standard device {device} must be allowed"
1827            );
1828        }
1829        assert!(
1830            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1831            "read of standard device /dev/stdin must be allowed"
1832        );
1833        assert!(
1834            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1835            "write to /dev/stdin is not a standard output stream"
1836        );
1837        assert!(
1838            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1839            "standard devices must not bypass delete scoping"
1840        );
1841        // Numeric /dev/fd/<N> descriptors are allowed.
1842        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1843        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1844
1845        // A non-device path outside the workspace is still rejected.
1846        let stranger = tempfile::tempdir().unwrap();
1847        assert!(
1848            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1849            "a real out-of-root write must still be rejected"
1850        );
1851        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
1852        assert!(
1853            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1854            "/dev/sda must not be allowed by the standard-device allowlist"
1855        );
1856        assert!(
1857            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1858            "non-numeric /dev/fd/<x> must not be allowed"
1859        );
1860
1861        pop_execution_policy();
1862    }
1863
1864    #[test]
1865    fn is_standard_io_device_matches_only_known_streams() {
1866        assert!(is_standard_io_device_for_access(
1867            Path::new("/dev/stdin"),
1868            FsAccess::Read
1869        ));
1870        assert!(!is_standard_io_device_for_access(
1871            Path::new("/dev/stdin"),
1872            FsAccess::Write
1873        ));
1874        assert!(is_standard_io_device_for_access(
1875            Path::new("/dev/stdout"),
1876            FsAccess::Write
1877        ));
1878        assert!(is_standard_io_device_for_access(
1879            Path::new("/dev/stderr"),
1880            FsAccess::Write
1881        ));
1882        assert!(is_standard_io_device_for_access(
1883            Path::new("/dev/null"),
1884            FsAccess::Write
1885        ));
1886        assert!(is_standard_io_device_for_access(
1887            Path::new("/dev/fd/0"),
1888            FsAccess::Read
1889        ));
1890        assert!(is_standard_io_device_for_access(
1891            Path::new("/dev/fd/12"),
1892            FsAccess::Write
1893        ));
1894        assert!(!is_standard_io_device_for_access(
1895            Path::new("/dev/null"),
1896            FsAccess::Delete
1897        ));
1898        assert!(!is_standard_io_device_for_access(
1899            Path::new("/dev/fd/"),
1900            FsAccess::Write
1901        ));
1902        assert!(!is_standard_io_device_for_access(
1903            Path::new("/dev/fd/1a"),
1904            FsAccess::Write
1905        ));
1906        assert!(!is_standard_io_device_for_access(
1907            Path::new("/dev/stdoutx"),
1908            FsAccess::Write
1909        ));
1910        assert!(!is_standard_io_device_for_access(
1911            Path::new("/dev/random"),
1912            FsAccess::Read
1913        ));
1914        assert!(!is_standard_io_device_for_access(
1915            Path::new("/tmp/dev/null"),
1916            FsAccess::Write
1917        ));
1918    }
1919
1920    #[test]
1921    fn path_within_root_accepts_root_and_children() {
1922        let root = Path::new("/tmp/harn-root");
1923        assert!(path_is_within(root, root));
1924        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1925        assert!(!path_is_within(
1926            Path::new("/tmp/harn-root-other/file"),
1927            root
1928        ));
1929    }
1930
1931    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1932    #[test]
1933    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1934        let temp_home = tempfile::tempdir().expect("temp home");
1935        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1936        let normalized_home = normalize_for_policy(temp_home.path());
1937
1938        for suffix in [
1939            Path::new(".cargo"),
1940            Path::new(".rustup"),
1941            Path::new(".pyenv"),
1942            Path::new(".nvm"),
1943            Path::new(".volta"),
1944            Path::new(".local/share/uv"),
1945            Path::new("go"),
1946        ] {
1947            assert!(
1948                roots.iter().any(|path| path.ends_with(suffix)),
1949                "expected a developer-toolchain grant for {}",
1950                suffix.display()
1951            );
1952        }
1953        assert!(
1954            roots.iter().all(|path| path.starts_with(&normalized_home)),
1955            "developer-toolchain roots must stay under HOME"
1956        );
1957    }
1958
1959    #[cfg(any(target_os = "linux", target_os = "macos"))]
1960    #[test]
1961    fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
1962        let temp_home = tempfile::tempdir().expect("temp home");
1963        let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
1964        let normalized_home = normalize_for_policy(temp_home.path());
1965
1966        for suffix in [
1967            Path::new(".gradle"),
1968            Path::new(".m2"),
1969            Path::new(".konan"),
1970            Path::new("Library/Caches/CocoaPods"),
1971            Path::new("Library/Developer/Xcode/DerivedData"),
1972        ] {
1973            assert!(
1974                roots.iter().any(|path| path.ends_with(suffix)),
1975                "expected a JVM/iOS toolchain cache grant for {}",
1976                suffix.display()
1977            );
1978        }
1979        assert!(
1980            roots.iter().all(|path| path.starts_with(&normalized_home)),
1981            "toolchain cache roots must stay under HOME"
1982        );
1983    }
1984
1985    #[cfg(any(target_os = "linux", target_os = "macos"))]
1986    #[test]
1987    fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
1988        let mut policy = CapabilityPolicy {
1989            workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1990            ..CapabilityPolicy::default()
1991        };
1992        // Default presets include DeveloperToolchains -> cache roots present
1993        // (only when an absolute HOME is resolvable on this host).
1994        if sandbox_user_home_dir().is_some() {
1995            assert!(
1996                !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
1997                "default presets should render JVM/iOS cache roots"
1998            );
1999        }
2000        // Explicitly dropping DeveloperToolchains removes them.
2001        policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2002        assert!(
2003            process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2004            "cache roots must be gated on the DeveloperToolchains preset"
2005        );
2006    }
2007
2008    #[test]
2009    fn os_hardened_profile_overrides_fallback_env() {
2010        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
2011        // point of the profile is that the OS sandbox is required.
2012        // We cannot mutate the env here without races, so just check
2013        // the pure resolution function.
2014        assert_eq!(
2015            effective_fallback(SandboxProfile::OsHardened),
2016            SandboxFallback::Enforce
2017        );
2018    }
2019
2020    #[test]
2021    fn unrestricted_profile_skips_active_sandbox() {
2022        let policy = CapabilityPolicy {
2023            sandbox_profile: SandboxProfile::Unrestricted,
2024            workspace_roots: vec!["/tmp".to_string()],
2025            ..Default::default()
2026        };
2027        crate::orchestration::push_execution_policy(policy);
2028        let result = active_sandbox_policy();
2029        crate::orchestration::pop_execution_policy();
2030        assert!(
2031            result.is_none(),
2032            "Unrestricted profile must short-circuit sandbox dispatch"
2033        );
2034    }
2035
2036    #[test]
2037    fn worktree_profile_engages_active_sandbox() {
2038        let policy = CapabilityPolicy {
2039            sandbox_profile: SandboxProfile::Worktree,
2040            workspace_roots: vec!["/tmp".to_string()],
2041            ..Default::default()
2042        };
2043        crate::orchestration::push_execution_policy(policy);
2044        let result = active_sandbox_policy();
2045        crate::orchestration::pop_execution_policy();
2046        assert!(
2047            result.is_some(),
2048            "Worktree profile must keep sandbox dispatch active"
2049        );
2050    }
2051}