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