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 allowlist 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::io;
40use std::io::Write as _;
41use std::path::{Component, Path, PathBuf};
42use std::process::{Command, Output, Stdio};
43
44#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
45use crate::orchestration::ProcessSandboxPreset;
46use crate::orchestration::{CapabilityPolicy, SandboxProfile};
47use crate::value::{ErrorCategory, VmError, VmValue};
48use crate::vm::Vm;
49
50#[cfg(target_os = "linux")]
51mod linux;
52#[cfg(target_os = "macos")]
53mod macos;
54#[cfg(target_os = "openbsd")]
55mod openbsd;
56#[cfg(target_os = "windows")]
57mod windows;
58
59const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
60
61thread_local! {
62    static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
63}
64
65/// The kind of filesystem access a path-scope check is guarding. This drives
66/// the verb rendered in rejection messages and the narrow standard-device
67/// exception; ordinary files are otherwise scoped by the same workspace roots.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub enum FsAccess {
70    Read,
71    Write,
72    Delete,
73}
74
75#[derive(Clone, Debug, Default)]
76pub struct ProcessCommandConfig {
77    pub cwd: Option<PathBuf>,
78    pub env: Vec<(String, String)>,
79    pub stdin_null: bool,
80}
81
82#[derive(Clone, Debug, Default)]
83pub struct ProcessSandboxScope {
84    pub workspace_roots: Vec<String>,
85}
86
87#[must_use]
88pub struct ProcessSandboxScopeGuard {
89    pushed: bool,
90}
91
92impl Drop for ProcessSandboxScopeGuard {
93    fn drop(&mut self) {
94        if self.pushed {
95            crate::orchestration::pop_execution_policy();
96        }
97    }
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub(crate) enum SandboxFallback {
102    Off,
103    Warn,
104    Enforce,
105}
106
107/// Trait implemented once per supported host OS. Each backend knows
108/// how to attach the active capability ceiling to a `Command` /
109/// `tokio::process::Command`, or — on Windows where the standard
110/// process types cannot carry an AppContainer — how to drive an
111/// equivalent custom spawn that returns an `Output`.
112///
113/// One concrete implementation is selected at compile time via `cfg`
114/// gating in this module. Callers should not reach for the trait
115/// directly; the module-level `command_output` / `std_command_for` /
116/// `tokio_command_for` entry points dispatch through it.
117pub(crate) trait SandboxBackend {
118    /// Stable identifier used in diagnostics and conformance fixtures.
119    fn name() -> &'static str;
120
121    /// Whether the platform mechanism this backend uses is available
122    /// on the running host (e.g. Landlock kernel support, the
123    /// `/usr/bin/sandbox-exec` binary, AppContainer APIs).
124    fn available() -> bool;
125
126    /// Apply the per-spawn confinement to a [`std::process::Command`].
127    /// Returns `Ok(())` if the backend can attach inline (Linux
128    /// `pre_exec`, OpenBSD pledge/unveil), or
129    /// [`PrepareOutcome::WrappedExec`] when the spawn must be
130    /// re-routed through a wrapper binary (macOS `sandbox-exec`).
131    fn prepare_std_command(
132        program: &str,
133        args: &[String],
134        command: &mut Command,
135        policy: &CapabilityPolicy,
136        profile: SandboxProfile,
137    ) -> Result<PrepareOutcome, VmError>;
138
139    /// Same as [`prepare_std_command`], but for `tokio::process::Command`.
140    fn prepare_tokio_command(
141        program: &str,
142        args: &[String],
143        command: &mut tokio::process::Command,
144        policy: &CapabilityPolicy,
145        profile: SandboxProfile,
146    ) -> Result<PrepareOutcome, VmError>;
147
148    /// Direct spawn that returns the captured `Output`. Windows uses
149    /// this because AppContainer cannot be attached to a vanilla
150    /// `Command`; other platforms can fall back to the default
151    /// implementation that builds a `Command` and runs it.
152    fn run_to_output(
153        program: &str,
154        args: &[String],
155        config: &ProcessCommandConfig,
156        policy: &CapabilityPolicy,
157        profile: SandboxProfile,
158    ) -> Result<Output, VmError> {
159        let mut command = build_std_command::<Self>(program, args, policy, profile)?;
160        apply_process_config(&mut command, config);
161        crate::op_interrupt::capture_output_interruptible(&mut command)
162            .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
163    }
164}
165
166/// What [`SandboxBackend::prepare_std_command`] / `_tokio_command`
167/// produced: either the original spawn target with sandboxing applied
168/// inline, or a wrapper binary that should be invoked instead.
169pub(crate) enum PrepareOutcome {
170    /// Use the prepared command unchanged.
171    Direct,
172    /// Replace the spawn target with the wrapper binary and args
173    /// (e.g. `sandbox-exec -p '<profile>' -- <program> <args...>`).
174    /// Only macOS produces this today; on other platforms the variant
175    /// stays defined so the trait surface is portable, but the
176    /// build-time dead-code lint would otherwise flip.
177    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
178    WrappedExec { wrapper: String, args: Vec<String> },
179}
180
181#[cfg(target_os = "linux")]
182type ActiveBackend = linux::Backend;
183#[cfg(target_os = "macos")]
184type ActiveBackend = macos::Backend;
185#[cfg(target_os = "openbsd")]
186type ActiveBackend = openbsd::Backend;
187#[cfg(target_os = "windows")]
188type ActiveBackend = windows::Backend;
189#[cfg(not(any(
190    target_os = "linux",
191    target_os = "macos",
192    target_os = "openbsd",
193    target_os = "windows"
194)))]
195type ActiveBackend = NoopBackend;
196
197#[cfg(not(any(
198    target_os = "linux",
199    target_os = "macos",
200    target_os = "openbsd",
201    target_os = "windows"
202)))]
203pub(crate) struct NoopBackend;
204
205#[cfg(not(any(
206    target_os = "linux",
207    target_os = "macos",
208    target_os = "openbsd",
209    target_os = "windows"
210)))]
211impl SandboxBackend for NoopBackend {
212    fn name() -> &'static str {
213        "noop"
214    }
215    fn available() -> bool {
216        false
217    }
218    fn prepare_std_command(
219        _program: &str,
220        _args: &[String],
221        _command: &mut Command,
222        _policy: &CapabilityPolicy,
223        _profile: SandboxProfile,
224    ) -> Result<PrepareOutcome, VmError> {
225        Ok(PrepareOutcome::Direct)
226    }
227    fn prepare_tokio_command(
228        _program: &str,
229        _args: &[String],
230        _command: &mut tokio::process::Command,
231        _policy: &CapabilityPolicy,
232        _profile: SandboxProfile,
233    ) -> Result<PrepareOutcome, VmError> {
234        Ok(PrepareOutcome::Direct)
235    }
236}
237
238pub(crate) fn reset_sandbox_state() {
239    WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
240}
241
242/// Stable identifier for the platform sandbox backend selected at
243/// compile time. Surfaced for diagnostics and conformance fixtures so
244/// callers can record which backend produced a recorded run.
245pub fn active_backend_name() -> &'static str {
246    ActiveBackend::name()
247}
248
249/// Whether the platform mechanism backing the active sandbox backend
250/// is available on the running host. Used by conformance fixtures and
251/// the `harn doctor` flow to skip OS-hardened checks on hosts without
252/// the required kernel support.
253pub fn active_backend_available() -> bool {
254    ActiveBackend::available()
255}
256
257/// Register Harn-callable introspection builtins for the sandbox.
258/// Intended for diagnostics, `harn doctor`, and conformance fixtures —
259/// not as a way to mutate runtime sandbox behavior from a script.
260pub fn register_sandbox_builtins(vm: &mut Vm) {
261    for def in MODULE_BUILTINS {
262        vm.register_builtin_def(def);
263    }
264}
265
266pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
267    &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
268    &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
269    &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
270];
271
272#[crate::stdlib::macros::harn_builtin(
273    sig = "sandbox_active_backend() -> string",
274    category = "sandbox"
275)]
276fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277    Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
278}
279
280#[crate::stdlib::macros::harn_builtin(
281    sig = "sandbox_backend_available() -> bool",
282    category = "sandbox"
283)]
284fn sandbox_backend_available_impl(
285    _args: &[VmValue],
286    _out: &mut String,
287) -> Result<VmValue, VmError> {
288    Ok(VmValue::Bool(active_backend_available()))
289}
290
291#[crate::stdlib::macros::harn_builtin(
292    sig = "sandbox_active_profile() -> string",
293    category = "sandbox"
294)]
295fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
296    let profile = crate::orchestration::current_execution_policy()
297        .map(|policy| policy.sandbox_profile)
298        .unwrap_or(SandboxProfile::Unrestricted);
299    Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
300}
301
302/// A workspace-root scope violation: a path that resolved outside every
303/// configured workspace root under a restricted [`SandboxProfile`].
304///
305/// This is the `VmError`-free shape returned by [`check_fs_path_scope`] so
306/// that crates outside `harn-vm` (today: `harn-hostlib`) can enforce the
307/// same scope policy and render the violation onto their own error type.
308#[derive(Clone, Debug)]
309pub struct SandboxViolation {
310    /// The path the call attempted to touch, normalized against the
311    /// active policy (CWD-relative paths resolved to absolute, `..`
312    /// collapsed, symlinks canonicalized where the path exists).
313    pub attempted: PathBuf,
314    /// The writable workspace roots the path was checked against,
315    /// normalized the same way as `attempted`.
316    pub roots: Vec<PathBuf>,
317    /// Whether the rejected access was a read, write, or delete.
318    pub access: FsAccess,
319    /// True when the path resolved *inside* a read-only root: it is in
320    /// scope for reads, and only the attempted mutation is denied. False
321    /// when the path fell outside every configured root entirely.
322    pub read_only: bool,
323}
324
325impl SandboxViolation {
326    /// Render the canonical rejection message. Matches the text produced
327    /// by [`enforce_fs_path`] so the `harness.fs.*` and hostlib surfaces
328    /// reject an out-of-root path identically.
329    pub fn message(&self, builtin: &str) -> String {
330        if self.read_only {
331            return format!(
332                "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
333                self.access.verb(),
334                self.attempted.display(),
335            );
336        }
337        format!(
338            "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
339            self.access.verb(),
340            self.attempted.display(),
341            self.roots
342                .iter()
343                .map(|root| root.display().to_string())
344                .collect::<Vec<_>>()
345                .join(", ")
346        )
347    }
348}
349
350/// Check whether `path` is inside the active policy's workspace roots.
351///
352/// Returns `Ok(())` when no execution policy is active, when the active
353/// profile is [`SandboxProfile::Unrestricted`], when the normalized path
354/// falls within a writable workspace root, or — for [`FsAccess::Read`]
355/// only — when it falls within a read-only root. A write/delete that
356/// resolves under a read-only root is rejected with `read_only` set, as
357/// is any access that falls outside every configured root.
358///
359/// This is the public, `VmError`-free entry point embedders use to apply
360/// workspace-root scoping to their own host calls. The in-crate
361/// `harness.fs.*` builtins funnel through [`enforce_fs_path`], which wraps
362/// this with a `VmError`; both share the same path normalization and
363/// rejection text.
364pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
365    let Some(policy) = crate::orchestration::current_execution_policy() else {
366        return Ok(());
367    };
368    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
369        return Ok(());
370    }
371    // Standard process I/O device files are not workspace filesystem
372    // mutations: writing to /dev/stdout, /dev/stderr, or /dev/null (and the
373    // numeric /dev/fd/<N> descriptors they alias) targets the process's own
374    // output streams, not the sandboxed tree. A pipeline that falls back to
375    // /dev/stdout for debug output must not read as a sandbox violation, so
376    // allow these regardless of the configured roots. Matched on the
377    // lexically-normalized path (not the canonicalized form): canonicalize()
378    // rewrites /dev/stdout to a per-process /dev/fd/<…>.output alias that no
379    // longer looks like a standard device. Kept deliberately narrow — only
380    // the well-known device files, no broader /dev access.
381    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
382        return Ok(());
383    }
384    let candidate = normalize_for_policy(path);
385    let roots = normalized_workspace_roots(&policy);
386    if roots.iter().any(|root| path_is_within(&candidate, root)) {
387        return Ok(());
388    }
389    let read_only_roots = normalized_read_only_roots(&policy);
390    let within_read_only = read_only_roots
391        .iter()
392        .any(|root| path_is_within(&candidate, root));
393    if within_read_only && access == FsAccess::Read {
394        return Ok(());
395    }
396    Err(SandboxViolation {
397        attempted: candidate,
398        roots,
399        access,
400        read_only: within_read_only,
401    })
402}
403
404pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
405    check_fs_path_scope(path, access)
406        .map_err(|violation| sandbox_rejection(violation.message(builtin)))
407}
408
409pub(crate) fn atomic_write_scoped_at_open(
410    builtin: &str,
411    path: &Path,
412    contents: &[u8],
413) -> io::Result<()> {
414    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
415        return atomic_write_unscoped(path, contents);
416    };
417    atomic_write_scoped_target(&target, contents)
418}
419
420pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
421    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
422        return append_unscoped(path, contents);
423    };
424    append_scoped_target(&target, contents)
425}
426
427pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
428    let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
429        return std::fs::copy(src, dst);
430    };
431    copy_scoped_target(src, &target)
432}
433
434pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
435    let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
436        return std::fs::rename(src, dst);
437    };
438    let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
439        io::Error::new(
440            io::ErrorKind::PermissionDenied,
441            format!(
442                "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
443                dst.display()
444            ),
445        )
446    })?;
447    rename_scoped_targets(&src_target, &dst_target)
448}
449
450pub(crate) fn create_dir_scoped_at_open(
451    builtin: &str,
452    path: &Path,
453    recursive: bool,
454) -> io::Result<()> {
455    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
456        return if recursive {
457            std::fs::create_dir_all(path)
458        } else {
459            std::fs::create_dir(path)
460        };
461    };
462    if recursive {
463        create_dir_all_scoped_target(&target)
464    } else {
465        create_dir_scoped_target(&target)
466    }
467}
468
469#[derive(Clone, Debug)]
470struct ScopedMutationTarget {
471    root: PathBuf,
472    relative: PathBuf,
473}
474
475fn scoped_mutation_target(
476    builtin: &str,
477    path: &Path,
478    access: FsAccess,
479) -> io::Result<Option<ScopedMutationTarget>> {
480    let Some(policy) = crate::orchestration::current_execution_policy() else {
481        return Ok(None);
482    };
483    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
484        return Ok(None);
485    }
486    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
487        return Ok(None);
488    }
489    check_fs_path_scope(path, access).map_err(|violation| {
490        io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
491    })?;
492    let candidate = normalize_for_policy(path);
493    let roots = normalized_workspace_roots(&policy);
494    let Some(root) = roots
495        .into_iter()
496        .find(|root| path_is_within(&candidate, root))
497    else {
498        return Err(io::Error::new(
499            io::ErrorKind::PermissionDenied,
500            format!(
501                "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
502                access.verb(),
503                candidate.display()
504            ),
505        ));
506    };
507    let relative = candidate.strip_prefix(&root).map_err(|_| {
508        io::Error::new(
509            io::ErrorKind::PermissionDenied,
510            format!(
511                "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
512                access.verb(),
513                candidate.display(),
514                root.display()
515            ),
516        )
517    })?;
518    if relative.as_os_str().is_empty() {
519        return Err(io::Error::new(
520            io::ErrorKind::InvalidInput,
521            format!(
522                "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
523                access.verb(),
524                root.display()
525            ),
526        ));
527    }
528    Ok(Some(ScopedMutationTarget {
529        root,
530        relative: relative.to_path_buf(),
531    }))
532}
533
534fn atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
535    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
536    let dir = parent.unwrap_or_else(|| Path::new("."));
537    // Restore the pre-hardening `mkdir -p` contract for content-producing
538    // writes: an unrestricted (no active sandbox scope) write into a
539    // not-yet-created directory recreates its ancestor chain, matching the
540    // scoped path's `ensure_parent_dirs_scoped`.
541    if let Some(parent) = parent {
542        std::fs::create_dir_all(parent)?;
543    }
544    let tmp_path = dir.join(scoped_tmp_name(path));
545    let write_result = (|| -> io::Result<()> {
546        let mut file = std::fs::File::create(&tmp_path)?;
547        file.write_all(contents)?;
548        file.flush()?;
549        file.sync_all()?;
550        Ok(())
551    })();
552    if let Err(err) = write_result {
553        let _ = std::fs::remove_file(&tmp_path);
554        return Err(err);
555    }
556    if let Err(err) = std::fs::rename(&tmp_path, path) {
557        let _ = std::fs::remove_file(&tmp_path);
558        return Err(err);
559    }
560    Ok(())
561}
562
563fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
564    // Match the `append_file` contract: appending to a new log in a
565    // not-yet-created directory recreates the parent chain.
566    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
567        std::fs::create_dir_all(parent)?;
568    }
569    std::fs::OpenOptions::new()
570        .create(true)
571        .append(true)
572        .open(path)
573        .and_then(|mut file| file.write_all(contents))
574}
575
576fn scoped_tmp_name(path: &Path) -> String {
577    use std::sync::atomic::{AtomicU64, Ordering};
578    static COUNTER: AtomicU64 = AtomicU64::new(0);
579    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
580    let file_name = path
581        .file_name()
582        .map(|name| name.to_string_lossy().into_owned())
583        .unwrap_or_else(|| "file".to_string());
584    format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id())
585}
586
587#[cfg(unix)]
588fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
589    use std::os::fd::AsRawFd;
590
591    // Content-producing writes recreate their parent chain (`mkdir -p`),
592    // restoring the pre-hardening `write_file`/`http_download` contract that
593    // downstream `.harn` relies on. The creation stays inside the scope root
594    // and reuses the same symlink-safe parent-fd walk as the write itself.
595    ensure_parent_dirs_scoped(target)?;
596    let (parent, file_name) = open_parent_dir_scoped(target)?;
597    let tmp_name = scoped_tmp_name(Path::new(&file_name));
598    let mut file = openat_file(
599        parent.as_raw_fd(),
600        &tmp_name,
601        libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
602        0o666,
603    )?;
604    let write_result = (|| -> io::Result<()> {
605        file.write_all(contents)?;
606        file.flush()?;
607        file.sync_all()?;
608        Ok(())
609    })();
610    if let Err(err) = write_result {
611        let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
612        return Err(err);
613    }
614    if let Err(err) = renameat_name(
615        parent.as_raw_fd(),
616        &tmp_name,
617        parent.as_raw_fd(),
618        &file_name,
619    ) {
620        let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
621        return Err(err);
622    }
623    sync_dir_fd(parent.as_raw_fd());
624    Ok(())
625}
626
627#[cfg(not(unix))]
628fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
629    let full = target.root.join(&target.relative);
630    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
631        std::fs::create_dir_all(parent)?;
632    }
633    atomic_write_unscoped(&full, contents)
634}
635
636#[cfg(unix)]
637fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
638    use std::os::fd::AsRawFd;
639
640    // Append creates the file (and its parent chain) when absent, matching the
641    // pre-hardening `append_file` contract (append-to-a-new-log-in-a-new-dir).
642    ensure_parent_dirs_scoped(target)?;
643    let (parent, file_name) = open_parent_dir_scoped(target)?;
644    let mut file = openat_file(
645        parent.as_raw_fd(),
646        &file_name,
647        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
648        0o666,
649    )?;
650    file.write_all(contents)
651}
652
653#[cfg(not(unix))]
654fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
655    let full = target.root.join(&target.relative);
656    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
657        std::fs::create_dir_all(parent)?;
658    }
659    append_unscoped(&full, contents)
660}
661
662#[cfg(unix)]
663fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
664    use std::os::fd::AsRawFd;
665
666    let mut source = std::fs::File::open(src)?;
667    let source_metadata = source.metadata().ok();
668    let (parent, file_name) = open_parent_dir_scoped(target)?;
669    let mut destination = openat_file(
670        parent.as_raw_fd(),
671        &file_name,
672        libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
673        0o666,
674    )?;
675    let copied = io::copy(&mut source, &mut destination)?;
676    destination.sync_all()?;
677    if let Some(metadata) = source_metadata {
678        let _ = destination.set_permissions(metadata.permissions());
679    }
680    sync_dir_fd(parent.as_raw_fd());
681    Ok(copied)
682}
683
684#[cfg(not(unix))]
685fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
686    std::fs::copy(src, target.root.join(&target.relative))
687}
688
689#[cfg(unix)]
690fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
691    use std::os::fd::AsRawFd;
692
693    let (src_parent, src_name) = open_parent_dir_scoped(src)?;
694    let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
695    renameat_name(
696        src_parent.as_raw_fd(),
697        &src_name,
698        dst_parent.as_raw_fd(),
699        &dst_name,
700    )?;
701    sync_dir_fd(dst_parent.as_raw_fd());
702    Ok(())
703}
704
705#[cfg(not(unix))]
706fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
707    std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
708}
709
710#[cfg(unix)]
711fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
712    use std::os::fd::AsRawFd;
713
714    let (parent, file_name) = open_parent_dir_scoped(target)?;
715    mkdirat_name(parent.as_raw_fd(), &file_name)?;
716    sync_dir_fd(parent.as_raw_fd());
717    Ok(())
718}
719
720#[cfg(not(unix))]
721fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
722    std::fs::create_dir(target.root.join(&target.relative))
723}
724
725#[cfg(unix)]
726fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
727    use std::os::fd::AsRawFd;
728
729    let root = open_dir_absolute(&target.root)?;
730    let mut current = root;
731    for component in clean_relative_components(&target.relative)? {
732        match open_dir_at(current.as_raw_fd(), &component) {
733            Ok(next) => current = next,
734            Err(error) if error.kind() == io::ErrorKind::NotFound => {
735                mkdirat_name(current.as_raw_fd(), &component)?;
736                let next = open_dir_at(current.as_raw_fd(), &component)?;
737                current = next;
738            }
739            Err(error) => return Err(error),
740        }
741    }
742    Ok(())
743}
744
745#[cfg(not(unix))]
746fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
747    std::fs::create_dir_all(target.root.join(&target.relative))
748}
749
750#[cfg(unix)]
751/// Create the ancestor directory chain of a scoped write/append target,
752/// mirroring the pre-hardening `mkdir -p` behavior of the content-producing
753/// filesystem builtins (`write_file`, `write_file_bytes`, `append_file`) and
754/// `http_download`. Only the ancestors are created — the final path component
755/// is the file the caller writes. Traversal stays scoped to `target.root` and
756/// symlink-safe (each level is opened with `O_NOFOLLOW` via `open_dir_at`), so
757/// this preserves the security properties #4147 added while restoring the
758/// directory-autovivification contract downstream code depends on. Concurrent
759/// creators are tolerated (a losing `mkdirat` that sees `EEXIST` is ignored).
760///
761/// Structural operations (copy destination, rename, remove, single `mkdir`)
762/// intentionally do NOT call this — they keep `open_parent_dir_scoped`'s
763/// "parent must already exist" semantics.
764#[cfg(unix)]
765fn ensure_parent_dirs_scoped(target: &ScopedMutationTarget) -> io::Result<()> {
766    use std::os::fd::AsRawFd;
767
768    let mut components = clean_relative_components(&target.relative)?;
769    // Drop the file-name component; only the ancestor directories are created.
770    // An empty remainder means the parent is the (already-existing) scope root.
771    if components.pop().is_none() || components.is_empty() {
772        return Ok(());
773    }
774    let mut current = open_dir_absolute(&target.root)?;
775    for component in components {
776        match open_dir_at(current.as_raw_fd(), &component) {
777            Ok(next) => current = next,
778            Err(error) if error.kind() == io::ErrorKind::NotFound => {
779                if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
780                    // A concurrent writer may have created it between our
781                    // failed open and this mkdir; that is a success for us.
782                    if mkerr.kind() != io::ErrorKind::AlreadyExists {
783                        return Err(mkerr);
784                    }
785                }
786                current = open_dir_at(current.as_raw_fd(), &component)?;
787            }
788            Err(error) => return Err(error),
789        }
790    }
791    Ok(())
792}
793
794#[cfg(unix)]
795fn open_parent_dir_scoped(
796    target: &ScopedMutationTarget,
797) -> io::Result<(std::os::fd::OwnedFd, String)> {
798    use std::os::fd::AsRawFd;
799
800    let mut components = clean_relative_components(&target.relative)?;
801    let file_name = components.pop().ok_or_else(|| {
802        io::Error::new(
803            io::ErrorKind::InvalidInput,
804            format!(
805                "sandbox scoped open requires a file name: {}",
806                target.relative.display()
807            ),
808        )
809    })?;
810    let root = open_dir_absolute(&target.root)?;
811    let mut current = root;
812    for component in components {
813        current = open_dir_at(current.as_raw_fd(), &component)?;
814    }
815    Ok((current, file_name))
816}
817
818#[cfg(unix)]
819fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
820    use std::os::unix::ffi::OsStrExt;
821
822    let mut out = Vec::new();
823    for component in path.components() {
824        match component {
825            Component::Normal(value) => {
826                let bytes = value.as_bytes();
827                if bytes.contains(&0) {
828                    return Err(io::Error::new(
829                        io::ErrorKind::InvalidInput,
830                        format!("path component contains NUL: {}", path.display()),
831                    ));
832                }
833                out.push(value.to_string_lossy().into_owned());
834            }
835            Component::CurDir => {}
836            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
837                return Err(io::Error::new(
838                    io::ErrorKind::InvalidInput,
839                    format!("sandbox scoped path must stay relative: {}", path.display()),
840                ));
841            }
842        }
843    }
844    Ok(out)
845}
846
847#[cfg(unix)]
848fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
849    use std::os::fd::{FromRawFd, OwnedFd};
850    use std::os::unix::ffi::OsStrExt;
851
852    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
853        io::Error::new(
854            io::ErrorKind::InvalidInput,
855            format!("path contains NUL: {}", path.display()),
856        )
857    })?;
858    let fd = unsafe {
859        libc::open(
860            c_path.as_ptr(),
861            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
862        )
863    };
864    if fd < 0 {
865        return Err(io::Error::last_os_error());
866    }
867    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
868}
869
870#[cfg(unix)]
871fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
872    use std::os::fd::{FromRawFd, OwnedFd};
873
874    let c_name = c_name(name)?;
875    let fd = unsafe {
876        libc::openat(
877            parent_fd,
878            c_name.as_ptr(),
879            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
880        )
881    };
882    if fd < 0 {
883        return Err(io::Error::last_os_error());
884    }
885    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
886}
887
888#[cfg(unix)]
889fn openat_file(
890    parent_fd: libc::c_int,
891    name: &str,
892    flags: libc::c_int,
893    mode: libc::mode_t,
894) -> io::Result<std::fs::File> {
895    use std::os::fd::FromRawFd;
896
897    let c_name = c_name(name)?;
898    let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
899    if fd < 0 {
900        return Err(io::Error::last_os_error());
901    }
902    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
903}
904
905#[cfg(unix)]
906fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
907    let c_name = c_name(name)?;
908    let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
909    if rc != 0 {
910        return Err(io::Error::last_os_error());
911    }
912    Ok(())
913}
914
915#[cfg(unix)]
916fn renameat_name(
917    old_parent_fd: libc::c_int,
918    old_name: &str,
919    new_parent_fd: libc::c_int,
920    new_name: &str,
921) -> io::Result<()> {
922    let old_name = c_name(old_name)?;
923    let new_name = c_name(new_name)?;
924    let rc = unsafe {
925        libc::renameat(
926            old_parent_fd,
927            old_name.as_ptr(),
928            new_parent_fd,
929            new_name.as_ptr(),
930        )
931    };
932    if rc != 0 {
933        return Err(io::Error::last_os_error());
934    }
935    Ok(())
936}
937
938#[cfg(unix)]
939fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
940    let c_name = c_name(name)?;
941    let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
942    if rc != 0 {
943        return Err(io::Error::last_os_error());
944    }
945    Ok(())
946}
947
948#[cfg(unix)]
949fn sync_dir_fd(fd: libc::c_int) {
950    let _ = unsafe { libc::fsync(fd) };
951}
952
953#[cfg(unix)]
954fn c_name(name: &str) -> io::Result<std::ffi::CString> {
955    std::ffi::CString::new(name).map_err(|_| {
956        io::Error::new(
957            io::ErrorKind::InvalidInput,
958            format!("path component contains NUL: {name:?}"),
959        )
960    })
961}
962
963pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
964    let Some(policy) = crate::orchestration::current_execution_policy() else {
965        return Ok(());
966    };
967    enforce_process_cwd_for_policy(path, &policy)
968}
969
970pub fn push_process_sandbox_scope(
971    scope: ProcessSandboxScope,
972) -> Result<ProcessSandboxScopeGuard, VmError> {
973    let Some(mut policy) = crate::orchestration::current_execution_policy() else {
974        return Ok(ProcessSandboxScopeGuard { pushed: false });
975    };
976    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
977        return Ok(ProcessSandboxScopeGuard { pushed: false });
978    }
979
980    let requested_roots: Vec<PathBuf> = scope
981        .workspace_roots
982        .iter()
983        .filter_map(|root| {
984            let trimmed = root.trim();
985            (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
986        })
987        .collect();
988    if requested_roots.is_empty() {
989        return Ok(ProcessSandboxScopeGuard { pushed: false });
990    }
991
992    if !policy.workspace_roots.is_empty() {
993        let ceiling_roots = normalized_workspace_roots(&policy);
994        if let Some(rejected) = requested_roots.iter().find(|root| {
995            !ceiling_roots
996                .iter()
997                .any(|ceiling| path_is_within(root, ceiling))
998        }) {
999            return Err(sandbox_rejection(format!(
1000                "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1001                rejected.display(),
1002                ceiling_roots
1003                    .iter()
1004                    .map(|root| root.display().to_string())
1005                    .collect::<Vec<_>>()
1006                    .join(", ")
1007            )));
1008        }
1009    }
1010
1011    let mut merged_roots = if policy.workspace_roots.is_empty() {
1012        Vec::new()
1013    } else {
1014        normalized_workspace_roots(&policy)
1015    };
1016    for requested in requested_roots {
1017        if !merged_roots
1018            .iter()
1019            .any(|existing| path_is_within(&requested, existing))
1020        {
1021            merged_roots.push(requested);
1022        }
1023    }
1024    policy.workspace_roots = merged_roots
1025        .into_iter()
1026        .map(|root| root.display().to_string())
1027        .collect();
1028    crate::orchestration::push_execution_policy(policy);
1029    Ok(ProcessSandboxScopeGuard { pushed: true })
1030}
1031
1032fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1033    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1034        return Ok(());
1035    }
1036    let candidate = normalize_for_policy(path);
1037    let roots = normalized_workspace_roots(policy);
1038    if roots.iter().any(|root| path_is_within(&candidate, root)) {
1039        return Ok(());
1040    }
1041    Err(sandbox_rejection(format!(
1042        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1043        candidate.display(),
1044        roots
1045            .iter()
1046            .map(|root| root.display().to_string())
1047            .collect::<Vec<_>>()
1048            .join(", ")
1049    )))
1050}
1051
1052pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1053    let (policy, profile) = match active_sandbox_policy() {
1054        Some(value) => value,
1055        None => {
1056            let mut command = Command::new(program);
1057            command.args(args);
1058            return Ok(command);
1059        }
1060    };
1061    build_std_command::<ActiveBackend>(program, args, &policy, profile)
1062}
1063
1064pub fn tokio_command_for(
1065    program: &str,
1066    args: &[String],
1067) -> Result<tokio::process::Command, VmError> {
1068    let (policy, profile) = match active_sandbox_policy() {
1069        Some(value) => value,
1070        None => {
1071            let mut command = tokio::process::Command::new(program);
1072            command.args(args);
1073            return Ok(command);
1074        }
1075    };
1076    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1077}
1078
1079pub fn command_output(
1080    program: &str,
1081    args: &[String],
1082    config: &ProcessCommandConfig,
1083) -> Result<Output, VmError> {
1084    // Testbench replay mode short-circuits the spawn entirely.
1085    // Recording mode falls through; the duration is captured by the
1086    // recording handle below using the injected mock clock when one
1087    // is active.
1088    if let Some(intercepted) =
1089        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1090    {
1091        return intercepted.map_err(|message| {
1092            VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1093        });
1094    }
1095
1096    let recording =
1097        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1098
1099    let output = match active_sandbox_policy() {
1100        Some((policy, profile)) => {
1101            let config = sandboxed_process_config(config, &policy)?;
1102            ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1103        }
1104        None => {
1105            let mut command = Command::new(program);
1106            command.args(args);
1107            apply_process_config(&mut command, config);
1108            // Interrupt-aware `Command::output()`: puts the child in its own
1109            // kill group and gracefully terminates the whole group when the
1110            // invoking scope is cancelled, a deadline fires, or the VM is
1111            // dropped. See `crate::op_interrupt`.
1112            crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1113                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1114            })?
1115        }
1116    };
1117    if let Some(error) = process_violation_error(&output) {
1118        return Err(error);
1119    }
1120    if let Some(span) = recording {
1121        span.finish(&output);
1122    }
1123    Ok(output)
1124}
1125
1126fn sandboxed_process_config(
1127    config: &ProcessCommandConfig,
1128    policy: &CapabilityPolicy,
1129) -> Result<ProcessCommandConfig, VmError> {
1130    let mut resolved = config.clone();
1131    if let Some(cwd) = resolved.cwd.as_ref() {
1132        enforce_process_cwd_for_policy(cwd, policy)?;
1133    } else {
1134        resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1135    }
1136    neutralize_rustc_wrapper(&mut resolved.env);
1137    inject_workspace_tmpdir(&mut resolved.env, policy);
1138    Ok(resolved)
1139}
1140
1141/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
1142///
1143/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
1144/// cargo build is the first caller to spawn it, the daemon inherits the
1145/// `sandbox-exec` confinement permanently — even after it reparents to
1146/// launchd — and then fails *every* later build machine-wide with
1147/// `Operation not permitted` (it can no longer read build inputs outside the
1148/// sandbox root nor write its cache dir under `~/Library/Caches`). A
1149/// per-command sandbox must never be allowed to poison a cross-workspace
1150/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
1151/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
1152/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
1153/// on-disk cache and all unsandboxed builds are unaffected.
1154fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1155    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1156        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1157            entry.1.clear();
1158        } else {
1159            env.push((key.to_string(), String::new()));
1160        }
1161    }
1162}
1163
1164/// Workspace-relative directory name for the sandbox-writable temp dir that
1165/// [`workspace_local_tmpdir`] points `TMPDIR`/`TMP`/`TEMP` at. Lives inside a
1166/// writable workspace root (which both OS backends already grant) so any
1167/// toolchain that honors `TMPDIR` writes its intermediates somewhere the
1168/// sandbox permits, instead of the unwritable system `/tmp`.
1169pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1170
1171/// The environment keys a workspace-local temp dir is exported under. `TMPDIR`
1172/// is the POSIX/Rust/clang/gcc/Go/Swift convention; `TMP`/`TEMP` cover tools
1173/// (and Windows toolchains) that read those instead.
1174pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1175
1176/// Resolve the sandbox-writable, workspace-local temp directory for `policy`,
1177/// creating it lazily.
1178///
1179/// Compiler linkers (`rustc`/`cc`/`ld`, Go, Swift, …) and countless other
1180/// toolchains write intermediate object/temp files to `$TMPDIR`, defaulting to
1181/// the system `/tmp` when it is unset. Under a restricted profile `/tmp` is
1182/// outside the writable workspace roots, so those writes are denied and a build
1183/// that would otherwise succeed FALSE-FAILS for an infrastructure reason. By
1184/// pointing the child's temp dir at a directory *inside* the first writable
1185/// workspace root — which the OS sandbox already grants write access to — the
1186/// build's temp writes land somewhere permitted without widening the sandbox.
1187///
1188/// Returns `None` when the policy declares no writable workspace root (there is
1189/// nowhere sandbox-writable to anchor the temp dir) or when the directory could
1190/// not be created (the caller then leaves the child's inherited temp dir
1191/// untouched rather than failing the spawn).
1192pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1193    let root = normalized_workspace_roots(policy).into_iter().next()?;
1194    let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1195    if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1196        warn_once(
1197            "handler_sandbox_workspace_tmpdir",
1198            &format!(
1199                "could not create workspace-local temp dir '{}': {error}; \
1200                 leaving the child's inherited temp dir in place",
1201                tmpdir.display()
1202            ),
1203        );
1204        return None;
1205    }
1206    // Keep the temp dir's churn out of every git-based diff/status (so it never
1207    // leaks into an agent's view, a PR, or eval grading) by self-ignoring its
1208    // own contents. A `.gitignore` of `*` inside the dir excludes everything,
1209    // including itself, regardless of whether the workspace tracks it. Written
1210    // best-effort and only when absent so we don't thrash an existing file.
1211    let ignore = tmpdir.join(".gitignore");
1212    if !ignore.exists() {
1213        let _ = std::fs::write(
1214            &ignore,
1215            "# Created by the Harn sandbox; safe to delete.\n*\n",
1216        );
1217    }
1218    Some(tmpdir)
1219}
1220
1221/// Overlay `TMPDIR`/`TMP`/`TEMP` onto a child's env so a sandboxed toolchain
1222/// writes its intermediates to a workspace-local, sandbox-writable directory
1223/// instead of the unwritable system `/tmp` (see [`workspace_local_tmpdir`]).
1224///
1225/// A key the caller set explicitly in `env` is left untouched — an intentional
1226/// per-call `TMPDIR` is honored. The inherited-from-parent value is *not*
1227/// preserved: that is exactly the non-writable `/tmp` (or empty) we must
1228/// override. No-op under an unrestricted/absent policy or when no writable
1229/// workspace root is available.
1230pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1231    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1232        return;
1233    }
1234    let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1235        return;
1236    };
1237    let tmpdir = tmpdir.display().to_string();
1238    for key in TMPDIR_ENV_KEYS {
1239        if env.iter().any(|(existing, _)| existing == key) {
1240            // The caller pinned this key explicitly; respect it.
1241            continue;
1242        }
1243        env.push((key.to_string(), tmpdir.clone()));
1244    }
1245}
1246
1247/// The `TMPDIR`/`TMP`/`TEMP` overrides for the *currently active* execution
1248/// policy, as `(key, value)` pairs, or an empty vec when no restricted policy
1249/// is active or no writable workspace root exists.
1250///
1251/// This reads the active execution policy directly (gating only on a restricted
1252/// `sandbox_profile`), deliberately *not* through [`active_sandbox_policy`]:
1253/// the workspace-local temp dir is a benefit of the child env, independent of
1254/// whether OS confinement is enforced, so it must still engage under
1255/// `HARN_HANDLER_SANDBOX=warn`/`off` (which only weaken *enforcement*, not the
1256/// profile). [`inject_workspace_tmpdir`] still no-ops under `Unrestricted`.
1257///
1258/// This is the entry point the `host_call("process", …)` exec/spawn builder and
1259/// the `harn-hostlib` real spawner use to overlay the keys onto a
1260/// `Command`/`tokio::process::Command`, skipping any the caller already pinned.
1261pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1262    let Some(policy) = crate::orchestration::current_execution_policy() else {
1263        return Vec::new();
1264    };
1265    let mut env = Vec::new();
1266    inject_workspace_tmpdir(&mut env, &policy);
1267    env
1268}
1269
1270/// Environment overlay that pins a child tool's *message* output to a
1271/// deterministic, English, UTF-8-preserving locale, as `(key, value)` pairs.
1272///
1273/// Build/test/verify commands inherit the parent environment, so a user whose
1274/// shell sets `LC_ALL=ja_JP.UTF-8` (or `LANG=de_DE.UTF-8`) would otherwise get
1275/// *localized* compiler/test output. Every downstream matcher that keys on
1276/// English diagnostics — deterministic syntax repair, error-signature
1277/// grounding, completion/pass-fail classification — would then silently
1278/// misfire for a non-Anglosphere user. Forcing a stable message locale is the
1279/// root-cause fix: it keeps the English matchers correct by construction,
1280/// without shipping per-locale translations of every toolchain.
1281///
1282/// `LC_MESSAGES=C` forces untranslated (English) messages for gettext-based
1283/// tools (gcc/clang, git-l10n, GNU coreutils, gradle) while deliberately *not*
1284/// touching `LC_CTYPE`/`LANG`, so UTF-8 handling of non-ASCII source and
1285/// identifiers is preserved (unlike the blunt `LC_ALL=C`, which forces an ASCII
1286/// ctype and can mangle non-ASCII identifiers in diagnostics). The .NET CLI
1287/// ignores `LC_*` and localizes from its own variable / the OS UI language, so
1288/// `DOTNET_CLI_UI_LANGUAGE=en` is required in addition.
1289///
1290/// A user-inherited `LC_ALL` would override `LC_MESSAGES`, so the spawn sites
1291/// additionally strip `LC_ALL` (unless the caller pinned it) before applying
1292/// this overlay. Both are subject to the caller-pinned-key rule (like the
1293/// `TMPDIR` overlay): an explicit `env`/`env_remove` still wins.
1294pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1295    vec![
1296        ("LC_MESSAGES".to_string(), "C".to_string()),
1297        ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1298    ]
1299}
1300
1301/// The environment variable a user-inherited value of which would override
1302/// [`deterministic_message_locale_env`]'s `LC_MESSAGES`. Spawn sites strip this
1303/// (unless the caller pinned it) so the forced message locale actually takes
1304/// effect. Kept as a named constant so both spawn paths stay in sync.
1305pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1306
1307fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1308    let roots = normalized_workspace_roots(policy);
1309    let current = std::env::current_dir().map_err(|error| {
1310        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1311            format!("process cwd resolution failed: {error}"),
1312        )))
1313    })?;
1314    let current = normalize_for_policy(&current);
1315    if roots.iter().any(|root| path_is_within(&current, root)) {
1316        return Ok(current);
1317    }
1318    roots.first().cloned().ok_or_else(|| {
1319        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1320            "process cwd resolution failed: no workspace root available",
1321        )))
1322    })
1323}
1324
1325fn build_std_command<B: SandboxBackend + ?Sized>(
1326    program: &str,
1327    args: &[String],
1328    policy: &CapabilityPolicy,
1329    profile: SandboxProfile,
1330) -> Result<Command, VmError> {
1331    let mut command = Command::new(program);
1332    command.args(args);
1333    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1334        PrepareOutcome::Direct => Ok(command),
1335        PrepareOutcome::WrappedExec { wrapper, args } => {
1336            let mut wrapped = Command::new(wrapper);
1337            wrapped.args(args);
1338            Ok(wrapped)
1339        }
1340    }
1341}
1342
1343fn build_tokio_command<B: SandboxBackend + ?Sized>(
1344    program: &str,
1345    args: &[String],
1346    policy: &CapabilityPolicy,
1347    profile: SandboxProfile,
1348) -> Result<tokio::process::Command, VmError> {
1349    let mut command = tokio::process::Command::new(program);
1350    command.args(args);
1351    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1352        PrepareOutcome::Direct => Ok(command),
1353        PrepareOutcome::WrappedExec { wrapper, args } => {
1354            let mut wrapped = tokio::process::Command::new(wrapper);
1355            wrapped.args(args);
1356            Ok(wrapped)
1357        }
1358    }
1359}
1360
1361pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1362    let policy = crate::orchestration::current_execution_policy()?;
1363    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1364        return None;
1365    }
1366    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1367        || !ActiveBackend::available()
1368    {
1369        return None;
1370    }
1371    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1372    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1373    if !output.status.success()
1374        && (stderr.contains("operation not permitted")
1375            || stderr.contains("permission denied")
1376            || stderr.contains("access is denied")
1377            || stdout.contains("operation not permitted"))
1378    {
1379        return Some(sandbox_rejection(sandbox_process_violation_message(
1380            format!(
1381                "sandbox violation: process was denied by the OS sandbox (status {})",
1382                output.status.code().unwrap_or(-1)
1383            ),
1384        )));
1385    }
1386    if sandbox_signal_status(output) {
1387        return Some(sandbox_rejection(sandbox_process_violation_message(
1388            format!(
1389                "sandbox violation: process was terminated by the OS sandbox (status {})",
1390                output.status
1391            ),
1392        )));
1393    }
1394    None
1395}
1396
1397pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1398    let policy = crate::orchestration::current_execution_policy()?;
1399    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1400        return None;
1401    }
1402    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1403        || !ActiveBackend::available()
1404    {
1405        return None;
1406    }
1407    let message = error.to_string().to_ascii_lowercase();
1408    if error.kind() == std::io::ErrorKind::PermissionDenied
1409        || message.contains("operation not permitted")
1410        || message.contains("permission denied")
1411        || message.contains("access is denied")
1412    {
1413        return Some(sandbox_rejection(sandbox_process_violation_message(
1414            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1415        )));
1416    }
1417    None
1418}
1419
1420#[cfg(unix)]
1421fn sandbox_signal_status(output: &std::process::Output) -> bool {
1422    use std::os::unix::process::ExitStatusExt;
1423
1424    matches!(
1425        output.status.signal(),
1426        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1427    )
1428}
1429
1430#[cfg(not(unix))]
1431fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1432    false
1433}
1434
1435/// Returns the active capability policy and the resolved sandbox
1436/// profile, or `None` if confinement should be skipped entirely. The
1437/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
1438/// hatch both produce `None`. The `Wasi` profile also produces `None`
1439/// on the host spawn path — testbench mode intercepts subprocesses
1440/// before they reach this layer, so the host-spawn fallback should be
1441/// a normal direct exec.
1442pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1443    let policy = crate::orchestration::current_execution_policy()?;
1444    let profile = policy.sandbox_profile;
1445    match profile {
1446        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1447        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1448            if effective_fallback(profile) == SandboxFallback::Off {
1449                None
1450            } else {
1451                Some((policy, profile))
1452            }
1453        }
1454    }
1455}
1456
1457fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1458    if let Some(cwd) = config.cwd.as_ref() {
1459        command.current_dir(cwd);
1460    }
1461    command.envs(config.env.iter().map(|(key, value)| (key, value)));
1462    if config.stdin_null {
1463        command.stdin(Stdio::null());
1464    }
1465}
1466
1467fn spawn_error(error: std::io::Error) -> VmError {
1468    VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1469        format!("process spawn failed: {error}"),
1470    )))
1471}
1472
1473/// Resolve the fallback policy for the requested profile. `OsHardened`
1474/// always enforces — that is the entire point of the profile, so the
1475/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
1476/// the env var (default `warn`).
1477pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1478    if matches!(profile, SandboxProfile::OsHardened) {
1479        return SandboxFallback::Enforce;
1480    }
1481    match std::env::var(HANDLER_SANDBOX_ENV)
1482        .unwrap_or_else(|_| "warn".to_string())
1483        .trim()
1484        .to_ascii_lowercase()
1485        .as_str()
1486    {
1487        "0" | "false" | "off" | "none" => SandboxFallback::Off,
1488        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1489        _ => SandboxFallback::Warn,
1490    }
1491}
1492
1493pub(crate) fn warn_once(key: &str, message: &str) {
1494    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1495    if inserted {
1496        crate::events::log_warn("handler_sandbox", message);
1497    }
1498}
1499
1500pub(crate) fn sandbox_rejection(message: String) -> VmError {
1501    VmError::CategorizedError {
1502        message,
1503        category: ErrorCategory::ToolRejected,
1504    }
1505}
1506
1507fn sandbox_process_violation_message(summary: String) -> String {
1508    format!(
1509        "{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"
1510    )
1511}
1512
1513/// Helper for backends that can't attach confinement at all (macOS
1514/// without `/usr/bin/sandbox-exec`, Windows when called through the
1515/// `Command`-returning entry points): either fail loudly under
1516/// `OsHardened` / `enforce`, or warn once and proceed direct.
1517///
1518/// Linux and OpenBSD don't reach this path — they install confinement
1519/// in `pre_exec` and surface unavailability through `landlock_profile`
1520/// directly. The dead-code lint allow keeps the helper compilable on
1521/// targets where no backend uses it.
1522#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1523pub(crate) fn unavailable(
1524    message: &str,
1525    profile: SandboxProfile,
1526) -> Result<PrepareOutcome, VmError> {
1527    match effective_fallback(profile) {
1528        SandboxFallback::Off | SandboxFallback::Warn => {
1529            warn_once("handler_sandbox_unavailable", message);
1530            Ok(PrepareOutcome::Direct)
1531        }
1532        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1533            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1534        ))),
1535    }
1536}
1537
1538/// Writable workspace roots derived from the active agent session's
1539/// workspace anchor: the anchor `primary` plus any `Extend` (writable)
1540/// mounts. Read-only mounts are intentionally excluded — they are not
1541/// writable jail roots (a read of one is permitted via the read-only-roots
1542/// path, but a write must not be). Returns `None` when there is no current
1543/// session or the session has no anchor, so the caller falls back to the
1544/// process execution root.
1545fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1546    let session_id = crate::agent_sessions::current_session_id()?;
1547    let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1548    let mut roots = vec![anchor.primary.clone()];
1549    for mounted in &anchor.additional_roots {
1550        if matches!(
1551            mounted.mount_mode,
1552            crate::workspace_anchor::MountMode::Extend
1553        ) {
1554            roots.push(mounted.path.clone());
1555        }
1556    }
1557    Some(roots)
1558}
1559
1560/// The host-declared project root (`HARN_PROJECT_ROOT`), if set and
1561/// non-empty. This mirrors the standalone project-root fallback the
1562/// `workspace.project_root` host capability already uses
1563/// (`stdlib::host`), so the write jail and the reported project root
1564/// agree. It is the project a run is bound to even when the OS process
1565/// cwd differs (the eval harness runs `burin-headless` from the repo with
1566/// `--project <fixture>`, and the real IDE may launch from elsewhere).
1567fn project_root_env_workspace_root() -> Option<PathBuf> {
1568    std::env::var("HARN_PROJECT_ROOT")
1569        .ok()
1570        .map(|value| value.trim().to_string())
1571        .filter(|value| !value.is_empty())
1572        .map(PathBuf::from)
1573}
1574
1575fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1576    if policy.workspace_roots.is_empty() {
1577        // An empty `policy.workspace_roots` means no explicit write-jail was
1578        // configured for this call. Historically this fell straight back to the
1579        // process execution root, but under the eval pattern (process cwd !=
1580        // `--project`) and dispatch fan-out children, the process cwd is the
1581        // repo, not the project the run is bound to — so a write that correctly
1582        // resolved INTO the project was rejected as outside the jail
1583        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
1584        // silently compensated. Prefer, in order: (1) the active agent
1585        // session's workspace anchor (primary + writable `Extend` mounts) when
1586        // the session is anchored; (2) the host-declared `HARN_PROJECT_ROOT`
1587        // project root (robust across the session nesting that an unanchored
1588        // dispatch child sees); (3) the process execution root, the historical
1589        // default. Explicit `policy.workspace_roots` still take precedence
1590        // (handled in the non-empty branch below).
1591        if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1592            return anchor_roots
1593                .iter()
1594                .map(|root| normalize_for_policy(root))
1595                .collect();
1596        }
1597        if let Some(project_root) = project_root_env_workspace_root() {
1598            return vec![normalize_for_policy(&project_root)];
1599        }
1600        return vec![normalize_for_policy(
1601            &crate::stdlib::process::execution_root_path(),
1602        )];
1603    }
1604    policy
1605        .workspace_roots
1606        .iter()
1607        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1608        .collect()
1609}
1610
1611pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1612    normalized_workspace_roots(policy)
1613}
1614
1615/// Normalize the policy's read-only roots. Unlike
1616/// [`normalized_workspace_roots`], an empty list stays empty — read-only
1617/// scope is purely additive, so there is no execution-root fallback to
1618/// synthesize.
1619fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1620    policy
1621        .read_only_roots
1622        .iter()
1623        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1624        .collect()
1625}
1626
1627#[cfg(any(
1628    target_os = "linux",
1629    target_os = "macos",
1630    target_os = "openbsd",
1631    target_os = "windows"
1632))]
1633pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1634    normalized_read_only_roots(policy)
1635}
1636
1637#[cfg(any(
1638    target_os = "linux",
1639    target_os = "macos",
1640    target_os = "openbsd",
1641    target_os = "windows"
1642))]
1643pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1644    normalized_process_roots(&policy.process_sandbox.read_roots)
1645}
1646
1647#[cfg(any(
1648    target_os = "linux",
1649    target_os = "macos",
1650    target_os = "openbsd",
1651    target_os = "windows"
1652))]
1653pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1654    normalized_process_roots(&policy.process_sandbox.write_roots)
1655}
1656
1657#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1658pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1659    policy.process_sandbox.effective_presets()
1660}
1661
1662#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1663pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1664    policy: &CapabilityPolicy,
1665) -> Vec<PathBuf> {
1666    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1667        return Vec::new();
1668    }
1669    let Some(home) = sandbox_user_home_dir() else {
1670        return Vec::new();
1671    };
1672    developer_toolchain_read_roots_for_home(&home)
1673}
1674
1675#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1676pub(crate) fn process_sandbox_package_manager_config_read_roots(
1677    policy: &CapabilityPolicy,
1678) -> Vec<PathBuf> {
1679    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1680        return Vec::new();
1681    }
1682    let Some(home) = sandbox_user_home_dir() else {
1683        return Vec::new();
1684    };
1685    package_manager_config_read_roots_for_home(&home)
1686}
1687
1688/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
1689/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
1690/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
1691/// read-only: a build legitimately populates `~/.gradle/caches`,
1692/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
1693/// gated on the `DeveloperToolchains` preset and granted *write* only when the
1694/// active policy already permits workspace writes (mirroring `UserTemp`); under
1695/// a read-only policy they fall back to read access so dependency resolution
1696/// still works.
1697// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
1698// (Landlock) sandbox backends; the Windows backend deliberately does not grant
1699// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
1700// targets keeps `-D warnings` happy on Windows, where this would otherwise be
1701// dead code.
1702#[cfg(any(target_os = "linux", target_os = "macos"))]
1703pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1704    policy: &CapabilityPolicy,
1705) -> Vec<PathBuf> {
1706    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1707        return Vec::new();
1708    }
1709    let Some(home) = sandbox_user_home_dir() else {
1710        return Vec::new();
1711    };
1712    developer_toolchain_cache_write_roots_for_home(&home)
1713}
1714
1715#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1716fn sandbox_user_home_dir() -> Option<PathBuf> {
1717    // Only an absolute home grounds the user-scope read-roots below; a
1718    // relative or unset home yields no extra roots (the safe direction).
1719    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1720}
1721
1722#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1723pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1724    let mut roots: Vec<_> = [
1725        ".asdf",
1726        ".bun",
1727        ".cargo",
1728        ".fnm",
1729        ".juliaup",
1730        ".local/bin",
1731        ".local/share/mise",
1732        ".local/share/uv",
1733        ".nvm",
1734        ".pyenv",
1735        ".rbenv",
1736        ".rustup",
1737        ".sdkman",
1738        ".swiftly",
1739        ".volta",
1740        "go",
1741    ]
1742    .into_iter()
1743    .map(|entry| normalize_for_policy(&home.join(entry)))
1744    .collect();
1745    #[cfg(target_os = "windows")]
1746    roots.extend(
1747        [
1748            "AppData/Local/Programs/Python",
1749            "AppData/Local/uv",
1750            "AppData/Roaming/uv",
1751            "scoop",
1752        ]
1753        .into_iter()
1754        .map(|entry| normalize_for_policy(&home.join(entry))),
1755    );
1756    roots.sort_unstable();
1757    roots.dedup();
1758    roots
1759}
1760
1761/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
1762/// the macOS seatbelt and Linux Landlock backends render the same set; the
1763/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
1764/// `optional`/NotFound handling in each backend skips roots that do not exist.
1765#[cfg(any(target_os = "linux", target_os = "macos"))]
1766pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1767    let mut roots: Vec<_> = [
1768        ".gradle",                             // Gradle (JVM/Android/Kotlin)
1769        ".m2",                                 // Maven (JVM)
1770        ".konan",                              // Kotlin/Native
1771        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
1772        "Library/Developer/Xcode/DerivedData", // Xcode build products
1773    ]
1774    .into_iter()
1775    .map(|entry| normalize_for_policy(&home.join(entry)))
1776    .collect();
1777    roots.sort_unstable();
1778    roots.dedup();
1779    roots
1780}
1781
1782#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1783pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1784    let mut roots: Vec<_> = [
1785        ".npmrc",
1786        ".gitconfig",
1787        ".netrc",
1788        ".yarnrc.yml",
1789        ".config",
1790        ".npm",
1791        ".cache",
1792        ".pip",
1793        ".pypirc",
1794        ".cargo/config",
1795        ".cargo/config.toml",
1796        ".cargo/credentials",
1797        ".cargo/credentials.toml",
1798        ".cargo/registry",
1799        ".cargo/git",
1800    ]
1801    .into_iter()
1802    .map(|entry| normalize_for_policy(&home.join(entry)))
1803    .collect();
1804    roots.sort_unstable();
1805    roots.dedup();
1806    roots
1807}
1808
1809#[cfg(any(
1810    target_os = "linux",
1811    target_os = "macos",
1812    target_os = "openbsd",
1813    target_os = "windows"
1814))]
1815fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1816    roots
1817        .iter()
1818        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1819        .collect()
1820}
1821
1822fn resolve_policy_path(path: &str) -> PathBuf {
1823    let candidate = PathBuf::from(path);
1824    if candidate.is_absolute() {
1825        candidate
1826    } else {
1827        crate::stdlib::process::execution_root_path().join(candidate)
1828    }
1829}
1830
1831fn normalize_for_policy(path: &Path) -> PathBuf {
1832    let absolute = if path.is_absolute() {
1833        path.to_path_buf()
1834    } else {
1835        crate::stdlib::process::execution_root_path().join(path)
1836    };
1837    let absolute = normalize_lexically(&absolute);
1838    if let Ok(canonical) = absolute.canonicalize() {
1839        return canonical;
1840    }
1841
1842    let mut existing = absolute.as_path();
1843    let mut suffix = Vec::new();
1844    while !existing.exists() {
1845        let Some(parent) = existing.parent() else {
1846            return normalize_lexically(&absolute);
1847        };
1848        if let Some(name) = existing.file_name() {
1849            suffix.push(name.to_os_string());
1850        }
1851        existing = parent;
1852    }
1853
1854    let mut normalized = existing
1855        .canonicalize()
1856        .unwrap_or_else(|_| normalize_lexically(existing));
1857    for component in suffix.iter().rev() {
1858        normalized.push(component);
1859    }
1860    normalize_lexically(&normalized)
1861}
1862
1863fn normalize_lexically(path: &Path) -> PathBuf {
1864    let mut normalized = PathBuf::new();
1865    for component in path.components() {
1866        match component {
1867            Component::CurDir => {}
1868            Component::ParentDir => {
1869                normalized.pop();
1870            }
1871            other => normalized.push(other.as_os_str()),
1872        }
1873    }
1874    normalized
1875}
1876
1877fn path_is_within(path: &Path, root: &Path) -> bool {
1878    path == root || path.starts_with(root)
1879}
1880
1881/// Resolve `path` to an absolute, lexically-normalized form for the standard
1882/// I/O device check. Unlike [`normalize_for_policy`] this never calls
1883/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
1884/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
1885fn normalize_io_device_path(path: &Path) -> PathBuf {
1886    let absolute = if path.is_absolute() {
1887        path.to_path_buf()
1888    } else {
1889        crate::stdlib::process::execution_root_path().join(path)
1890    };
1891    normalize_lexically(&absolute)
1892}
1893
1894/// Whether `path` is one of the standard process I/O device files that the
1895/// sandbox treats as a stream rather than a workspace mutation for this access:
1896/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
1897/// stream operation. `path` must already be absolute and lexically normalized.
1898fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1899    match access {
1900        FsAccess::Read => {
1901            matches!(
1902                path.to_str(),
1903                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1904            ) || is_dev_fd_descriptor(path)
1905        }
1906        FsAccess::Write => {
1907            matches!(
1908                path.to_str(),
1909                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1910            ) || is_dev_fd_descriptor(path)
1911        }
1912        FsAccess::Delete => false,
1913    }
1914}
1915
1916/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
1917/// digits (the numeric file-descriptor aliases for the standard streams).
1918fn is_dev_fd_descriptor(path: &Path) -> bool {
1919    let Some(text) = path.to_str() else {
1920        return false;
1921    };
1922    let Some(fd) = text.strip_prefix("/dev/fd/") else {
1923        return false;
1924    };
1925    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1926}
1927
1928#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1929pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1930    use crate::tool_annotations::SideEffectLevel;
1931    policy
1932        .side_effect_level
1933        .as_ref()
1934        .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
1935        .unwrap_or(true)
1936}
1937
1938#[cfg(any(
1939    target_os = "linux",
1940    target_os = "macos",
1941    target_os = "openbsd",
1942    target_os = "windows"
1943))]
1944pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1945    policy.capabilities.is_empty()
1946        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1947}
1948
1949#[cfg(any(
1950    target_os = "linux",
1951    target_os = "macos",
1952    target_os = "openbsd",
1953    target_os = "windows"
1954))]
1955pub(crate) fn policy_allows_capability(
1956    policy: &CapabilityPolicy,
1957    capability: &str,
1958    ops: &[&str],
1959) -> bool {
1960    policy
1961        .capabilities
1962        .get(capability)
1963        .map(|allowed| {
1964            ops.iter()
1965                .any(|op| allowed.iter().any(|candidate| candidate == op))
1966        })
1967        .unwrap_or(false)
1968}
1969
1970impl FsAccess {
1971    fn verb(self) -> &'static str {
1972        match self {
1973            FsAccess::Read => "read",
1974            FsAccess::Write => "write",
1975            FsAccess::Delete => "delete",
1976        }
1977    }
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982    use super::*;
1983    use crate::orchestration::{pop_execution_policy, push_execution_policy};
1984
1985    #[test]
1986    fn missing_create_path_normalizes_against_existing_parent() {
1987        let dir = tempfile::tempdir().unwrap();
1988        let nested = dir.path().join("a/../new.txt");
1989        let normalized = normalize_for_policy(&nested);
1990        assert_eq!(
1991            normalized,
1992            normalize_for_policy(&dir.path().join("new.txt"))
1993        );
1994    }
1995
1996    #[test]
1997    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1998        // Serialize env mutation and clear HARN_PROJECT_ROOT so this asserts the
1999        // pure execution-root fallback (the project-root-env preference is
2000        // covered by the next test).
2001        let _env_lock = crate::runtime_paths::test_env_lock()
2002            .lock()
2003            .unwrap_or_else(|poisoned| poisoned.into_inner());
2004        std::env::remove_var("HARN_PROJECT_ROOT");
2005        let dir = tempfile::tempdir().unwrap();
2006        crate::stdlib::process::set_thread_execution_context(Some(
2007            crate::orchestration::RunExecutionRecord {
2008                cwd: Some(dir.path().to_string_lossy().into_owned()),
2009                source_dir: None,
2010                env: Default::default(),
2011                adapter: None,
2012                repo_path: None,
2013                worktree_path: None,
2014                branch: None,
2015                base_ref: None,
2016                cleanup: None,
2017            },
2018        ));
2019        push_execution_policy(CapabilityPolicy {
2020            sandbox_profile: SandboxProfile::Worktree,
2021            ..CapabilityPolicy::default()
2022        });
2023
2024        assert!(
2025            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2026        );
2027        let outside = tempfile::tempdir().unwrap();
2028        assert!(enforce_fs_path(
2029            "read_file",
2030            &outside.path().join("outside.txt"),
2031            FsAccess::Read
2032        )
2033        .is_err());
2034
2035        pop_execution_policy();
2036        crate::stdlib::process::set_thread_execution_context(None);
2037    }
2038
2039    /// Regression for burin-labs/burin-code#3288. When a restricted policy has
2040    /// no explicit `workspace_roots`, the write jail must follow the
2041    /// host-declared `HARN_PROJECT_ROOT` project — NOT the process/execution
2042    /// cwd. This is the eval/dispatch pattern: `burin-headless` runs from the
2043    /// repo (`execution cwd = repo`) with `--project <fixture>` + matching
2044    /// `HARN_PROJECT_ROOT`, and a dispatched sub-agent worker's writes resolve
2045    /// into the fixture. Before the fix the empty-roots fallback used the
2046    /// execution cwd (the repo), so the in-project write was rejected
2047    /// (HARN-CAP-201) and the dispatched child wrote nothing.
2048    #[test]
2049    fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2050        let _env_lock = crate::runtime_paths::test_env_lock()
2051            .lock()
2052            .unwrap_or_else(|poisoned| poisoned.into_inner());
2053        let project = tempfile::tempdir().unwrap();
2054        let execution_cwd = tempfile::tempdir().unwrap();
2055        std::env::set_var("HARN_PROJECT_ROOT", project.path());
2056        crate::stdlib::process::set_thread_execution_context(Some(
2057            crate::orchestration::RunExecutionRecord {
2058                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2059                source_dir: None,
2060                env: Default::default(),
2061                adapter: None,
2062                repo_path: None,
2063                worktree_path: None,
2064                branch: None,
2065                base_ref: None,
2066                cleanup: None,
2067            },
2068        ));
2069        push_execution_policy(CapabilityPolicy {
2070            sandbox_profile: SandboxProfile::Worktree,
2071            ..CapabilityPolicy::default()
2072        });
2073
2074        // A write that resolves INTO the project is allowed even though the
2075        // process/execution cwd is elsewhere.
2076        assert!(
2077            enforce_fs_path(
2078                "write_file",
2079                &project.path().join("test/created.ts"),
2080                FsAccess::Write,
2081            )
2082            .is_ok(),
2083            "write into HARN_PROJECT_ROOT must be allowed"
2084        );
2085        // A write under the execution cwd (the repo, in the eval pattern) is NOT
2086        // the project and must still be rejected — the jail moved to the
2087        // project, it did not widen to both.
2088        assert!(
2089            enforce_fs_path(
2090                "write_file",
2091                &execution_cwd.path().join("escape.ts"),
2092                FsAccess::Write,
2093            )
2094            .is_err(),
2095            "write under the execution cwd (outside the project) must be rejected"
2096        );
2097
2098        pop_execution_policy();
2099        crate::stdlib::process::set_thread_execution_context(None);
2100        std::env::remove_var("HARN_PROJECT_ROOT");
2101    }
2102
2103    #[test]
2104    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2105        let dir = tempfile::tempdir().unwrap();
2106        crate::stdlib::process::set_thread_execution_context(Some(
2107            crate::orchestration::RunExecutionRecord {
2108                cwd: Some(dir.path().to_string_lossy().into_owned()),
2109                source_dir: None,
2110                env: Default::default(),
2111                adapter: None,
2112                repo_path: None,
2113                worktree_path: None,
2114                branch: None,
2115                base_ref: None,
2116                cleanup: None,
2117            },
2118        ));
2119        push_execution_policy(CapabilityPolicy {
2120            sandbox_profile: SandboxProfile::Worktree,
2121            ..CapabilityPolicy::default()
2122        });
2123
2124        assert!(enforce_process_cwd(dir.path()).is_ok());
2125        let outside = tempfile::tempdir().unwrap();
2126        assert!(enforce_process_cwd(outside.path()).is_err());
2127
2128        pop_execution_policy();
2129        crate::stdlib::process::set_thread_execution_context(None);
2130    }
2131
2132    #[test]
2133    fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2134        let _env_lock = crate::runtime_paths::test_env_lock()
2135            .lock()
2136            .unwrap_or_else(|poisoned| poisoned.into_inner());
2137        std::env::remove_var("HARN_PROJECT_ROOT");
2138        let execution_root = tempfile::tempdir().unwrap();
2139        let command_root = tempfile::tempdir().unwrap();
2140        crate::stdlib::process::set_thread_execution_context(Some(
2141            crate::orchestration::RunExecutionRecord {
2142                cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2143                source_dir: None,
2144                env: Default::default(),
2145                adapter: None,
2146                repo_path: None,
2147                worktree_path: None,
2148                branch: None,
2149                base_ref: None,
2150                cleanup: None,
2151            },
2152        ));
2153        push_execution_policy(CapabilityPolicy {
2154            sandbox_profile: SandboxProfile::Worktree,
2155            ..CapabilityPolicy::default()
2156        });
2157
2158        assert!(
2159            enforce_process_cwd(command_root.path()).is_err(),
2160            "before the scoped overlay the command temp root is outside the execution-root fallback",
2161        );
2162        {
2163            let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2164                workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2165            })
2166            .unwrap();
2167            assert!(
2168                enforce_process_cwd(command_root.path()).is_ok(),
2169                "scoped command root must be usable as the process cwd"
2170            );
2171            assert!(
2172                enforce_process_cwd(execution_root.path()).is_err(),
2173                "the scoped root must narrow the concrete spawn jail instead of widening it"
2174            );
2175        }
2176        assert!(
2177            enforce_process_cwd(command_root.path()).is_err(),
2178            "the scoped command root must pop after the command spawn"
2179        );
2180
2181        pop_execution_policy();
2182        crate::stdlib::process::set_thread_execution_context(None);
2183    }
2184
2185    #[test]
2186    fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2187        let workspace = tempfile::tempdir().unwrap();
2188        let inside = workspace.path().join("subdir");
2189        std::fs::create_dir(&inside).unwrap();
2190        let outside = tempfile::tempdir().unwrap();
2191        push_execution_policy(CapabilityPolicy {
2192            sandbox_profile: SandboxProfile::Worktree,
2193            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2194            ..CapabilityPolicy::default()
2195        });
2196
2197        assert!(
2198            push_process_sandbox_scope(ProcessSandboxScope {
2199                workspace_roots: vec![inside.to_string_lossy().into_owned()],
2200            })
2201            .is_ok(),
2202            "a command subroot inside the explicit workspace ceiling is allowed"
2203        );
2204        assert!(
2205            push_process_sandbox_scope(ProcessSandboxScope {
2206                workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2207            })
2208            .is_err(),
2209            "a command root outside the explicit workspace ceiling must be rejected"
2210        );
2211
2212        pop_execution_policy();
2213    }
2214
2215    #[cfg(unix)]
2216    #[test]
2217    fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2218        let workspace = tempfile::tempdir().unwrap();
2219        let outside = tempfile::tempdir().unwrap();
2220        let safe_parent = workspace.path().join("safe");
2221        std::fs::create_dir(&safe_parent).unwrap();
2222        let path = safe_parent.join("state.json");
2223        push_execution_policy(CapabilityPolicy {
2224            sandbox_profile: SandboxProfile::Worktree,
2225            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2226            ..CapabilityPolicy::default()
2227        });
2228        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2229            .unwrap()
2230            .expect("restricted policy yields scoped target");
2231
2232        std::fs::remove_dir(&safe_parent).unwrap();
2233        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2234        let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2235        pop_execution_policy();
2236
2237        assert!(
2238            !outside.path().join("state.json").exists(),
2239            "scoped write must not follow swapped parent symlink; error={error}"
2240        );
2241    }
2242
2243    #[cfg(unix)]
2244    #[test]
2245    fn scoped_write_creates_missing_parent_dirs() {
2246        // Regression guard for #4147: the scoped-write hardening dropped the
2247        // `mkdir -p` contract that `write_file` / `write_text` / `http_download`
2248        // relied on, surfacing downstream as "No such file or directory" when a
2249        // tool wrote into a not-yet-created state dir. A write to a path whose
2250        // ancestors are missing must recreate them (scoped) and succeed.
2251        let workspace = tempfile::tempdir().unwrap();
2252        let path = workspace.path().join("a/b/c/plan.json");
2253        push_execution_policy(CapabilityPolicy {
2254            sandbox_profile: SandboxProfile::Worktree,
2255            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2256            ..CapabilityPolicy::default()
2257        });
2258        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2259            .unwrap()
2260            .expect("restricted policy yields scoped target");
2261        let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2262        pop_execution_policy();
2263
2264        assert!(
2265            result.is_ok(),
2266            "write must create missing parents: {result:?}"
2267        );
2268        assert_eq!(
2269            std::fs::read(&path).unwrap(),
2270            b"{\"plan\":\"Redis-backed\"}".to_vec()
2271        );
2272    }
2273
2274    #[cfg(unix)]
2275    #[test]
2276    fn scoped_append_creates_missing_parent_dirs() {
2277        let workspace = tempfile::tempdir().unwrap();
2278        let path = workspace.path().join("logs/deep/qa.jsonl");
2279        push_execution_policy(CapabilityPolicy {
2280            sandbox_profile: SandboxProfile::Worktree,
2281            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2282            ..CapabilityPolicy::default()
2283        });
2284        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2285            .unwrap()
2286            .expect("restricted policy yields scoped target");
2287        append_scoped_target(&target, b"line1\n").unwrap();
2288        append_scoped_target(&target, b"line2\n").unwrap();
2289        pop_execution_policy();
2290
2291        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2292    }
2293
2294    #[test]
2295    fn unscoped_write_creates_missing_parent_dirs() {
2296        // With no active sandbox scope (`scoped_mutation_target` returns None),
2297        // writes flow through `atomic_write_unscoped`. That path must honor the
2298        // same `mkdir -p` contract, otherwise a trusted-context write (CLI
2299        // scripts, `harn run`, conformance) into a fresh directory fails.
2300        let dir = tempfile::tempdir().unwrap();
2301        let path = dir.path().join("x/y/z/plan.json");
2302        atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2303        assert_eq!(
2304            std::fs::read(&path).unwrap(),
2305            b"{\"plan\":\"Redis-backed\"}".to_vec()
2306        );
2307    }
2308
2309    #[test]
2310    fn unscoped_append_creates_missing_parent_dirs() {
2311        let dir = tempfile::tempdir().unwrap();
2312        let path = dir.path().join("logs/deep/qa.jsonl");
2313        append_unscoped(&path, b"line1\n").unwrap();
2314        append_unscoped(&path, b"line2\n").unwrap();
2315        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2316    }
2317
2318    #[cfg(unix)]
2319    #[test]
2320    fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
2321        // Auto-creating parents must stay symlink-safe: the create walk opens
2322        // each level with O_NOFOLLOW, so a symlinked intermediate directory
2323        // cannot be used to escape the workspace root.
2324        let workspace = tempfile::tempdir().unwrap();
2325        let outside = tempfile::tempdir().unwrap();
2326        std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
2327        let path = workspace.path().join("escape/sub/plan.json");
2328        push_execution_policy(CapabilityPolicy {
2329            sandbox_profile: SandboxProfile::Worktree,
2330            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2331            ..CapabilityPolicy::default()
2332        });
2333        // The escape is refused at whichever layer sees it first: path
2334        // resolution (`scoped_mutation_target` canonicalizes and rejects a
2335        // target outside `workspace_roots`) or, for a symlink swapped in after
2336        // resolution, the auto-create walk itself (`O_NOFOLLOW` on each level).
2337        let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
2338            Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
2339            Ok(None) | Err(_) => false,
2340        };
2341        pop_execution_policy();
2342
2343        assert!(
2344            !escaped,
2345            "must not write through a symlinked intermediate dir"
2346        );
2347        assert!(
2348            !outside.path().join("sub/plan.json").exists(),
2349            "scoped write escaped the workspace via a symlinked parent"
2350        );
2351    }
2352
2353    #[cfg(unix)]
2354    #[test]
2355    fn scoped_append_rejects_final_symlink_created_after_policy_match() {
2356        let workspace = tempfile::tempdir().unwrap();
2357        let outside = tempfile::tempdir().unwrap();
2358        let safe_parent = workspace.path().join("safe");
2359        std::fs::create_dir(&safe_parent).unwrap();
2360        let outside_file = outside.path().join("state.log");
2361        std::fs::write(&outside_file, b"outside").unwrap();
2362        let path = safe_parent.join("state.log");
2363        push_execution_policy(CapabilityPolicy {
2364            sandbox_profile: SandboxProfile::Worktree,
2365            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2366            ..CapabilityPolicy::default()
2367        });
2368        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2369            .unwrap()
2370            .expect("restricted policy yields scoped target");
2371
2372        std::os::unix::fs::symlink(&outside_file, &path).unwrap();
2373        let error = append_scoped_target(&target, b"\nescape").unwrap_err();
2374        pop_execution_policy();
2375
2376        assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
2377        assert!(
2378            error.raw_os_error() == Some(libc::ELOOP)
2379                || error.kind() == io::ErrorKind::PermissionDenied
2380                || error.kind() == io::ErrorKind::Other,
2381            "expected symlink refusal, got {error:?}"
2382        );
2383    }
2384
2385    #[cfg(unix)]
2386    #[test]
2387    fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
2388        let workspace = tempfile::tempdir().unwrap();
2389        let outside = tempfile::tempdir().unwrap();
2390        let safe_parent = workspace.path().join("safe");
2391        std::fs::create_dir(&safe_parent).unwrap();
2392        let path = safe_parent.join("nested/deeper");
2393        push_execution_policy(CapabilityPolicy {
2394            sandbox_profile: SandboxProfile::Worktree,
2395            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2396            ..CapabilityPolicy::default()
2397        });
2398        let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
2399            .unwrap()
2400            .expect("restricted policy yields scoped target");
2401
2402        std::fs::remove_dir(&safe_parent).unwrap();
2403        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2404        let error = create_dir_all_scoped_target(&target).unwrap_err();
2405        pop_execution_policy();
2406
2407        assert!(
2408            !outside.path().join("nested").exists(),
2409            "scoped mkdir must not follow swapped parent symlink; error={error}"
2410        );
2411    }
2412
2413    #[test]
2414    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
2415        let cwd = std::env::current_dir().unwrap();
2416        let policy = CapabilityPolicy {
2417            sandbox_profile: SandboxProfile::Worktree,
2418            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2419            ..CapabilityPolicy::default()
2420        };
2421
2422        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2423
2424        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
2425    }
2426
2427    #[test]
2428    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
2429        let workspace = tempfile::tempdir().unwrap();
2430        let policy = CapabilityPolicy {
2431            sandbox_profile: SandboxProfile::Worktree,
2432            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2433            ..CapabilityPolicy::default()
2434        };
2435
2436        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2437
2438        assert_eq!(
2439            resolved.cwd.unwrap(),
2440            normalize_for_policy(workspace.path())
2441        );
2442    }
2443
2444    #[test]
2445    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
2446        let workspace = tempfile::tempdir().unwrap();
2447        let outside = tempfile::tempdir().unwrap();
2448        let policy = CapabilityPolicy {
2449            sandbox_profile: SandboxProfile::Worktree,
2450            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2451            ..CapabilityPolicy::default()
2452        };
2453        let config = ProcessCommandConfig {
2454            cwd: Some(outside.path().to_path_buf()),
2455            ..ProcessCommandConfig::default()
2456        };
2457
2458        assert!(sandboxed_process_config(&config, &policy).is_err());
2459    }
2460
2461    #[test]
2462    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
2463        let cwd = std::env::current_dir().unwrap();
2464        let policy = CapabilityPolicy {
2465            sandbox_profile: SandboxProfile::Worktree,
2466            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
2467            ..CapabilityPolicy::default()
2468        };
2469
2470        // A sandboxed spawn must bypass sccache so it can never spawn (and
2471        // thereby permanently confine) the shared daemon.
2472        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
2473        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2474        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2475        assert_eq!(
2476            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
2477            Some("")
2478        );
2479    }
2480
2481    #[test]
2482    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
2483        // Even if a caller (or inherited env) asked for sccache, the sandboxed
2484        // config forces it off rather than appending a duplicate entry.
2485        let mut env = vec![
2486            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
2487            ("PATH".to_string(), "/usr/bin".to_string()),
2488        ];
2489        neutralize_rustc_wrapper(&mut env);
2490        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2491        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
2492        assert_eq!(
2493            collected
2494                .get("CARGO_BUILD_RUSTC_WRAPPER")
2495                .map(String::as_str),
2496            Some("")
2497        );
2498        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
2499        // No duplicate RUSTC_WRAPPER entries.
2500        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
2501    }
2502
2503    #[test]
2504    fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
2505        let workspace = tempfile::tempdir().unwrap();
2506        let policy = CapabilityPolicy {
2507            sandbox_profile: SandboxProfile::Worktree,
2508            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2509            ..CapabilityPolicy::default()
2510        };
2511
2512        let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
2513
2514        // The temp dir is created, lives under the writable workspace root, and
2515        // is named by the documented convention.
2516        assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
2517        assert!(
2518            path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
2519            "temp dir {tmpdir:?} must be inside the writable workspace root"
2520        );
2521        assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
2522        // It self-ignores so its churn never shows in a git diff.
2523        let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
2524        assert!(
2525            ignore.lines().any(|line| line.trim() == "*"),
2526            "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
2527        );
2528        // It is within the sandbox's writable scope: a write under it passes the
2529        // same path-scope check the OS sandbox enforces.
2530        push_execution_policy(policy);
2531        assert!(
2532            check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
2533            "writes under the workspace-local temp dir must be in sandbox scope"
2534        );
2535        pop_execution_policy();
2536    }
2537
2538    #[test]
2539    fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
2540        // The unrestricted profile short-circuits the injection helper: an
2541        // unsandboxed child keeps whatever TMPDIR it would otherwise inherit.
2542        let policy = CapabilityPolicy {
2543            sandbox_profile: SandboxProfile::Unrestricted,
2544            workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
2545            ..CapabilityPolicy::default()
2546        };
2547        let mut env = Vec::new();
2548        inject_workspace_tmpdir(&mut env, &policy);
2549        assert!(
2550            env.is_empty(),
2551            "unrestricted profile must not inject a TMPDIR override, got {env:?}"
2552        );
2553    }
2554
2555    #[test]
2556    fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
2557        let workspace = tempfile::tempdir().unwrap();
2558        let policy = CapabilityPolicy {
2559            sandbox_profile: SandboxProfile::Worktree,
2560            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2561            ..CapabilityPolicy::default()
2562        };
2563        let mut env = Vec::new();
2564        inject_workspace_tmpdir(&mut env, &policy);
2565
2566        let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
2567        let expected = workspace_local_tmpdir(&policy)
2568            .unwrap()
2569            .display()
2570            .to_string();
2571        for key in TMPDIR_ENV_KEYS {
2572            assert_eq!(
2573                collected.get(key).map(String::as_str),
2574                Some(expected.as_str()),
2575                "{key} must point at the workspace-local temp dir"
2576            );
2577        }
2578    }
2579
2580    #[test]
2581    fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
2582        let env: std::collections::BTreeMap<_, _> =
2583            deterministic_message_locale_env().into_iter().collect();
2584        // gettext tools (gcc/clang, git-l10n, coreutils, gradle) honor
2585        // LC_MESSAGES; `C` yields untranslated English.
2586        assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
2587        // .NET ignores LC_* and localizes from its own variable.
2588        assert_eq!(
2589            env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
2590            Some("en")
2591        );
2592        // Deliberately NOT setting LC_ALL/LC_CTYPE/LANG so UTF-8 handling of
2593        // non-ASCII source and identifiers is preserved (unlike `LC_ALL=C`).
2594        assert!(
2595            !env.contains_key("LC_ALL"),
2596            "must not force LC_ALL (would clobber UTF-8 ctype)"
2597        );
2598        assert!(!env.contains_key("LC_CTYPE"));
2599        assert!(!env.contains_key("LANG"));
2600        // The override-strip constant names the one variable that would defeat
2601        // LC_MESSAGES if inherited.
2602        assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
2603    }
2604
2605    #[test]
2606    fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
2607        let workspace = tempfile::tempdir().unwrap();
2608        let policy = CapabilityPolicy {
2609            sandbox_profile: SandboxProfile::Worktree,
2610            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2611            ..CapabilityPolicy::default()
2612        };
2613        // Caller already pinned TMPDIR; only the untouched siblings get filled.
2614        let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
2615        inject_workspace_tmpdir(&mut env, &policy);
2616
2617        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
2618        assert_eq!(
2619            collected.get("TMPDIR").map(String::as_str),
2620            Some("/caller/explicit/tmp"),
2621            "an explicit caller TMPDIR must be preserved untouched"
2622        );
2623        let expected = workspace_local_tmpdir(&policy)
2624            .unwrap()
2625            .display()
2626            .to_string();
2627        assert_eq!(
2628            collected.get("TMP").map(String::as_str),
2629            Some(expected.as_str())
2630        );
2631        assert_eq!(
2632            collected.get("TEMP").map(String::as_str),
2633            Some(expected.as_str())
2634        );
2635        // And no duplicate TMPDIR entry was appended.
2636        assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
2637    }
2638
2639    #[test]
2640    fn sandboxed_process_config_injects_workspace_tmpdir() {
2641        let workspace = tempfile::tempdir().unwrap();
2642        let policy = CapabilityPolicy {
2643            sandbox_profile: SandboxProfile::Worktree,
2644            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2645            ..CapabilityPolicy::default()
2646        };
2647        let config = ProcessCommandConfig {
2648            cwd: Some(workspace.path().to_path_buf()),
2649            ..ProcessCommandConfig::default()
2650        };
2651        let resolved = sandboxed_process_config(&config, &policy).unwrap();
2652        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
2653        let expected = workspace_local_tmpdir(&policy)
2654            .unwrap()
2655            .display()
2656            .to_string();
2657        assert_eq!(
2658            env.get("TMPDIR").map(String::as_str),
2659            Some(expected.as_str()),
2660            "the command_output path must inject a workspace-local TMPDIR"
2661        );
2662    }
2663
2664    #[test]
2665    fn read_only_root_outside_workspace_allows_read_denies_write() {
2666        // Models an embedder (burin's in-process TUI) that grants a
2667        // read-only root R holding bundled pipelines/partials outside the
2668        // user's writable workspace. A read under R passes; a write under R
2669        // is denied; a read outside both R and the workspace is denied.
2670        let workspace = tempfile::tempdir().unwrap();
2671        let read_only = tempfile::tempdir().unwrap();
2672        push_execution_policy(CapabilityPolicy {
2673            sandbox_profile: SandboxProfile::Worktree,
2674            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2675            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
2676            ..CapabilityPolicy::default()
2677        });
2678
2679        let asset = read_only
2680            .path()
2681            .join("partials/agent-web-tools.harn.prompt");
2682        // READ under the read-only root is allowed.
2683        assert!(
2684            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
2685            "read under a configured read-only root must be allowed"
2686        );
2687
2688        // WRITE under the read-only root is denied, flagged read_only.
2689        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
2690            .expect_err("write under a read-only root must be denied");
2691        assert!(write_err.read_only, "write rejection must set read_only");
2692
2693        // DELETE under the read-only root is likewise denied.
2694        assert!(
2695            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
2696            "delete under a read-only root must be denied"
2697        );
2698
2699        // A read inside the writable workspace still passes.
2700        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
2701
2702        // A read outside BOTH the workspace and the read-only root is denied
2703        // and is NOT flagged read_only (it fell outside every root).
2704        let stranger = tempfile::tempdir().unwrap();
2705        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
2706            .expect_err("read outside all roots must be denied");
2707        assert!(
2708            !outside_err.read_only,
2709            "out-of-scope rejection must not be flagged read_only"
2710        );
2711
2712        pop_execution_policy();
2713    }
2714
2715    #[cfg(unix)]
2716    #[test]
2717    fn standard_io_device_files_allowed_under_restricted_profile() {
2718        // Writing to the standard process I/O streams is not a workspace
2719        // mutation, so a restricted profile with a workspace root that does
2720        // not contain /dev must still allow them — while a genuine
2721        // out-of-root write is still rejected.
2722        let workspace = tempfile::tempdir().unwrap();
2723        push_execution_policy(CapabilityPolicy {
2724            sandbox_profile: SandboxProfile::Worktree,
2725            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2726            ..CapabilityPolicy::default()
2727        });
2728
2729        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
2730            assert!(
2731                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
2732                "write to standard device {device} must be allowed"
2733            );
2734            // Reads of the same devices are likewise allowed.
2735            assert!(
2736                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
2737                "read of standard device {device} must be allowed"
2738            );
2739        }
2740        assert!(
2741            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
2742            "read of standard device /dev/stdin must be allowed"
2743        );
2744        assert!(
2745            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
2746            "write to /dev/stdin is not a standard output stream"
2747        );
2748        assert!(
2749            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
2750            "standard devices must not bypass delete scoping"
2751        );
2752        // Numeric /dev/fd/<N> descriptors are allowed.
2753        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
2754        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
2755
2756        // A non-device path outside the workspace is still rejected.
2757        let stranger = tempfile::tempdir().unwrap();
2758        assert!(
2759            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
2760            "a real out-of-root write must still be rejected"
2761        );
2762        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
2763        assert!(
2764            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
2765            "/dev/sda must not be allowed by the standard-device allowlist"
2766        );
2767        assert!(
2768            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
2769            "non-numeric /dev/fd/<x> must not be allowed"
2770        );
2771
2772        pop_execution_policy();
2773    }
2774
2775    #[test]
2776    fn is_standard_io_device_matches_only_known_streams() {
2777        assert!(is_standard_io_device_for_access(
2778            Path::new("/dev/stdin"),
2779            FsAccess::Read
2780        ));
2781        assert!(!is_standard_io_device_for_access(
2782            Path::new("/dev/stdin"),
2783            FsAccess::Write
2784        ));
2785        assert!(is_standard_io_device_for_access(
2786            Path::new("/dev/stdout"),
2787            FsAccess::Write
2788        ));
2789        assert!(is_standard_io_device_for_access(
2790            Path::new("/dev/stderr"),
2791            FsAccess::Write
2792        ));
2793        assert!(is_standard_io_device_for_access(
2794            Path::new("/dev/null"),
2795            FsAccess::Write
2796        ));
2797        assert!(is_standard_io_device_for_access(
2798            Path::new("/dev/fd/0"),
2799            FsAccess::Read
2800        ));
2801        assert!(is_standard_io_device_for_access(
2802            Path::new("/dev/fd/12"),
2803            FsAccess::Write
2804        ));
2805        assert!(!is_standard_io_device_for_access(
2806            Path::new("/dev/null"),
2807            FsAccess::Delete
2808        ));
2809        assert!(!is_standard_io_device_for_access(
2810            Path::new("/dev/fd/"),
2811            FsAccess::Write
2812        ));
2813        assert!(!is_standard_io_device_for_access(
2814            Path::new("/dev/fd/1a"),
2815            FsAccess::Write
2816        ));
2817        assert!(!is_standard_io_device_for_access(
2818            Path::new("/dev/stdoutx"),
2819            FsAccess::Write
2820        ));
2821        assert!(!is_standard_io_device_for_access(
2822            Path::new("/dev/random"),
2823            FsAccess::Read
2824        ));
2825        assert!(!is_standard_io_device_for_access(
2826            Path::new("/tmp/dev/null"),
2827            FsAccess::Write
2828        ));
2829    }
2830
2831    #[test]
2832    fn path_within_root_accepts_root_and_children() {
2833        let root = Path::new("/tmp/harn-root");
2834        assert!(path_is_within(root, root));
2835        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
2836        assert!(!path_is_within(
2837            Path::new("/tmp/harn-root-other/file"),
2838            root
2839        ));
2840    }
2841
2842    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2843    #[test]
2844    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
2845        let temp_home = tempfile::tempdir().expect("temp home");
2846        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
2847        let normalized_home = normalize_for_policy(temp_home.path());
2848
2849        for suffix in [
2850            Path::new(".cargo"),
2851            Path::new(".rustup"),
2852            Path::new(".pyenv"),
2853            Path::new(".nvm"),
2854            Path::new(".volta"),
2855            Path::new(".local/share/uv"),
2856            Path::new("go"),
2857        ] {
2858            assert!(
2859                roots.iter().any(|path| path.ends_with(suffix)),
2860                "expected a developer-toolchain grant for {}",
2861                suffix.display()
2862            );
2863        }
2864        assert!(
2865            roots.iter().all(|path| path.starts_with(&normalized_home)),
2866            "developer-toolchain roots must stay under HOME"
2867        );
2868    }
2869
2870    #[cfg(any(target_os = "linux", target_os = "macos"))]
2871    #[test]
2872    fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
2873        let temp_home = tempfile::tempdir().expect("temp home");
2874        let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
2875        let normalized_home = normalize_for_policy(temp_home.path());
2876
2877        for suffix in [
2878            Path::new(".gradle"),
2879            Path::new(".m2"),
2880            Path::new(".konan"),
2881            Path::new("Library/Caches/CocoaPods"),
2882            Path::new("Library/Developer/Xcode/DerivedData"),
2883        ] {
2884            assert!(
2885                roots.iter().any(|path| path.ends_with(suffix)),
2886                "expected a JVM/iOS toolchain cache grant for {}",
2887                suffix.display()
2888            );
2889        }
2890        assert!(
2891            roots.iter().all(|path| path.starts_with(&normalized_home)),
2892            "toolchain cache roots must stay under HOME"
2893        );
2894    }
2895
2896    #[cfg(any(target_os = "linux", target_os = "macos"))]
2897    #[test]
2898    fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
2899        let mut policy = CapabilityPolicy {
2900            workspace_roots: vec!["/tmp/harn-workspace".to_string()],
2901            ..CapabilityPolicy::default()
2902        };
2903        // Default presets include DeveloperToolchains -> cache roots present
2904        // (only when an absolute HOME is resolvable on this host).
2905        if sandbox_user_home_dir().is_some() {
2906            assert!(
2907                !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2908                "default presets should render JVM/iOS cache roots"
2909            );
2910        }
2911        // Explicitly dropping DeveloperToolchains removes them.
2912        policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2913        assert!(
2914            process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2915            "cache roots must be gated on the DeveloperToolchains preset"
2916        );
2917    }
2918
2919    #[test]
2920    fn os_hardened_profile_overrides_fallback_env() {
2921        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
2922        // point of the profile is that the OS sandbox is required.
2923        // We cannot mutate the env here without races, so just check
2924        // the pure resolution function.
2925        assert_eq!(
2926            effective_fallback(SandboxProfile::OsHardened),
2927            SandboxFallback::Enforce
2928        );
2929    }
2930
2931    #[test]
2932    fn unrestricted_profile_skips_active_sandbox() {
2933        let policy = CapabilityPolicy {
2934            sandbox_profile: SandboxProfile::Unrestricted,
2935            workspace_roots: vec!["/tmp".to_string()],
2936            ..Default::default()
2937        };
2938        crate::orchestration::push_execution_policy(policy);
2939        let result = active_sandbox_policy();
2940        crate::orchestration::pop_execution_policy();
2941        assert!(
2942            result.is_none(),
2943            "Unrestricted profile must short-circuit sandbox dispatch"
2944        );
2945    }
2946
2947    #[test]
2948    fn worktree_profile_engages_active_sandbox() {
2949        let policy = CapabilityPolicy {
2950            sandbox_profile: SandboxProfile::Worktree,
2951            workspace_roots: vec!["/tmp".to_string()],
2952            ..Default::default()
2953        };
2954        crate::orchestration::push_execution_policy(policy);
2955        let result = active_sandbox_policy();
2956        crate::orchestration::pop_execution_policy();
2957        assert!(
2958            result.is_some(),
2959            "Worktree profile must keep sandbox dispatch active"
2960        );
2961    }
2962}