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(any(unix, windows))]
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(windows)]
629fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
630    let (parent, file_name) = win_scoped_parent(target, true)?;
631    let full = parent.join(&file_name);
632    win_reject_reparse_leaf(&full)?;
633    atomic_write_unscoped(&full, contents)
634}
635
636#[cfg(all(not(unix), not(windows)))]
637fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
638    let full = target.root.join(&target.relative);
639    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
640        std::fs::create_dir_all(parent)?;
641    }
642    atomic_write_unscoped(&full, contents)
643}
644
645#[cfg(unix)]
646fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
647    use std::os::fd::AsRawFd;
648
649    // Append creates the file (and its parent chain) when absent, matching the
650    // pre-hardening `append_file` contract (append-to-a-new-log-in-a-new-dir).
651    let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
652    let mut file = openat_file(
653        parent.as_raw_fd(),
654        &file_name,
655        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
656        0o666,
657    )?;
658    file.write_all(contents)
659}
660
661#[cfg(windows)]
662fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
663    let (parent, file_name) = win_scoped_parent(target, true)?;
664    let full = parent.join(&file_name);
665    win_reject_reparse_leaf(&full)?;
666    append_unscoped(&full, contents)
667}
668
669#[cfg(all(not(unix), not(windows)))]
670fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
671    let full = target.root.join(&target.relative);
672    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
673        std::fs::create_dir_all(parent)?;
674    }
675    append_unscoped(&full, contents)
676}
677
678#[cfg(unix)]
679fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
680    use std::os::fd::AsRawFd;
681
682    let mut source = std::fs::File::open(src)?;
683    let source_metadata = source.metadata().ok();
684    let (parent, file_name) = open_parent_dir_scoped(target)?;
685    let mut destination = openat_file(
686        parent.as_raw_fd(),
687        &file_name,
688        libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
689        0o666,
690    )?;
691    let copied = io::copy(&mut source, &mut destination)?;
692    destination.sync_all()?;
693    if let Some(metadata) = source_metadata {
694        let _ = destination.set_permissions(metadata.permissions());
695    }
696    sync_dir_fd(parent.as_raw_fd());
697    Ok(copied)
698}
699
700#[cfg(windows)]
701fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
702    // Copy destinations keep the "parent must already exist" contract, so the
703    // walk does not auto-create (create_parents = false), matching the unix
704    // `open_parent_dir_scoped` path.
705    let (parent, file_name) = win_scoped_parent(target, false)?;
706    let full = parent.join(&file_name);
707    win_reject_reparse_leaf(&full)?;
708    std::fs::copy(src, full)
709}
710
711#[cfg(all(not(unix), not(windows)))]
712fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
713    std::fs::copy(src, target.root.join(&target.relative))
714}
715
716#[cfg(unix)]
717fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
718    use std::os::fd::AsRawFd;
719
720    let (src_parent, src_name) = open_parent_dir_scoped(src)?;
721    let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
722    renameat_name(
723        src_parent.as_raw_fd(),
724        &src_name,
725        dst_parent.as_raw_fd(),
726        &dst_name,
727    )?;
728    sync_dir_fd(dst_parent.as_raw_fd());
729    Ok(())
730}
731
732#[cfg(windows)]
733fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
734    // No `win_reject_reparse_leaf` on the leaves here: rename operates on the
735    // directory entry (the name), not by traversing through the target, and may
736    // legitimately move/replace a reparse point. The junction-traversal defense
737    // is the ancestor-chain validation in `win_scoped_parent`.
738    let (src_parent, src_name) = win_scoped_parent(src, false)?;
739    let (dst_parent, dst_name) = win_scoped_parent(dst, false)?;
740    std::fs::rename(src_parent.join(&src_name), dst_parent.join(&dst_name))
741}
742
743#[cfg(all(not(unix), not(windows)))]
744fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
745    std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
746}
747
748#[cfg(unix)]
749fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
750    use std::os::fd::AsRawFd;
751
752    let (parent, file_name) = open_parent_dir_scoped(target)?;
753    mkdirat_name(parent.as_raw_fd(), &file_name)?;
754    sync_dir_fd(parent.as_raw_fd());
755    Ok(())
756}
757
758#[cfg(windows)]
759fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
760    // Single `mkdir` keeps the "parent must already exist" contract; only the
761    // leaf is created, after verifying no ancestor is a junction/symlink. No
762    // `win_reject_reparse_leaf` on the leaf: `CreateDirectoryW` creates a NEW
763    // name and fails `AlreadyExists` if anything (reparse point or not) already
764    // occupies it — it never writes *through* an existing leaf — so the
765    // ancestor-chain validation is the whole defense.
766    let (parent, file_name) = win_scoped_parent(target, false)?;
767    win_create_dir_raw(&parent.join(&file_name))
768}
769
770#[cfg(all(not(unix), not(windows)))]
771fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
772    std::fs::create_dir(target.root.join(&target.relative))
773}
774
775#[cfg(unix)]
776fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
777    use std::os::fd::AsRawFd;
778
779    let root = open_dir_absolute(&target.root)?;
780    let mut current = root;
781    for component in clean_relative_components(&target.relative)? {
782        match open_dir_at(current.as_raw_fd(), &component) {
783            Ok(next) => current = next,
784            Err(error) if error.kind() == io::ErrorKind::NotFound => {
785                mkdirat_name(current.as_raw_fd(), &component)?;
786                let next = open_dir_at(current.as_raw_fd(), &component)?;
787                current = next;
788            }
789            Err(error) => return Err(error),
790        }
791    }
792    Ok(())
793}
794
795#[cfg(windows)]
796fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
797    // `mkdir -p`: every component (including the leaf) is created, and each is
798    // verified not to be a reparse point (junction/symlink) as the walk descends.
799    let components = win_clean_relative_components(&target.relative)?;
800    win_walk_components(&target.root, &components, true)?;
801    Ok(())
802}
803
804#[cfg(all(not(unix), not(windows)))]
805fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
806    std::fs::create_dir_all(target.root.join(&target.relative))
807}
808
809#[cfg(unix)]
810/// Create the ancestor directory chain of a scoped write/append target,
811/// mirroring the pre-hardening `mkdir -p` behavior of the content-producing
812/// filesystem builtins (`write_file`, `write_file_bytes`, `append_file`) and
813/// `http_download`. Only the ancestors are created — the final path component
814/// is the file the caller writes. Traversal stays scoped to `target.root` and
815/// symlink-safe (each level is opened with `O_NOFOLLOW` via `open_dir_at`), so
816/// this preserves the security properties #4147 added while restoring the
817/// directory-autovivification contract downstream code depends on. Concurrent
818/// creators are tolerated (a losing `mkdirat` that sees `EEXIST` is ignored).
819///
820/// The returned parent fd is the one content-producing callers must use for
821/// their final `openat`/`renameat`, so the path is not resolved again between
822/// mkdir-p and the write.
823///
824/// Structural operations (copy destination, rename, remove, single `mkdir`)
825/// intentionally do NOT call this — they keep `open_parent_dir_scoped`'s
826/// "parent must already exist" semantics.
827#[cfg(unix)]
828fn ensure_parent_dirs_scoped(
829    target: &ScopedMutationTarget,
830) -> io::Result<(std::os::fd::OwnedFd, String)> {
831    use std::os::fd::AsRawFd;
832
833    let mut components = clean_relative_components(&target.relative)?;
834    let file_name = components.pop().ok_or_else(|| {
835        io::Error::new(
836            io::ErrorKind::InvalidInput,
837            format!(
838                "sandbox scoped open requires a file name: {}",
839                target.relative.display()
840            ),
841        )
842    })?;
843    let root = open_dir_absolute(&target.root)?;
844    let mut current = root;
845    for component in components {
846        match open_dir_at(current.as_raw_fd(), &component) {
847            Ok(next) => current = next,
848            Err(error) if error.kind() == io::ErrorKind::NotFound => {
849                if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
850                    if mkerr.kind() != io::ErrorKind::AlreadyExists {
851                        return Err(mkerr);
852                    }
853                }
854                current = open_dir_at(current.as_raw_fd(), &component)?;
855            }
856            Err(error) => return Err(error),
857        }
858    }
859    Ok((current, file_name))
860}
861
862#[cfg(unix)]
863fn open_parent_dir_scoped(
864    target: &ScopedMutationTarget,
865) -> io::Result<(std::os::fd::OwnedFd, String)> {
866    use std::os::fd::AsRawFd;
867
868    let mut components = clean_relative_components(&target.relative)?;
869    let file_name = components.pop().ok_or_else(|| {
870        io::Error::new(
871            io::ErrorKind::InvalidInput,
872            format!(
873                "sandbox scoped open requires a file name: {}",
874                target.relative.display()
875            ),
876        )
877    })?;
878    let root = open_dir_absolute(&target.root)?;
879    let mut current = root;
880    for component in components {
881        current = open_dir_at(current.as_raw_fd(), &component)?;
882    }
883    Ok((current, file_name))
884}
885
886#[cfg(unix)]
887fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
888    use std::os::unix::ffi::OsStrExt;
889
890    let mut out = Vec::new();
891    for component in path.components() {
892        match component {
893            Component::Normal(value) => {
894                let bytes = value.as_bytes();
895                if bytes.contains(&0) {
896                    return Err(io::Error::new(
897                        io::ErrorKind::InvalidInput,
898                        format!("path component contains NUL: {}", path.display()),
899                    ));
900                }
901                out.push(value.to_string_lossy().into_owned());
902                if out.len() > MAX_SCOPED_PATH_COMPONENTS {
903                    return Err(io::Error::new(
904                        io::ErrorKind::InvalidInput,
905                        format!(
906                            "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
907                            path.display()
908                        ),
909                    ));
910                }
911            }
912            Component::CurDir => {}
913            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
914                return Err(io::Error::new(
915                    io::ErrorKind::InvalidInput,
916                    format!("sandbox scoped path must stay relative: {}", path.display()),
917                ));
918            }
919        }
920    }
921    Ok(out)
922}
923
924#[cfg(unix)]
925fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
926    use std::os::fd::{FromRawFd, OwnedFd};
927    use std::os::unix::ffi::OsStrExt;
928
929    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
930        io::Error::new(
931            io::ErrorKind::InvalidInput,
932            format!("path contains NUL: {}", path.display()),
933        )
934    })?;
935    let fd = unsafe {
936        libc::open(
937            c_path.as_ptr(),
938            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
939        )
940    };
941    if fd < 0 {
942        return Err(io::Error::last_os_error());
943    }
944    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
945}
946
947#[cfg(unix)]
948fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
949    use std::os::fd::{FromRawFd, OwnedFd};
950
951    let c_name = c_name(name)?;
952    let fd = unsafe {
953        libc::openat(
954            parent_fd,
955            c_name.as_ptr(),
956            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
957        )
958    };
959    if fd < 0 {
960        return Err(io::Error::last_os_error());
961    }
962    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
963}
964
965#[cfg(unix)]
966fn openat_file(
967    parent_fd: libc::c_int,
968    name: &str,
969    flags: libc::c_int,
970    mode: libc::mode_t,
971) -> io::Result<std::fs::File> {
972    use std::os::fd::FromRawFd;
973
974    let c_name = c_name(name)?;
975    let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
976    if fd < 0 {
977        return Err(io::Error::last_os_error());
978    }
979    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
980}
981
982#[cfg(unix)]
983fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
984    let c_name = c_name(name)?;
985    let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
986    if rc != 0 {
987        return Err(io::Error::last_os_error());
988    }
989    Ok(())
990}
991
992#[cfg(unix)]
993fn renameat_name(
994    old_parent_fd: libc::c_int,
995    old_name: &str,
996    new_parent_fd: libc::c_int,
997    new_name: &str,
998) -> io::Result<()> {
999    let old_name = c_name(old_name)?;
1000    let new_name = c_name(new_name)?;
1001    let rc = unsafe {
1002        libc::renameat(
1003            old_parent_fd,
1004            old_name.as_ptr(),
1005            new_parent_fd,
1006            new_name.as_ptr(),
1007        )
1008    };
1009    if rc != 0 {
1010        return Err(io::Error::last_os_error());
1011    }
1012    Ok(())
1013}
1014
1015#[cfg(unix)]
1016fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
1017    let c_name = c_name(name)?;
1018    let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
1019    if rc != 0 {
1020        return Err(io::Error::last_os_error());
1021    }
1022    Ok(())
1023}
1024
1025#[cfg(unix)]
1026fn sync_dir_fd(fd: libc::c_int) {
1027    let _ = unsafe { libc::fsync(fd) };
1028}
1029
1030#[cfg(unix)]
1031fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1032    std::ffi::CString::new(name).map_err(|_| {
1033        io::Error::new(
1034            io::ErrorKind::InvalidInput,
1035            format!("path component contains NUL: {name:?}"),
1036        )
1037    })
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Windows scoped-walk primitives (junction/symlink-safe directory descent).
1042//
1043// Windows has no `openat`, and `O_NOFOLLOW` has no equivalent that a plain
1044// `std::fs` path open honors — worse, a *junction* (mount-point reparse point)
1045// IS a directory and is creatable by a non-admin user, so it slips past every
1046// "is this a symlink" check that only inspects the leaf. The unix path defends
1047// the whole chain by opening each component `O_NOFOLLOW`; the Windows path here
1048// mirrors that by opening each walked component with
1049// `FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS` (so the reparse
1050// point itself is opened, not its target) and refusing the walk the moment any
1051// component reports a mount-point or symlink reparse tag. See
1052// research/scoped-fs-mkdir-footguns (#12, RedirectionGuard) for the class.
1053//
1054// Residual, Windows-CI-only: because there is no handle-relative openat here,
1055// each component is re-resolved by string as the walk descends, so a
1056// concurrent attacker who swaps an *already-validated* ancestor for a junction
1057// between our check and the next open is not fully closed (the unix fd-walk is;
1058// the true fix is `NtCreateFile` with a `RootDirectory` handle). The
1059// intermediate-junction class the acceptance test covers IS closed.
1060// ---------------------------------------------------------------------------
1061
1062/// Reparse tags Windows assigns to the two "traverses out of the tree"
1063/// reparse-point kinds the scoped walk must refuse. Defined locally so the
1064/// module does not need the `Win32_System_SystemServices` feature just for two
1065/// stable ABI constants.
1066#[cfg(windows)]
1067const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1068#[cfg(windows)]
1069const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1070
1071#[cfg(windows)]
1072fn win_wide(path: &Path) -> Vec<u16> {
1073    use std::os::windows::ffi::OsStrExt;
1074    path.as_os_str()
1075        .encode_wide()
1076        .chain(std::iter::once(0))
1077        .collect()
1078}
1079
1080/// Refuse `path` if it is a mount-point or symlink reparse point. The handle is
1081/// opened with `FILE_FLAG_OPEN_REPARSE_POINT` so we inspect the reparse point
1082/// itself rather than following it, and `FILE_FLAG_BACKUP_SEMANTICS` so a
1083/// directory handle is permitted. A `NotFound` error is propagated unchanged so
1084/// callers can distinguish "does not exist yet" (create it) from "exists and is
1085/// hostile" (refuse).
1086#[cfg(windows)]
1087fn win_reject_reparse_point(path: &Path) -> io::Result<()> {
1088    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1089    use windows_sys::Win32::Storage::FileSystem::{
1090        CreateFileW, FileAttributeTagInfo, GetFileInformationByHandleEx,
1091        FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS,
1092        FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1093        OPEN_EXISTING,
1094    };
1095
1096    // Query attributes only; no read/write access to the object is needed.
1097    const FILE_READ_ATTRIBUTES: u32 = 0x0080;
1098
1099    let wide = win_wide(path);
1100    let handle = unsafe {
1101        CreateFileW(
1102            wide.as_ptr(),
1103            FILE_READ_ATTRIBUTES,
1104            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1105            std::ptr::null(),
1106            OPEN_EXISTING,
1107            FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
1108            std::ptr::null_mut(),
1109        )
1110    };
1111    if handle == INVALID_HANDLE_VALUE {
1112        return Err(io::Error::last_os_error());
1113    }
1114    let mut info = FILE_ATTRIBUTE_TAG_INFO::default();
1115    let ok = unsafe {
1116        GetFileInformationByHandleEx(
1117            handle,
1118            FileAttributeTagInfo,
1119            std::ptr::from_mut(&mut info).cast(),
1120            std::mem::size_of::<FILE_ATTRIBUTE_TAG_INFO>() as u32,
1121        )
1122    };
1123    let result = if ok == 0 {
1124        Err(io::Error::last_os_error())
1125    } else if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1126        && matches!(
1127            info.ReparseTag,
1128            IO_REPARSE_TAG_MOUNT_POINT | IO_REPARSE_TAG_SYMLINK
1129        )
1130    {
1131        Err(io::Error::new(
1132            io::ErrorKind::PermissionDenied,
1133            format!(
1134                "sandbox scoped walk refuses reparse-point (junction/symlink) component: {}",
1135                path.display()
1136            ),
1137        ))
1138    } else {
1139        Ok(())
1140    };
1141    unsafe {
1142        CloseHandle(handle);
1143    }
1144    result
1145}
1146
1147/// A reparse point that squats on a *leaf* target name is refused; a leaf that
1148/// simply does not exist yet is fine (the caller is about to create it).
1149#[cfg(windows)]
1150fn win_reject_reparse_leaf(path: &Path) -> io::Result<()> {
1151    match win_reject_reparse_point(path) {
1152        Ok(()) => Ok(()),
1153        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1154        Err(err) => Err(err),
1155    }
1156}
1157
1158/// Low-level `CreateDirectoryW` used by the scoped walk. Kept raw (rather than
1159/// `std::fs::create_dir`) so the recurrence-guard lint can assert the scoped
1160/// Windows walk never reaches for a path-based `std::fs` mutation.
1161#[cfg(windows)]
1162fn win_create_dir_raw(path: &Path) -> io::Result<()> {
1163    use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
1164    let wide = win_wide(path);
1165    let ok = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) };
1166    if ok == 0 {
1167        return Err(io::Error::last_os_error());
1168    }
1169    Ok(())
1170}
1171
1172/// Windows analogue of [`clean_relative_components`]: reject `..`, absolute, and
1173/// drive-prefixed components, cap the depth, and refuse embedded NULs — keeping
1174/// the same invariants the unix walk enforces before descending.
1175#[cfg(windows)]
1176fn win_clean_relative_components(path: &Path) -> io::Result<Vec<std::ffi::OsString>> {
1177    use std::os::windows::ffi::OsStrExt;
1178
1179    let mut out: Vec<std::ffi::OsString> = Vec::new();
1180    for component in path.components() {
1181        match component {
1182            Component::Normal(value) => {
1183                if value.encode_wide().any(|unit| unit == 0) {
1184                    return Err(io::Error::new(
1185                        io::ErrorKind::InvalidInput,
1186                        format!("path component contains NUL: {}", path.display()),
1187                    ));
1188                }
1189                out.push(value.to_os_string());
1190                if out.len() > MAX_SCOPED_PATH_COMPONENTS {
1191                    return Err(io::Error::new(
1192                        io::ErrorKind::InvalidInput,
1193                        format!(
1194                            "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
1195                            path.display()
1196                        ),
1197                    ));
1198                }
1199            }
1200            Component::CurDir => {}
1201            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1202                return Err(io::Error::new(
1203                    io::ErrorKind::InvalidInput,
1204                    format!("sandbox scoped path must stay relative: {}", path.display()),
1205                ));
1206            }
1207        }
1208    }
1209    Ok(out)
1210}
1211
1212/// Descend `root` through `components`, refusing any component that is a
1213/// junction/symlink reparse point. When `create` is set, missing directories
1214/// are created (`mkdir -p`) and re-validated immediately, so a directory we
1215/// just made cannot be a reparse point. Returns the validated deepest path.
1216#[cfg(windows)]
1217fn win_walk_components(
1218    root: &Path,
1219    components: &[std::ffi::OsString],
1220    create: bool,
1221) -> io::Result<PathBuf> {
1222    // The configured workspace root is trusted, but verify it resolves to a real
1223    // directory and is not itself a reparse point, mirroring the unix
1224    // `open_dir_absolute` `O_NOFOLLOW` open of the root.
1225    win_reject_reparse_point(root)?;
1226    let mut current = root.to_path_buf();
1227    for component in components {
1228        current.push(component);
1229        match win_reject_reparse_point(&current) {
1230            Ok(()) => {}
1231            Err(err) if create && err.kind() == io::ErrorKind::NotFound => {
1232                match win_create_dir_raw(&current) {
1233                    Ok(()) => {}
1234                    // A concurrent creator won the race; tolerate and re-validate.
1235                    Err(mkerr) if mkerr.kind() == io::ErrorKind::AlreadyExists => {}
1236                    Err(mkerr) => return Err(mkerr),
1237                }
1238                win_reject_reparse_point(&current)?;
1239            }
1240            Err(err) => return Err(err),
1241        }
1242    }
1243    Ok(current)
1244}
1245
1246/// Validate the ancestor chain of a scoped target on Windows and return the
1247/// verified `(parent_dir, leaf_name)`. With `create_parents`, missing ancestors
1248/// are created; without it, the parent must already exist (structural ops).
1249#[cfg(windows)]
1250fn win_scoped_parent(
1251    target: &ScopedMutationTarget,
1252    create_parents: bool,
1253) -> io::Result<(PathBuf, std::ffi::OsString)> {
1254    let mut components = win_clean_relative_components(&target.relative)?;
1255    let file_name = components.pop().ok_or_else(|| {
1256        io::Error::new(
1257            io::ErrorKind::InvalidInput,
1258            format!(
1259                "sandbox scoped open requires a file name: {}",
1260                target.relative.display()
1261            ),
1262        )
1263    })?;
1264    let parent = win_walk_components(&target.root, &components, create_parents)?;
1265    Ok((parent, file_name))
1266}
1267
1268pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
1269    let Some(policy) = crate::orchestration::current_execution_policy() else {
1270        return Ok(());
1271    };
1272    enforce_process_cwd_for_policy(path, &policy)
1273}
1274
1275pub fn push_process_sandbox_scope(
1276    scope: ProcessSandboxScope,
1277) -> Result<ProcessSandboxScopeGuard, VmError> {
1278    let Some(mut policy) = crate::orchestration::current_execution_policy() else {
1279        return Ok(ProcessSandboxScopeGuard { pushed: false });
1280    };
1281    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1282        return Ok(ProcessSandboxScopeGuard { pushed: false });
1283    }
1284
1285    let requested_roots: Vec<PathBuf> = scope
1286        .workspace_roots
1287        .iter()
1288        .filter_map(|root| {
1289            let trimmed = root.trim();
1290            (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1291        })
1292        .collect();
1293    if requested_roots.is_empty() {
1294        return Ok(ProcessSandboxScopeGuard { pushed: false });
1295    }
1296
1297    if !policy.workspace_roots.is_empty() {
1298        let ceiling_roots = normalized_workspace_roots(&policy);
1299        if let Some(rejected) = requested_roots.iter().find(|root| {
1300            !ceiling_roots
1301                .iter()
1302                .any(|ceiling| path_is_within(root, ceiling))
1303        }) {
1304            return Err(sandbox_rejection(format!(
1305                "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1306                rejected.display(),
1307                ceiling_roots
1308                    .iter()
1309                    .map(|root| root.display().to_string())
1310                    .collect::<Vec<_>>()
1311                    .join(", ")
1312            )));
1313        }
1314    }
1315
1316    let mut merged_roots = if policy.workspace_roots.is_empty() {
1317        Vec::new()
1318    } else {
1319        normalized_workspace_roots(&policy)
1320    };
1321    for requested in requested_roots {
1322        if !merged_roots
1323            .iter()
1324            .any(|existing| path_is_within(&requested, existing))
1325        {
1326            merged_roots.push(requested);
1327        }
1328    }
1329    policy.workspace_roots = merged_roots
1330        .into_iter()
1331        .map(|root| root.display().to_string())
1332        .collect();
1333    crate::orchestration::push_execution_policy(policy);
1334    Ok(ProcessSandboxScopeGuard { pushed: true })
1335}
1336
1337fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1338    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1339        return Ok(());
1340    }
1341    let candidate = normalize_for_policy(path);
1342    let roots = normalized_workspace_roots(policy);
1343    if roots.iter().any(|root| path_is_within(&candidate, root)) {
1344        return Ok(());
1345    }
1346    Err(sandbox_rejection(format!(
1347        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1348        candidate.display(),
1349        roots
1350            .iter()
1351            .map(|root| root.display().to_string())
1352            .collect::<Vec<_>>()
1353            .join(", ")
1354    )))
1355}
1356
1357pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1358    let (policy, profile) = match active_sandbox_policy() {
1359        Some(value) => value,
1360        None => {
1361            let mut command = Command::new(program);
1362            command.args(args);
1363            return Ok(command);
1364        }
1365    };
1366    build_std_command::<ActiveBackend>(program, args, &policy, profile)
1367}
1368
1369pub fn tokio_command_for(
1370    program: &str,
1371    args: &[String],
1372) -> Result<tokio::process::Command, VmError> {
1373    let (policy, profile) = match active_sandbox_policy() {
1374        Some(value) => value,
1375        None => {
1376            let mut command = tokio::process::Command::new(program);
1377            command.args(args);
1378            return Ok(command);
1379        }
1380    };
1381    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1382}
1383
1384pub fn command_output(
1385    program: &str,
1386    args: &[String],
1387    config: &ProcessCommandConfig,
1388) -> Result<Output, VmError> {
1389    // Testbench replay mode short-circuits the spawn entirely.
1390    // Recording mode falls through; the duration is captured by the
1391    // recording handle below using the injected mock clock when one
1392    // is active.
1393    if let Some(intercepted) =
1394        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1395    {
1396        return intercepted.map_err(|message| {
1397            VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1398        });
1399    }
1400
1401    let recording =
1402        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1403
1404    let output = match active_sandbox_policy() {
1405        Some((policy, profile)) => {
1406            let config = sandboxed_process_config(config, &policy)?;
1407            ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1408        }
1409        None => {
1410            let mut command = Command::new(program);
1411            command.args(args);
1412            apply_process_config(&mut command, config);
1413            // Interrupt-aware `Command::output()`: puts the child in its own
1414            // kill group and gracefully terminates the whole group when the
1415            // invoking scope is cancelled, a deadline fires, or the VM is
1416            // dropped. See `crate::op_interrupt`.
1417            crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1418                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1419            })?
1420        }
1421    };
1422    if let Some(error) = process_violation_error(&output) {
1423        return Err(error);
1424    }
1425    if let Some(span) = recording {
1426        span.finish(&output);
1427    }
1428    Ok(output)
1429}
1430
1431fn sandboxed_process_config(
1432    config: &ProcessCommandConfig,
1433    policy: &CapabilityPolicy,
1434) -> Result<ProcessCommandConfig, VmError> {
1435    let mut resolved = config.clone();
1436    if let Some(cwd) = resolved.cwd.as_ref() {
1437        enforce_process_cwd_for_policy(cwd, policy)?;
1438    } else {
1439        resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1440    }
1441    neutralize_rustc_wrapper(&mut resolved.env);
1442    inject_workspace_tmpdir(&mut resolved.env, policy);
1443    Ok(resolved)
1444}
1445
1446/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
1447///
1448/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
1449/// cargo build is the first caller to spawn it, the daemon inherits the
1450/// `sandbox-exec` confinement permanently — even after it reparents to
1451/// launchd — and then fails *every* later build machine-wide with
1452/// `Operation not permitted` (it can no longer read build inputs outside the
1453/// sandbox root nor write its cache dir under `~/Library/Caches`). A
1454/// per-command sandbox must never be allowed to poison a cross-workspace
1455/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
1456/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
1457/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
1458/// on-disk cache and all unsandboxed builds are unaffected.
1459fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1460    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1461        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1462            entry.1.clear();
1463        } else {
1464            env.push((key.to_string(), String::new()));
1465        }
1466    }
1467}
1468
1469/// Workspace-relative directory name for the sandbox-writable temp dir that
1470/// [`workspace_local_tmpdir`] points `TMPDIR`/`TMP`/`TEMP` at. Lives inside a
1471/// writable workspace root (which both OS backends already grant) so any
1472/// toolchain that honors `TMPDIR` writes its intermediates somewhere the
1473/// sandbox permits, instead of the unwritable system `/tmp`.
1474pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1475
1476/// The environment keys a workspace-local temp dir is exported under. `TMPDIR`
1477/// is the POSIX/Rust/clang/gcc/Go/Swift convention; `TMP`/`TEMP` cover tools
1478/// (and Windows toolchains) that read those instead.
1479pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1480
1481/// Resolve the sandbox-writable, workspace-local temp directory for `policy`,
1482/// creating it lazily.
1483///
1484/// Compiler linkers (`rustc`/`cc`/`ld`, Go, Swift, …) and countless other
1485/// toolchains write intermediate object/temp files to `$TMPDIR`, defaulting to
1486/// the system `/tmp` when it is unset. Under a restricted profile `/tmp` is
1487/// outside the writable workspace roots, so those writes are denied and a build
1488/// that would otherwise succeed FALSE-FAILS for an infrastructure reason. By
1489/// pointing the child's temp dir at a directory *inside* the first writable
1490/// workspace root — which the OS sandbox already grants write access to — the
1491/// build's temp writes land somewhere permitted without widening the sandbox.
1492///
1493/// Returns `None` when the policy declares no writable workspace root (there is
1494/// nowhere sandbox-writable to anchor the temp dir) or when the directory could
1495/// not be created (the caller then leaves the child's inherited temp dir
1496/// untouched rather than failing the spawn).
1497pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1498    let root = normalized_workspace_roots(policy).into_iter().next()?;
1499    let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1500    if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1501        warn_once(
1502            "handler_sandbox_workspace_tmpdir",
1503            &format!(
1504                "could not create workspace-local temp dir '{}': {error}; \
1505                 leaving the child's inherited temp dir in place",
1506                tmpdir.display()
1507            ),
1508        );
1509        return None;
1510    }
1511    // Keep the temp dir's churn out of every git-based diff/status (so it never
1512    // leaks into an agent's view, a PR, or eval grading) by self-ignoring its
1513    // own contents. A `.gitignore` of `*` inside the dir excludes everything,
1514    // including itself, regardless of whether the workspace tracks it. Written
1515    // best-effort and only when absent so we don't thrash an existing file.
1516    let ignore = tmpdir.join(".gitignore");
1517    if !ignore.exists() {
1518        let _ = std::fs::write(
1519            &ignore,
1520            "# Created by the Harn sandbox; safe to delete.\n*\n",
1521        );
1522    }
1523    Some(tmpdir)
1524}
1525
1526/// Overlay `TMPDIR`/`TMP`/`TEMP` onto a child's env so a sandboxed toolchain
1527/// writes its intermediates to a workspace-local, sandbox-writable directory
1528/// instead of the unwritable system `/tmp` (see [`workspace_local_tmpdir`]).
1529///
1530/// A key the caller set explicitly in `env` is left untouched — an intentional
1531/// per-call `TMPDIR` is honored. The inherited-from-parent value is *not*
1532/// preserved: that is exactly the non-writable `/tmp` (or empty) we must
1533/// override. No-op under an unrestricted/absent policy or when no writable
1534/// workspace root is available.
1535pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1536    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1537        return;
1538    }
1539    let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1540        return;
1541    };
1542    let tmpdir = tmpdir.display().to_string();
1543    for key in TMPDIR_ENV_KEYS {
1544        if env.iter().any(|(existing, _)| existing == key) {
1545            // The caller pinned this key explicitly; respect it.
1546            continue;
1547        }
1548        env.push((key.to_string(), tmpdir.clone()));
1549    }
1550}
1551
1552/// The `TMPDIR`/`TMP`/`TEMP` overrides for the *currently active* execution
1553/// policy, as `(key, value)` pairs, or an empty vec when no restricted policy
1554/// is active or no writable workspace root exists.
1555///
1556/// This reads the active execution policy directly (gating only on a restricted
1557/// `sandbox_profile`), deliberately *not* through [`active_sandbox_policy`]:
1558/// the workspace-local temp dir is a benefit of the child env, independent of
1559/// whether OS confinement is enforced, so it must still engage under
1560/// `HARN_HANDLER_SANDBOX=warn`/`off` (which only weaken *enforcement*, not the
1561/// profile). [`inject_workspace_tmpdir`] still no-ops under `Unrestricted`.
1562///
1563/// This is the entry point the `host_call("process", …)` exec/spawn builder and
1564/// the `harn-hostlib` real spawner use to overlay the keys onto a
1565/// `Command`/`tokio::process::Command`, skipping any the caller already pinned.
1566pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1567    let Some(policy) = crate::orchestration::current_execution_policy() else {
1568        return Vec::new();
1569    };
1570    let mut env = Vec::new();
1571    inject_workspace_tmpdir(&mut env, &policy);
1572    env
1573}
1574
1575/// Environment overlay that pins a child tool's *message* output to a
1576/// deterministic, English, UTF-8-preserving locale, as `(key, value)` pairs.
1577///
1578/// Build/test/verify commands inherit the parent environment, so a user whose
1579/// shell sets `LC_ALL=ja_JP.UTF-8` (or `LANG=de_DE.UTF-8`) would otherwise get
1580/// *localized* compiler/test output. Every downstream matcher that keys on
1581/// English diagnostics — deterministic syntax repair, error-signature
1582/// grounding, completion/pass-fail classification — would then silently
1583/// misfire for a non-Anglosphere user. Forcing a stable message locale is the
1584/// root-cause fix: it keeps the English matchers correct by construction,
1585/// without shipping per-locale translations of every toolchain.
1586///
1587/// `LC_MESSAGES=C` forces untranslated (English) messages for gettext-based
1588/// tools (gcc/clang, git-l10n, GNU coreutils, gradle) while deliberately *not*
1589/// touching `LC_CTYPE`/`LANG`, so UTF-8 handling of non-ASCII source and
1590/// identifiers is preserved (unlike the blunt `LC_ALL=C`, which forces an ASCII
1591/// ctype and can mangle non-ASCII identifiers in diagnostics). The .NET CLI
1592/// ignores `LC_*` and localizes from its own variable / the OS UI language, so
1593/// `DOTNET_CLI_UI_LANGUAGE=en` is required in addition.
1594///
1595/// A user-inherited `LC_ALL` would override `LC_MESSAGES`, so the spawn sites
1596/// additionally strip `LC_ALL` (unless the caller pinned it) before applying
1597/// this overlay. Both are subject to the caller-pinned-key rule (like the
1598/// `TMPDIR` overlay): an explicit `env`/`env_remove` still wins.
1599pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1600    vec![
1601        ("LC_MESSAGES".to_string(), "C".to_string()),
1602        ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1603    ]
1604}
1605
1606/// The environment variable a user-inherited value of which would override
1607/// [`deterministic_message_locale_env`]'s `LC_MESSAGES`. Spawn sites strip this
1608/// (unless the caller pinned it) so the forced message locale actually takes
1609/// effect. Kept as a named constant so both spawn paths stay in sync.
1610pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1611
1612fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1613    let roots = normalized_workspace_roots(policy);
1614    let current = std::env::current_dir().map_err(|error| {
1615        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1616            format!("process cwd resolution failed: {error}"),
1617        )))
1618    })?;
1619    let current = normalize_for_policy(&current);
1620    if roots.iter().any(|root| path_is_within(&current, root)) {
1621        return Ok(current);
1622    }
1623    roots.first().cloned().ok_or_else(|| {
1624        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1625            "process cwd resolution failed: no workspace root available",
1626        )))
1627    })
1628}
1629
1630fn build_std_command<B: SandboxBackend + ?Sized>(
1631    program: &str,
1632    args: &[String],
1633    policy: &CapabilityPolicy,
1634    profile: SandboxProfile,
1635) -> Result<Command, VmError> {
1636    let mut command = Command::new(program);
1637    command.args(args);
1638    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1639        PrepareOutcome::Direct => Ok(command),
1640        PrepareOutcome::WrappedExec { wrapper, args } => {
1641            let mut wrapped = Command::new(wrapper);
1642            wrapped.args(args);
1643            Ok(wrapped)
1644        }
1645    }
1646}
1647
1648fn build_tokio_command<B: SandboxBackend + ?Sized>(
1649    program: &str,
1650    args: &[String],
1651    policy: &CapabilityPolicy,
1652    profile: SandboxProfile,
1653) -> Result<tokio::process::Command, VmError> {
1654    let mut command = tokio::process::Command::new(program);
1655    command.args(args);
1656    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1657        PrepareOutcome::Direct => Ok(command),
1658        PrepareOutcome::WrappedExec { wrapper, args } => {
1659            let mut wrapped = tokio::process::Command::new(wrapper);
1660            wrapped.args(args);
1661            Ok(wrapped)
1662        }
1663    }
1664}
1665
1666pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1667    let policy = crate::orchestration::current_execution_policy()?;
1668    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1669        return None;
1670    }
1671    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1672        || !ActiveBackend::available()
1673    {
1674        return None;
1675    }
1676    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1677    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1678    if !output.status.success()
1679        && (stderr.contains("operation not permitted")
1680            || stderr.contains("permission denied")
1681            || stderr.contains("access is denied")
1682            || stdout.contains("operation not permitted"))
1683    {
1684        return Some(sandbox_rejection(sandbox_process_violation_message(
1685            format!(
1686                "sandbox violation: process was denied by the OS sandbox (status {})",
1687                output.status.code().unwrap_or(-1)
1688            ),
1689        )));
1690    }
1691    if sandbox_signal_status(output) {
1692        return Some(sandbox_rejection(sandbox_process_violation_message(
1693            format!(
1694                "sandbox violation: process was terminated by the OS sandbox (status {})",
1695                output.status
1696            ),
1697        )));
1698    }
1699    None
1700}
1701
1702pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1703    let policy = crate::orchestration::current_execution_policy()?;
1704    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1705        return None;
1706    }
1707    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1708        || !ActiveBackend::available()
1709    {
1710        return None;
1711    }
1712    let message = error.to_string().to_ascii_lowercase();
1713    if error.kind() == std::io::ErrorKind::PermissionDenied
1714        || message.contains("operation not permitted")
1715        || message.contains("permission denied")
1716        || message.contains("access is denied")
1717    {
1718        return Some(sandbox_rejection(sandbox_process_violation_message(
1719            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1720        )));
1721    }
1722    None
1723}
1724
1725#[cfg(unix)]
1726fn sandbox_signal_status(output: &std::process::Output) -> bool {
1727    use std::os::unix::process::ExitStatusExt;
1728
1729    matches!(
1730        output.status.signal(),
1731        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1732    )
1733}
1734
1735#[cfg(not(unix))]
1736fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1737    false
1738}
1739
1740/// Returns the active capability policy and the resolved sandbox
1741/// profile, or `None` if confinement should be skipped entirely. The
1742/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
1743/// hatch both produce `None`. The `Wasi` profile also produces `None`
1744/// on the host spawn path — testbench mode intercepts subprocesses
1745/// before they reach this layer, so the host-spawn fallback should be
1746/// a normal direct exec.
1747pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1748    let policy = crate::orchestration::current_execution_policy()?;
1749    let profile = policy.sandbox_profile;
1750    match profile {
1751        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1752        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1753            if effective_fallback(profile) == SandboxFallback::Off {
1754                None
1755            } else {
1756                Some((policy, profile))
1757            }
1758        }
1759    }
1760}
1761
1762fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1763    if let Some(cwd) = config.cwd.as_ref() {
1764        command.current_dir(cwd);
1765    }
1766    command.envs(config.env.iter().map(|(key, value)| (key, value)));
1767    if config.stdin_null {
1768        command.stdin(Stdio::null());
1769    }
1770}
1771
1772fn spawn_error(error: std::io::Error) -> VmError {
1773    VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1774        format!("process spawn failed: {error}"),
1775    )))
1776}
1777
1778/// Resolve the fallback policy for the requested profile. `OsHardened`
1779/// always enforces — that is the entire point of the profile, so the
1780/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
1781/// the env var (default `warn`).
1782pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1783    if matches!(profile, SandboxProfile::OsHardened) {
1784        return SandboxFallback::Enforce;
1785    }
1786    match std::env::var(HANDLER_SANDBOX_ENV)
1787        .unwrap_or_else(|_| "warn".to_string())
1788        .trim()
1789        .to_ascii_lowercase()
1790        .as_str()
1791    {
1792        "0" | "false" | "off" | "none" => SandboxFallback::Off,
1793        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1794        _ => SandboxFallback::Warn,
1795    }
1796}
1797
1798pub(crate) fn warn_once(key: &str, message: &str) {
1799    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1800    if inserted {
1801        crate::events::log_warn("handler_sandbox", message);
1802    }
1803}
1804
1805pub(crate) fn sandbox_rejection(message: String) -> VmError {
1806    VmError::CategorizedError {
1807        message,
1808        category: ErrorCategory::ToolRejected,
1809    }
1810}
1811
1812fn sandbox_process_violation_message(summary: String) -> String {
1813    format!(
1814        "{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"
1815    )
1816}
1817
1818/// Helper for backends that can't attach confinement at all (macOS
1819/// without `/usr/bin/sandbox-exec`, Windows when called through the
1820/// `Command`-returning entry points): either fail loudly under
1821/// `OsHardened` / `enforce`, or warn once and proceed direct.
1822///
1823/// Linux and OpenBSD don't reach this path — they install confinement
1824/// in `pre_exec` and surface unavailability through `landlock_profile`
1825/// directly. The dead-code lint allow keeps the helper compilable on
1826/// targets where no backend uses it.
1827#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1828pub(crate) fn unavailable(
1829    message: &str,
1830    profile: SandboxProfile,
1831) -> Result<PrepareOutcome, VmError> {
1832    match effective_fallback(profile) {
1833        SandboxFallback::Off | SandboxFallback::Warn => {
1834            warn_once("handler_sandbox_unavailable", message);
1835            Ok(PrepareOutcome::Direct)
1836        }
1837        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1838            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1839        ))),
1840    }
1841}
1842
1843/// Writable workspace roots derived from the active agent session's
1844/// workspace anchor: the anchor `primary` plus any `Extend` (writable)
1845/// mounts. Read-only mounts are intentionally excluded — they are not
1846/// writable jail roots (a read of one is permitted via the read-only-roots
1847/// path, but a write must not be). Returns `None` when there is no current
1848/// session or the session has no anchor, so the caller falls back to the
1849/// process execution root.
1850fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1851    let session_id = crate::agent_sessions::current_session_id()?;
1852    let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1853    let mut roots = vec![anchor.primary.clone()];
1854    for mounted in &anchor.additional_roots {
1855        if matches!(
1856            mounted.mount_mode,
1857            crate::workspace_anchor::MountMode::Extend
1858        ) {
1859            roots.push(mounted.path.clone());
1860        }
1861    }
1862    Some(roots)
1863}
1864
1865/// The host-declared project root (`HARN_PROJECT_ROOT`), if set and
1866/// non-empty. This mirrors the standalone project-root fallback the
1867/// `workspace.project_root` host capability already uses
1868/// (`stdlib::host`), so the write jail and the reported project root
1869/// agree. It is the project a run is bound to even when the OS process
1870/// cwd differs (the eval harness runs `burin-headless` from the repo with
1871/// `--project <fixture>`, and the real IDE may launch from elsewhere).
1872fn project_root_env_workspace_root() -> Option<PathBuf> {
1873    std::env::var("HARN_PROJECT_ROOT")
1874        .ok()
1875        .map(|value| value.trim().to_string())
1876        .filter(|value| !value.is_empty())
1877        .map(PathBuf::from)
1878}
1879
1880fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1881    if policy.workspace_roots.is_empty() {
1882        // An empty `policy.workspace_roots` means no explicit write-jail was
1883        // configured for this call. Historically this fell straight back to the
1884        // process execution root, but under the eval pattern (process cwd !=
1885        // `--project`) and dispatch fan-out children, the process cwd is the
1886        // repo, not the project the run is bound to — so a write that correctly
1887        // resolved INTO the project was rejected as outside the jail
1888        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
1889        // silently compensated. Prefer, in order: (1) the active agent
1890        // session's workspace anchor (primary + writable `Extend` mounts) when
1891        // the session is anchored; (2) the host-declared `HARN_PROJECT_ROOT`
1892        // project root (robust across the session nesting that an unanchored
1893        // dispatch child sees); (3) the process execution root, the historical
1894        // default. Explicit `policy.workspace_roots` still take precedence
1895        // (handled in the non-empty branch below).
1896        if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1897            return anchor_roots
1898                .iter()
1899                .map(|root| normalize_for_policy(root))
1900                .collect();
1901        }
1902        if let Some(project_root) = project_root_env_workspace_root() {
1903            return vec![normalize_for_policy(&project_root)];
1904        }
1905        return vec![normalize_for_policy(
1906            &crate::stdlib::process::execution_root_path(),
1907        )];
1908    }
1909    policy
1910        .workspace_roots
1911        .iter()
1912        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1913        .collect()
1914}
1915
1916pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1917    normalized_workspace_roots(policy)
1918}
1919
1920/// Normalize the policy's read-only roots. Unlike
1921/// [`normalized_workspace_roots`], an empty list stays empty — read-only
1922/// scope is purely additive, so there is no execution-root fallback to
1923/// synthesize.
1924fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1925    policy
1926        .read_only_roots
1927        .iter()
1928        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1929        .collect()
1930}
1931
1932#[cfg(any(
1933    target_os = "linux",
1934    target_os = "macos",
1935    target_os = "openbsd",
1936    target_os = "windows"
1937))]
1938pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1939    normalized_read_only_roots(policy)
1940}
1941
1942#[cfg(any(
1943    target_os = "linux",
1944    target_os = "macos",
1945    target_os = "openbsd",
1946    target_os = "windows"
1947))]
1948pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1949    normalized_process_roots(&policy.process_sandbox.read_roots)
1950}
1951
1952#[cfg(any(
1953    target_os = "linux",
1954    target_os = "macos",
1955    target_os = "openbsd",
1956    target_os = "windows"
1957))]
1958pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1959    normalized_process_roots(&policy.process_sandbox.write_roots)
1960}
1961
1962#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1963pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1964    policy.process_sandbox.effective_presets()
1965}
1966
1967#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1968pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1969    policy: &CapabilityPolicy,
1970) -> Vec<PathBuf> {
1971    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1972        return Vec::new();
1973    }
1974    let Some(home) = sandbox_user_home_dir() else {
1975        return Vec::new();
1976    };
1977    developer_toolchain_read_roots_for_home(&home)
1978}
1979
1980#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1981pub(crate) fn process_sandbox_package_manager_config_read_roots(
1982    policy: &CapabilityPolicy,
1983) -> Vec<PathBuf> {
1984    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1985        return Vec::new();
1986    }
1987    let Some(home) = sandbox_user_home_dir() else {
1988        return Vec::new();
1989    };
1990    package_manager_config_read_roots_for_home(&home)
1991}
1992
1993/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
1994/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
1995/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
1996/// read-only: a build legitimately populates `~/.gradle/caches`,
1997/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
1998/// gated on the `DeveloperToolchains` preset and granted *write* only when the
1999/// active policy already permits workspace writes (mirroring `UserTemp`); under
2000/// a read-only policy they fall back to read access so dependency resolution
2001/// still works.
2002// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
2003// (Landlock) sandbox backends; the Windows backend deliberately does not grant
2004// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
2005// targets keeps `-D warnings` happy on Windows, where this would otherwise be
2006// dead code.
2007#[cfg(any(target_os = "linux", target_os = "macos"))]
2008pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
2009    policy: &CapabilityPolicy,
2010) -> Vec<PathBuf> {
2011    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2012        return Vec::new();
2013    }
2014    let Some(home) = sandbox_user_home_dir() else {
2015        return Vec::new();
2016    };
2017    developer_toolchain_cache_write_roots_for_home(&home)
2018}
2019
2020#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2021fn sandbox_user_home_dir() -> Option<PathBuf> {
2022    // Only an absolute home grounds the user-scope read-roots below; a
2023    // relative or unset home yields no extra roots (the safe direction).
2024    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
2025}
2026
2027#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2028pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2029    let mut roots: Vec<_> = [
2030        ".asdf",
2031        ".bun",
2032        ".cargo",
2033        ".fnm",
2034        ".juliaup",
2035        ".local/bin",
2036        ".local/share/mise",
2037        ".local/share/uv",
2038        ".nvm",
2039        ".pyenv",
2040        ".rbenv",
2041        ".rustup",
2042        ".sdkman",
2043        ".swiftly",
2044        ".volta",
2045        "go",
2046    ]
2047    .into_iter()
2048    .map(|entry| normalize_for_policy(&home.join(entry)))
2049    .collect();
2050    #[cfg(target_os = "windows")]
2051    roots.extend(
2052        [
2053            "AppData/Local/Programs/Python",
2054            "AppData/Local/uv",
2055            "AppData/Roaming/uv",
2056            "scoop",
2057        ]
2058        .into_iter()
2059        .map(|entry| normalize_for_policy(&home.join(entry))),
2060    );
2061    roots.sort_unstable();
2062    roots.dedup();
2063    roots
2064}
2065
2066/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
2067/// the macOS seatbelt and Linux Landlock backends render the same set; the
2068/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
2069/// `optional`/NotFound handling in each backend skips roots that do not exist.
2070#[cfg(any(target_os = "linux", target_os = "macos"))]
2071pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
2072    let mut roots: Vec<_> = [
2073        ".gradle",                             // Gradle (JVM/Android/Kotlin)
2074        ".m2",                                 // Maven (JVM)
2075        ".konan",                              // Kotlin/Native
2076        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
2077        "Library/Developer/Xcode/DerivedData", // Xcode build products
2078    ]
2079    .into_iter()
2080    .map(|entry| normalize_for_policy(&home.join(entry)))
2081    .collect();
2082    roots.sort_unstable();
2083    roots.dedup();
2084    roots
2085}
2086
2087#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2088pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2089    let mut roots: Vec<_> = [
2090        ".npmrc",
2091        ".gitconfig",
2092        ".netrc",
2093        ".yarnrc.yml",
2094        ".config",
2095        ".npm",
2096        ".cache",
2097        ".pip",
2098        ".pypirc",
2099        ".cargo/config",
2100        ".cargo/config.toml",
2101        ".cargo/credentials",
2102        ".cargo/credentials.toml",
2103        ".cargo/registry",
2104        ".cargo/git",
2105    ]
2106    .into_iter()
2107    .map(|entry| normalize_for_policy(&home.join(entry)))
2108    .collect();
2109    roots.sort_unstable();
2110    roots.dedup();
2111    roots
2112}
2113
2114#[cfg(any(
2115    target_os = "linux",
2116    target_os = "macos",
2117    target_os = "openbsd",
2118    target_os = "windows"
2119))]
2120fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
2121    roots
2122        .iter()
2123        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
2124        .collect()
2125}
2126
2127fn resolve_policy_path(path: &str) -> PathBuf {
2128    let candidate = PathBuf::from(path);
2129    if candidate.is_absolute() {
2130        candidate
2131    } else {
2132        crate::stdlib::process::execution_root_path().join(candidate)
2133    }
2134}
2135
2136fn normalize_for_policy(path: &Path) -> PathBuf {
2137    let absolute = if path.is_absolute() {
2138        path.to_path_buf()
2139    } else {
2140        crate::stdlib::process::execution_root_path().join(path)
2141    };
2142    let absolute = normalize_lexically(&absolute);
2143    if let Ok(canonical) = absolute.canonicalize() {
2144        return canonical;
2145    }
2146
2147    let mut existing = absolute.as_path();
2148    let mut suffix = Vec::new();
2149    while !existing.exists() {
2150        let Some(parent) = existing.parent() else {
2151            return normalize_lexically(&absolute);
2152        };
2153        if let Some(name) = existing.file_name() {
2154            suffix.push(name.to_os_string());
2155        }
2156        existing = parent;
2157    }
2158
2159    let mut normalized = existing
2160        .canonicalize()
2161        .unwrap_or_else(|_| normalize_lexically(existing));
2162    for component in suffix.iter().rev() {
2163        normalized.push(component);
2164    }
2165    normalize_lexically(&normalized)
2166}
2167
2168fn normalize_lexically(path: &Path) -> PathBuf {
2169    let mut normalized = PathBuf::new();
2170    for component in path.components() {
2171        match component {
2172            Component::CurDir => {}
2173            Component::ParentDir => {
2174                normalized.pop();
2175            }
2176            other => normalized.push(other.as_os_str()),
2177        }
2178    }
2179    normalized
2180}
2181
2182fn path_is_within(path: &Path, root: &Path) -> bool {
2183    path == root || path.starts_with(root)
2184}
2185
2186/// Resolve `path` to an absolute, lexically-normalized form for the standard
2187/// I/O device check. Unlike [`normalize_for_policy`] this never calls
2188/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
2189/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
2190fn normalize_io_device_path(path: &Path) -> PathBuf {
2191    let absolute = if path.is_absolute() {
2192        path.to_path_buf()
2193    } else {
2194        crate::stdlib::process::execution_root_path().join(path)
2195    };
2196    normalize_lexically(&absolute)
2197}
2198
2199/// Whether `path` is one of the standard process I/O device files that the
2200/// sandbox treats as a stream rather than a workspace mutation for this access:
2201/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
2202/// stream operation. `path` must already be absolute and lexically normalized.
2203fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
2204    match access {
2205        FsAccess::Read => {
2206            matches!(
2207                path.to_str(),
2208                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
2209            ) || is_dev_fd_descriptor(path)
2210        }
2211        FsAccess::Write => {
2212            matches!(
2213                path.to_str(),
2214                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
2215            ) || is_dev_fd_descriptor(path)
2216        }
2217        FsAccess::Delete => false,
2218    }
2219}
2220
2221/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
2222/// digits (the numeric file-descriptor aliases for the standard streams).
2223fn is_dev_fd_descriptor(path: &Path) -> bool {
2224    let Some(text) = path.to_str() else {
2225        return false;
2226    };
2227    let Some(fd) = text.strip_prefix("/dev/fd/") else {
2228        return false;
2229    };
2230    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
2231}
2232
2233#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
2234pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
2235    use crate::tool_annotations::SideEffectLevel;
2236    policy
2237        .side_effect_level
2238        .as_ref()
2239        .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
2240        .unwrap_or(true)
2241}
2242
2243#[cfg(any(
2244    target_os = "linux",
2245    target_os = "macos",
2246    target_os = "openbsd",
2247    target_os = "windows"
2248))]
2249pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
2250    policy.capabilities.is_empty()
2251        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
2252}
2253
2254#[cfg(any(
2255    target_os = "linux",
2256    target_os = "macos",
2257    target_os = "openbsd",
2258    target_os = "windows"
2259))]
2260pub(crate) fn policy_allows_capability(
2261    policy: &CapabilityPolicy,
2262    capability: &str,
2263    ops: &[&str],
2264) -> bool {
2265    policy
2266        .capabilities
2267        .get(capability)
2268        .map(|allowed| {
2269            ops.iter()
2270                .any(|op| allowed.iter().any(|candidate| candidate == op))
2271        })
2272        .unwrap_or(false)
2273}
2274
2275impl FsAccess {
2276    fn verb(self) -> &'static str {
2277        match self {
2278            FsAccess::Read => "read",
2279            FsAccess::Write => "write",
2280            FsAccess::Delete => "delete",
2281        }
2282    }
2283}
2284
2285#[cfg(test)]
2286mod tests {
2287    use super::*;
2288    use crate::orchestration::{pop_execution_policy, push_execution_policy};
2289
2290    #[test]
2291    fn missing_create_path_normalizes_against_existing_parent() {
2292        let dir = tempfile::tempdir().unwrap();
2293        let nested = dir.path().join("a/../new.txt");
2294        let normalized = normalize_for_policy(&nested);
2295        assert_eq!(
2296            normalized,
2297            normalize_for_policy(&dir.path().join("new.txt"))
2298        );
2299    }
2300
2301    #[test]
2302    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
2303        // Serialize env mutation and clear HARN_PROJECT_ROOT so this asserts the
2304        // pure execution-root fallback (the project-root-env preference is
2305        // covered by the next test).
2306        let _env_lock = crate::runtime_paths::test_env_lock()
2307            .lock()
2308            .unwrap_or_else(|poisoned| poisoned.into_inner());
2309        std::env::remove_var("HARN_PROJECT_ROOT");
2310        let dir = tempfile::tempdir().unwrap();
2311        crate::stdlib::process::set_thread_execution_context(Some(
2312            crate::orchestration::RunExecutionRecord {
2313                cwd: Some(dir.path().to_string_lossy().into_owned()),
2314                source_dir: None,
2315                env: Default::default(),
2316                adapter: None,
2317                repo_path: None,
2318                worktree_path: None,
2319                branch: None,
2320                base_ref: None,
2321                cleanup: None,
2322            },
2323        ));
2324        push_execution_policy(CapabilityPolicy {
2325            sandbox_profile: SandboxProfile::Worktree,
2326            ..CapabilityPolicy::default()
2327        });
2328
2329        assert!(
2330            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2331        );
2332        let outside = tempfile::tempdir().unwrap();
2333        assert!(enforce_fs_path(
2334            "read_file",
2335            &outside.path().join("outside.txt"),
2336            FsAccess::Read
2337        )
2338        .is_err());
2339
2340        pop_execution_policy();
2341        crate::stdlib::process::set_thread_execution_context(None);
2342    }
2343
2344    /// Regression for burin-labs/burin-code#3288. When a restricted policy has
2345    /// no explicit `workspace_roots`, the write jail must follow the
2346    /// host-declared `HARN_PROJECT_ROOT` project — NOT the process/execution
2347    /// cwd. This is the eval/dispatch pattern: `burin-headless` runs from the
2348    /// repo (`execution cwd = repo`) with `--project <fixture>` + matching
2349    /// `HARN_PROJECT_ROOT`, and a dispatched sub-agent worker's writes resolve
2350    /// into the fixture. Before the fix the empty-roots fallback used the
2351    /// execution cwd (the repo), so the in-project write was rejected
2352    /// (HARN-CAP-201) and the dispatched child wrote nothing.
2353    #[test]
2354    fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2355        let _env_lock = crate::runtime_paths::test_env_lock()
2356            .lock()
2357            .unwrap_or_else(|poisoned| poisoned.into_inner());
2358        let project = tempfile::tempdir().unwrap();
2359        let execution_cwd = tempfile::tempdir().unwrap();
2360        std::env::set_var("HARN_PROJECT_ROOT", project.path());
2361        crate::stdlib::process::set_thread_execution_context(Some(
2362            crate::orchestration::RunExecutionRecord {
2363                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2364                source_dir: None,
2365                env: Default::default(),
2366                adapter: None,
2367                repo_path: None,
2368                worktree_path: None,
2369                branch: None,
2370                base_ref: None,
2371                cleanup: None,
2372            },
2373        ));
2374        push_execution_policy(CapabilityPolicy {
2375            sandbox_profile: SandboxProfile::Worktree,
2376            ..CapabilityPolicy::default()
2377        });
2378
2379        // A write that resolves INTO the project is allowed even though the
2380        // process/execution cwd is elsewhere.
2381        assert!(
2382            enforce_fs_path(
2383                "write_file",
2384                &project.path().join("test/created.ts"),
2385                FsAccess::Write,
2386            )
2387            .is_ok(),
2388            "write into HARN_PROJECT_ROOT must be allowed"
2389        );
2390        // A write under the execution cwd (the repo, in the eval pattern) is NOT
2391        // the project and must still be rejected — the jail moved to the
2392        // project, it did not widen to both.
2393        assert!(
2394            enforce_fs_path(
2395                "write_file",
2396                &execution_cwd.path().join("escape.ts"),
2397                FsAccess::Write,
2398            )
2399            .is_err(),
2400            "write under the execution cwd (outside the project) must be rejected"
2401        );
2402
2403        pop_execution_policy();
2404        crate::stdlib::process::set_thread_execution_context(None);
2405        std::env::remove_var("HARN_PROJECT_ROOT");
2406    }
2407
2408    #[test]
2409    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2410        let dir = tempfile::tempdir().unwrap();
2411        crate::stdlib::process::set_thread_execution_context(Some(
2412            crate::orchestration::RunExecutionRecord {
2413                cwd: Some(dir.path().to_string_lossy().into_owned()),
2414                source_dir: None,
2415                env: Default::default(),
2416                adapter: None,
2417                repo_path: None,
2418                worktree_path: None,
2419                branch: None,
2420                base_ref: None,
2421                cleanup: None,
2422            },
2423        ));
2424        push_execution_policy(CapabilityPolicy {
2425            sandbox_profile: SandboxProfile::Worktree,
2426            ..CapabilityPolicy::default()
2427        });
2428
2429        assert!(enforce_process_cwd(dir.path()).is_ok());
2430        let outside = tempfile::tempdir().unwrap();
2431        assert!(enforce_process_cwd(outside.path()).is_err());
2432
2433        pop_execution_policy();
2434        crate::stdlib::process::set_thread_execution_context(None);
2435    }
2436
2437    #[test]
2438    fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2439        let _env_lock = crate::runtime_paths::test_env_lock()
2440            .lock()
2441            .unwrap_or_else(|poisoned| poisoned.into_inner());
2442        std::env::remove_var("HARN_PROJECT_ROOT");
2443        let execution_root = tempfile::tempdir().unwrap();
2444        let command_root = tempfile::tempdir().unwrap();
2445        crate::stdlib::process::set_thread_execution_context(Some(
2446            crate::orchestration::RunExecutionRecord {
2447                cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2448                source_dir: None,
2449                env: Default::default(),
2450                adapter: None,
2451                repo_path: None,
2452                worktree_path: None,
2453                branch: None,
2454                base_ref: None,
2455                cleanup: None,
2456            },
2457        ));
2458        push_execution_policy(CapabilityPolicy {
2459            sandbox_profile: SandboxProfile::Worktree,
2460            ..CapabilityPolicy::default()
2461        });
2462
2463        assert!(
2464            enforce_process_cwd(command_root.path()).is_err(),
2465            "before the scoped overlay the command temp root is outside the execution-root fallback",
2466        );
2467        {
2468            let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2469                workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2470            })
2471            .unwrap();
2472            assert!(
2473                enforce_process_cwd(command_root.path()).is_ok(),
2474                "scoped command root must be usable as the process cwd"
2475            );
2476            assert!(
2477                enforce_process_cwd(execution_root.path()).is_err(),
2478                "the scoped root must narrow the concrete spawn jail instead of widening it"
2479            );
2480        }
2481        assert!(
2482            enforce_process_cwd(command_root.path()).is_err(),
2483            "the scoped command root must pop after the command spawn"
2484        );
2485
2486        pop_execution_policy();
2487        crate::stdlib::process::set_thread_execution_context(None);
2488    }
2489
2490    #[test]
2491    fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2492        let workspace = tempfile::tempdir().unwrap();
2493        let inside = workspace.path().join("subdir");
2494        std::fs::create_dir(&inside).unwrap();
2495        let outside = tempfile::tempdir().unwrap();
2496        push_execution_policy(CapabilityPolicy {
2497            sandbox_profile: SandboxProfile::Worktree,
2498            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2499            ..CapabilityPolicy::default()
2500        });
2501
2502        assert!(
2503            push_process_sandbox_scope(ProcessSandboxScope {
2504                workspace_roots: vec![inside.to_string_lossy().into_owned()],
2505            })
2506            .is_ok(),
2507            "a command subroot inside the explicit workspace ceiling is allowed"
2508        );
2509        assert!(
2510            push_process_sandbox_scope(ProcessSandboxScope {
2511                workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2512            })
2513            .is_err(),
2514            "a command root outside the explicit workspace ceiling must be rejected"
2515        );
2516
2517        pop_execution_policy();
2518    }
2519
2520    #[cfg(unix)]
2521    #[test]
2522    fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2523        let workspace = tempfile::tempdir().unwrap();
2524        let outside = tempfile::tempdir().unwrap();
2525        let safe_parent = workspace.path().join("safe");
2526        std::fs::create_dir(&safe_parent).unwrap();
2527        let path = safe_parent.join("state.json");
2528        push_execution_policy(CapabilityPolicy {
2529            sandbox_profile: SandboxProfile::Worktree,
2530            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2531            ..CapabilityPolicy::default()
2532        });
2533        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2534            .unwrap()
2535            .expect("restricted policy yields scoped target");
2536
2537        std::fs::remove_dir(&safe_parent).unwrap();
2538        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2539        let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2540        pop_execution_policy();
2541
2542        assert!(
2543            !outside.path().join("state.json").exists(),
2544            "scoped write must not follow swapped parent symlink; error={error}"
2545        );
2546    }
2547
2548    #[cfg(unix)]
2549    #[test]
2550    fn scoped_write_creates_missing_parent_dirs() {
2551        // Regression guard for #4147: the scoped-write hardening dropped the
2552        // `mkdir -p` contract that `write_file` / `write_text` / `http_download`
2553        // relied on, surfacing downstream as "No such file or directory" when a
2554        // tool wrote into a not-yet-created state dir. A write to a path whose
2555        // ancestors are missing must recreate them (scoped) and succeed.
2556        let workspace = tempfile::tempdir().unwrap();
2557        let path = workspace.path().join("a/b/c/plan.json");
2558        push_execution_policy(CapabilityPolicy {
2559            sandbox_profile: SandboxProfile::Worktree,
2560            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2561            ..CapabilityPolicy::default()
2562        });
2563        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2564            .unwrap()
2565            .expect("restricted policy yields scoped target");
2566        let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2567        pop_execution_policy();
2568
2569        assert!(
2570            result.is_ok(),
2571            "write must create missing parents: {result:?}"
2572        );
2573        assert_eq!(
2574            std::fs::read(&path).unwrap(),
2575            b"{\"plan\":\"Redis-backed\"}".to_vec()
2576        );
2577    }
2578
2579    #[cfg(unix)]
2580    #[test]
2581    fn scoped_parent_autocreate_refuses_preexisting_symlink_component() {
2582        let workspace = tempfile::tempdir().unwrap();
2583        let outside = tempfile::tempdir().unwrap();
2584        std::fs::create_dir(workspace.path().join("a")).unwrap();
2585        std::os::unix::fs::symlink(outside.path(), workspace.path().join("a/b")).unwrap();
2586        let target = ScopedMutationTarget {
2587            root: workspace.path().to_path_buf(),
2588            relative: PathBuf::from("a/b/c/plan.json"),
2589        };
2590
2591        let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2592
2593        assert!(
2594            !outside.path().join("c/plan.json").exists(),
2595            "parent creation must not follow a symlinked component; error={error}"
2596        );
2597        assert!(
2598            !workspace.path().join("a/b/c").exists(),
2599            "symlinked components must not be treated as satisfied parents"
2600        );
2601    }
2602
2603    #[test]
2604    fn scoped_read_check_does_not_create_missing_parent_dirs() {
2605        let workspace = tempfile::tempdir().unwrap();
2606        let path = workspace.path().join("a/b/c/plan.json");
2607        push_execution_policy(CapabilityPolicy {
2608            sandbox_profile: SandboxProfile::Worktree,
2609            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2610            ..CapabilityPolicy::default()
2611        });
2612        let result = enforce_fs_path("read_file", &path, FsAccess::Read);
2613        pop_execution_policy();
2614
2615        assert!(
2616            result.is_ok(),
2617            "read path inside workspace should be in scope"
2618        );
2619        assert!(
2620            !workspace.path().join("a").exists(),
2621            "read/list/stat/delete scope checks must not create ancestors"
2622        );
2623    }
2624
2625    #[cfg(unix)]
2626    #[test]
2627    fn scoped_paths_refuse_excessive_component_depth() {
2628        let mut relative = PathBuf::new();
2629        for index in 0..=MAX_SCOPED_PATH_COMPONENTS {
2630            relative.push(format!("d{index}"));
2631        }
2632
2633        let error = clean_relative_components(&relative).unwrap_err();
2634
2635        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2636        assert!(
2637            error
2638                .to_string()
2639                .contains("sandbox scoped path exceeds 256 components"),
2640            "unexpected error: {error}"
2641        );
2642    }
2643
2644    // ----------------------------------------------------------------------
2645    // (a) Windows junctions: a junction IS a directory (non-admin creatable),
2646    // so it bypasses every leaf-only symlink check. The scoped walk must refuse
2647    // it as an intermediate component. Junctions (mount points) need no admin
2648    // rights, so `mklink /J` works in CI; NTFS symlinks would need elevation.
2649    // ----------------------------------------------------------------------
2650    #[cfg(windows)]
2651    #[test]
2652    fn scoped_walk_refuses_junction_intermediate_component() {
2653        let workspace = tempfile::tempdir().unwrap();
2654        let outside = tempfile::tempdir().unwrap();
2655        std::fs::create_dir(workspace.path().join("a")).unwrap();
2656        let link = workspace.path().join("a").join("b");
2657        let status = std::process::Command::new("cmd")
2658            .arg("/C")
2659            .arg("mklink")
2660            .arg("/J")
2661            .arg(&link)
2662            .arg(outside.path())
2663            .status()
2664            .expect("spawn mklink /J");
2665        assert!(status.success(), "mklink /J failed to plant a junction");
2666
2667        let target = ScopedMutationTarget {
2668            root: workspace.path().to_path_buf(),
2669            relative: PathBuf::from("a/b/c/plan.json"),
2670        };
2671        let error = win_scoped_parent(&target, true).unwrap_err();
2672
2673        assert_eq!(
2674            error.kind(),
2675            io::ErrorKind::PermissionDenied,
2676            "junction intermediate must be refused, got {error}"
2677        );
2678        assert!(
2679            !outside.path().join("c").exists(),
2680            "walk must not create through a junction; error={error}"
2681        );
2682    }
2683
2684    // ----------------------------------------------------------------------
2685    // (c) Recurrence-guard lint. runc added a linter after this bug class
2686    // recurred; we scan this module's own source so a future edit cannot
2687    // silently reintroduce a path-based mutation inside the scoped walk, and so
2688    // every scoped leaf open keeps `O_NOFOLLOW`. Two invariants are guarded:
2689    //   1. The fd-walk helpers AND the content-open fns (write/append/copy/
2690    //      rename/mkdir) never round-trip through a path-based `std::fs`/`libc`
2691    //      call — they must stay on the *at primitives so the parent fd carried
2692    //      out of the walk (#4210's no-re-resolution contract) is the one the
2693    //      write uses. A path-re-resolving write in any of those fns trips this.
2694    //   2. Every scoped leaf open, and the directory-descent primitives, pass
2695    //      `O_NOFOLLOW`; the Windows walk rejects reparse-point components.
2696    //
2697    // A source scan (not a `clippy.toml` `disallowed-methods` entry) is used
2698    // deliberately: those raw APIs are legitimate elsewhere in this module (the
2699    // *unscoped* fallbacks used when no sandbox is active, and the non-unix
2700    // fallbacks) and across harn-vm, so a crate-wide clippy ban would be all
2701    // false positives; the risky call sites are exactly the functions named
2702    // below, which a targeted scan pins precisely.
2703    //
2704    // Coverage limits (deliberate): the scan is lexical, so it (a) only guards
2705    // the *first* (unix) definition of each dual-cfg fn — the unix fd-walk is
2706    // where the contract lives; the Windows fallbacks legitimately use `std::fs`
2707    // after their own reparse-point validation and are checked structurally via
2708    // the `win_walk_components` assertion below — and (b) matches call spellings,
2709    // not semantics, so a novel escape hatch (e.g. a freshly `use`-aliased fs fn
2710    // under a new name) would need its spelling added here.
2711    // ----------------------------------------------------------------------
2712    #[test]
2713    fn scoped_walk_forbids_raw_path_filesystem_calls() {
2714        let src = include_str!("mod.rs");
2715        // Anchor every scan to the production region (everything before the test
2716        // module) so the guard cannot pass by matching its own denylist literals
2717        // or the fixture code below.
2718        let production = &src[..src.find("mod tests {").expect("test module marker present")];
2719
2720        // Return the `{ ... }` body of the first function whose signature line
2721        // starts with `sig` (the unix definitions precede the fallbacks, so the
2722        // fd-walk / fd-carried versions are the ones scanned).
2723        fn fn_body<'a>(src: &'a str, sig: &str) -> &'a str {
2724            let start = src
2725                .find(sig)
2726                .unwrap_or_else(|| panic!("scoped-walk fn not found: {sig}"));
2727            let open = start
2728                + src[start..]
2729                    .find('{')
2730                    .unwrap_or_else(|| panic!("no body brace for {sig}"));
2731            let bytes = src.as_bytes();
2732            let mut depth = 0usize;
2733            for (offset, byte) in bytes[open..].iter().enumerate() {
2734                match byte {
2735                    b'{' => depth += 1,
2736                    b'}' => {
2737                        depth -= 1;
2738                        if depth == 0 {
2739                            return &src[open..=open + offset];
2740                        }
2741                    }
2742                    _ => {}
2743                }
2744            }
2745            panic!("unbalanced braces scanning {sig}");
2746        }
2747
2748        // Path-based mutations / resolver shortcuts that must never appear in a
2749        // scoped fn: each re-resolves a full path string (re-introducing the
2750        // TOCTOU the fd-walk closes) or shortcuts symlink resolution. Bare
2751        // `create_dir(`/`rename(` catch `use`-imported (unqualified) calls; the
2752        // `std::fs::`-qualified spellings catch the fully-pathed forms not
2753        // subsumed by a bare match. `file.write(`/`io::copy(` (fd-based) are NOT
2754        // forbidden — only the path-taking `std::fs::write(`/`std::fs::copy(`.
2755        const FORBIDDEN: [&str; 10] = [
2756            "create_dir_all(",
2757            "create_dir(",
2758            "File::create(",
2759            "OpenOptions",
2760            "canonicalize(",
2761            "rename(",
2762            "remove_dir_all(",
2763            "std::fs::write(",
2764            "std::fs::copy(",
2765            "libc::open(", // a full-path open; the walk uses openat/open_dir_at
2766        ];
2767        for sig in [
2768            // fd-walk helpers.
2769            "fn ensure_parent_dirs_scoped(",
2770            "fn open_parent_dir_scoped(",
2771            "fn create_dir_all_scoped_target(",
2772            "fn create_dir_scoped_target(",
2773            // content-open fns: this is where #4210's "carry the parent fd into
2774            // the write, never re-resolve the path" contract must hold.
2775            "fn atomic_write_scoped_target(",
2776            "fn append_scoped_target(",
2777            "fn copy_scoped_target(",
2778            "fn rename_scoped_targets(",
2779        ] {
2780            let body = fn_body(production, sig);
2781            for needle in FORBIDDEN {
2782                assert!(
2783                    !body.contains(needle),
2784                    "{sig} must not use raw `{needle}`; stay on the fd-carried *at path"
2785                );
2786            }
2787        }
2788
2789        // Every scoped *leaf* open must carry O_NOFOLLOW so it cannot follow a
2790        // swapped-in leaf symlink. Skip the `openat_file` wrapper definition
2791        // (its flags arrive as a parameter); only the call sites carry literals.
2792        for (idx, _) in production.match_indices("openat_file(") {
2793            if production[..idx].ends_with("fn ") {
2794                continue;
2795            }
2796            let window = &production[idx..(idx + 300).min(production.len())];
2797            assert!(
2798                window.contains("O_NOFOLLOW"),
2799                "openat_file call site near byte {idx} must pass O_NOFOLLOW"
2800            );
2801        }
2802        // The directory-descent primitives must open O_NOFOLLOW too.
2803        for sig in ["fn open_dir_at(", "fn open_dir_absolute("] {
2804            assert!(
2805                fn_body(production, sig).contains("O_NOFOLLOW"),
2806                "{sig} must open directories with O_NOFOLLOW"
2807            );
2808        }
2809
2810        // The Windows walk is the platform's O_NOFOLLOW substitute: it must
2811        // reject reparse-point (junction/symlink) components as it descends.
2812        assert!(
2813            fn_body(production, "fn win_walk_components(").contains("win_reject_reparse_point"),
2814            "the Windows scoped walk must reject reparse-point components"
2815        );
2816    }
2817
2818    #[cfg(unix)]
2819    #[test]
2820    fn scoped_append_creates_missing_parent_dirs() {
2821        let workspace = tempfile::tempdir().unwrap();
2822        let path = workspace.path().join("logs/deep/qa.jsonl");
2823        push_execution_policy(CapabilityPolicy {
2824            sandbox_profile: SandboxProfile::Worktree,
2825            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2826            ..CapabilityPolicy::default()
2827        });
2828        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2829            .unwrap()
2830            .expect("restricted policy yields scoped target");
2831        append_scoped_target(&target, b"line1\n").unwrap();
2832        append_scoped_target(&target, b"line2\n").unwrap();
2833        pop_execution_policy();
2834
2835        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2836    }
2837
2838    // ----------------------------------------------------------------------
2839    // (d) Two-thread race on the same auto-create (CVE-2024-45310 class). Both
2840    // threads ensure the same deep parent chain concurrently; the loser of each
2841    // `mkdirat` sees EEXIST and must tolerate it. All calls must succeed, the
2842    // chain must exist exactly once inside the root, and nothing may escape.
2843    // ----------------------------------------------------------------------
2844    #[cfg(unix)]
2845    #[test]
2846    fn scoped_parent_autocreate_tolerates_concurrent_creators() {
2847        let workspace = tempfile::tempdir().unwrap();
2848        let root = workspace.path().to_path_buf();
2849        let target = ScopedMutationTarget {
2850            root: root.clone(),
2851            relative: PathBuf::from("race/deep/nested/tree/plan.json"),
2852        };
2853
2854        let threads: Vec<_> = (0..4)
2855            .map(|_| {
2856                let target = target.clone();
2857                std::thread::spawn(move || {
2858                    for _ in 0..64 {
2859                        // Each call re-walks from the root; the EEXIST branch is
2860                        // the one under contention.
2861                        ensure_parent_dirs_scoped(&target)
2862                            .expect("concurrent ensure must tolerate EEXIST");
2863                    }
2864                })
2865            })
2866            .collect();
2867        for thread in threads {
2868            thread.join().expect("worker thread panicked");
2869        }
2870
2871        assert!(
2872            root.join("race/deep/nested/tree").is_dir(),
2873            "the raced parent chain must exist inside the root"
2874        );
2875        // A final ensure resolves cleanly and yields the leaf name.
2876        let (_parent, leaf) = ensure_parent_dirs_scoped(&target).unwrap();
2877        assert_eq!(leaf, "plan.json");
2878    }
2879
2880    // A *non-directory* planted as an intermediate component must abort the walk
2881    // (ENOTDIR from the O_DIRECTORY open), not be silently mkdir'd over — the
2882    // walk descends by opening each level as a directory.
2883    #[cfg(unix)]
2884    #[test]
2885    fn scoped_parent_autocreate_refuses_file_as_intermediate_component() {
2886        let workspace = tempfile::tempdir().unwrap();
2887        std::fs::create_dir(workspace.path().join("a")).unwrap();
2888        std::fs::write(workspace.path().join("a/b"), b"i am a file").unwrap();
2889        let target = ScopedMutationTarget {
2890            root: workspace.path().to_path_buf(),
2891            relative: PathBuf::from("a/b/c/plan.json"),
2892        };
2893
2894        let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2895
2896        assert_eq!(
2897            error.raw_os_error(),
2898            Some(libc::ENOTDIR),
2899            "a regular-file intermediate must fail with ENOTDIR, got {error:?}"
2900        );
2901        assert!(
2902            !workspace.path().join("a/b/c").exists(),
2903            "a non-directory intermediate must not be traversed or created through"
2904        );
2905    }
2906
2907    #[test]
2908    fn unscoped_write_creates_missing_parent_dirs() {
2909        // With no active sandbox scope (`scoped_mutation_target` returns None),
2910        // writes flow through `atomic_write_unscoped`. That path must honor the
2911        // same `mkdir -p` contract, otherwise a trusted-context write (CLI
2912        // scripts, `harn run`, conformance) into a fresh directory fails.
2913        let dir = tempfile::tempdir().unwrap();
2914        let path = dir.path().join("x/y/z/plan.json");
2915        atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2916        assert_eq!(
2917            std::fs::read(&path).unwrap(),
2918            b"{\"plan\":\"Redis-backed\"}".to_vec()
2919        );
2920    }
2921
2922    #[test]
2923    fn unscoped_append_creates_missing_parent_dirs() {
2924        let dir = tempfile::tempdir().unwrap();
2925        let path = dir.path().join("logs/deep/qa.jsonl");
2926        append_unscoped(&path, b"line1\n").unwrap();
2927        append_unscoped(&path, b"line2\n").unwrap();
2928        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2929    }
2930
2931    #[cfg(unix)]
2932    #[test]
2933    fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
2934        // Auto-creating parents must stay symlink-safe: the create walk opens
2935        // each level with O_NOFOLLOW, so a symlinked intermediate directory
2936        // cannot be used to escape the workspace root.
2937        let workspace = tempfile::tempdir().unwrap();
2938        let outside = tempfile::tempdir().unwrap();
2939        std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
2940        let path = workspace.path().join("escape/sub/plan.json");
2941        push_execution_policy(CapabilityPolicy {
2942            sandbox_profile: SandboxProfile::Worktree,
2943            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2944            ..CapabilityPolicy::default()
2945        });
2946        // The escape is refused at whichever layer sees it first: path
2947        // resolution (`scoped_mutation_target` canonicalizes and rejects a
2948        // target outside `workspace_roots`) or, for a symlink swapped in after
2949        // resolution, the auto-create walk itself (`O_NOFOLLOW` on each level).
2950        let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
2951            Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
2952            Ok(None) | Err(_) => false,
2953        };
2954        pop_execution_policy();
2955
2956        assert!(
2957            !escaped,
2958            "must not write through a symlinked intermediate dir"
2959        );
2960        assert!(
2961            !outside.path().join("sub/plan.json").exists(),
2962            "scoped write escaped the workspace via a symlinked parent"
2963        );
2964    }
2965
2966    #[cfg(unix)]
2967    #[test]
2968    fn scoped_append_rejects_final_symlink_created_after_policy_match() {
2969        let workspace = tempfile::tempdir().unwrap();
2970        let outside = tempfile::tempdir().unwrap();
2971        let safe_parent = workspace.path().join("safe");
2972        std::fs::create_dir(&safe_parent).unwrap();
2973        let outside_file = outside.path().join("state.log");
2974        std::fs::write(&outside_file, b"outside").unwrap();
2975        let path = safe_parent.join("state.log");
2976        push_execution_policy(CapabilityPolicy {
2977            sandbox_profile: SandboxProfile::Worktree,
2978            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2979            ..CapabilityPolicy::default()
2980        });
2981        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2982            .unwrap()
2983            .expect("restricted policy yields scoped target");
2984
2985        std::os::unix::fs::symlink(&outside_file, &path).unwrap();
2986        let error = append_scoped_target(&target, b"\nescape").unwrap_err();
2987        pop_execution_policy();
2988
2989        assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
2990        assert!(
2991            error.raw_os_error() == Some(libc::ELOOP)
2992                || error.kind() == io::ErrorKind::PermissionDenied
2993                || error.kind() == io::ErrorKind::Other,
2994            "expected symlink refusal, got {error:?}"
2995        );
2996    }
2997
2998    #[cfg(unix)]
2999    #[test]
3000    fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
3001        let workspace = tempfile::tempdir().unwrap();
3002        let outside = tempfile::tempdir().unwrap();
3003        let safe_parent = workspace.path().join("safe");
3004        std::fs::create_dir(&safe_parent).unwrap();
3005        let path = safe_parent.join("nested/deeper");
3006        push_execution_policy(CapabilityPolicy {
3007            sandbox_profile: SandboxProfile::Worktree,
3008            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3009            ..CapabilityPolicy::default()
3010        });
3011        let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
3012            .unwrap()
3013            .expect("restricted policy yields scoped target");
3014
3015        std::fs::remove_dir(&safe_parent).unwrap();
3016        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
3017        let error = create_dir_all_scoped_target(&target).unwrap_err();
3018        pop_execution_policy();
3019
3020        assert!(
3021            !outside.path().join("nested").exists(),
3022            "scoped mkdir must not follow swapped parent symlink; error={error}"
3023        );
3024    }
3025
3026    #[test]
3027    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
3028        let cwd = std::env::current_dir().unwrap();
3029        let policy = CapabilityPolicy {
3030            sandbox_profile: SandboxProfile::Worktree,
3031            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3032            ..CapabilityPolicy::default()
3033        };
3034
3035        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3036
3037        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
3038    }
3039
3040    #[test]
3041    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
3042        let workspace = tempfile::tempdir().unwrap();
3043        let policy = CapabilityPolicy {
3044            sandbox_profile: SandboxProfile::Worktree,
3045            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3046            ..CapabilityPolicy::default()
3047        };
3048
3049        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3050
3051        assert_eq!(
3052            resolved.cwd.unwrap(),
3053            normalize_for_policy(workspace.path())
3054        );
3055    }
3056
3057    #[test]
3058    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
3059        let workspace = tempfile::tempdir().unwrap();
3060        let outside = tempfile::tempdir().unwrap();
3061        let policy = CapabilityPolicy {
3062            sandbox_profile: SandboxProfile::Worktree,
3063            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3064            ..CapabilityPolicy::default()
3065        };
3066        let config = ProcessCommandConfig {
3067            cwd: Some(outside.path().to_path_buf()),
3068            ..ProcessCommandConfig::default()
3069        };
3070
3071        assert!(sandboxed_process_config(&config, &policy).is_err());
3072    }
3073
3074    #[test]
3075    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
3076        let cwd = std::env::current_dir().unwrap();
3077        let policy = CapabilityPolicy {
3078            sandbox_profile: SandboxProfile::Worktree,
3079            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3080            ..CapabilityPolicy::default()
3081        };
3082
3083        // A sandboxed spawn must bypass sccache so it can never spawn (and
3084        // thereby permanently confine) the shared daemon.
3085        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3086        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3087        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3088        assert_eq!(
3089            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
3090            Some("")
3091        );
3092    }
3093
3094    #[test]
3095    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
3096        // Even if a caller (or inherited env) asked for sccache, the sandboxed
3097        // config forces it off rather than appending a duplicate entry.
3098        let mut env = vec![
3099            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
3100            ("PATH".to_string(), "/usr/bin".to_string()),
3101        ];
3102        neutralize_rustc_wrapper(&mut env);
3103        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3104        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3105        assert_eq!(
3106            collected
3107                .get("CARGO_BUILD_RUSTC_WRAPPER")
3108                .map(String::as_str),
3109            Some("")
3110        );
3111        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
3112        // No duplicate RUSTC_WRAPPER entries.
3113        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
3114    }
3115
3116    #[test]
3117    fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
3118        let workspace = tempfile::tempdir().unwrap();
3119        let policy = CapabilityPolicy {
3120            sandbox_profile: SandboxProfile::Worktree,
3121            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3122            ..CapabilityPolicy::default()
3123        };
3124
3125        let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
3126
3127        // The temp dir is created, lives under the writable workspace root, and
3128        // is named by the documented convention.
3129        assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
3130        assert!(
3131            path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
3132            "temp dir {tmpdir:?} must be inside the writable workspace root"
3133        );
3134        assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
3135        // It self-ignores so its churn never shows in a git diff.
3136        let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
3137        assert!(
3138            ignore.lines().any(|line| line.trim() == "*"),
3139            "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
3140        );
3141        // It is within the sandbox's writable scope: a write under it passes the
3142        // same path-scope check the OS sandbox enforces.
3143        push_execution_policy(policy);
3144        assert!(
3145            check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
3146            "writes under the workspace-local temp dir must be in sandbox scope"
3147        );
3148        pop_execution_policy();
3149    }
3150
3151    #[test]
3152    fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
3153        // The unrestricted profile short-circuits the injection helper: an
3154        // unsandboxed child keeps whatever TMPDIR it would otherwise inherit.
3155        let policy = CapabilityPolicy {
3156            sandbox_profile: SandboxProfile::Unrestricted,
3157            workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
3158            ..CapabilityPolicy::default()
3159        };
3160        let mut env = Vec::new();
3161        inject_workspace_tmpdir(&mut env, &policy);
3162        assert!(
3163            env.is_empty(),
3164            "unrestricted profile must not inject a TMPDIR override, got {env:?}"
3165        );
3166    }
3167
3168    #[test]
3169    fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
3170        let workspace = tempfile::tempdir().unwrap();
3171        let policy = CapabilityPolicy {
3172            sandbox_profile: SandboxProfile::Worktree,
3173            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3174            ..CapabilityPolicy::default()
3175        };
3176        let mut env = Vec::new();
3177        inject_workspace_tmpdir(&mut env, &policy);
3178
3179        let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
3180        let expected = workspace_local_tmpdir(&policy)
3181            .unwrap()
3182            .display()
3183            .to_string();
3184        for key in TMPDIR_ENV_KEYS {
3185            assert_eq!(
3186                collected.get(key).map(String::as_str),
3187                Some(expected.as_str()),
3188                "{key} must point at the workspace-local temp dir"
3189            );
3190        }
3191    }
3192
3193    #[test]
3194    fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
3195        let env: std::collections::BTreeMap<_, _> =
3196            deterministic_message_locale_env().into_iter().collect();
3197        // gettext tools (gcc/clang, git-l10n, coreutils, gradle) honor
3198        // LC_MESSAGES; `C` yields untranslated English.
3199        assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
3200        // .NET ignores LC_* and localizes from its own variable.
3201        assert_eq!(
3202            env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
3203            Some("en")
3204        );
3205        // Deliberately NOT setting LC_ALL/LC_CTYPE/LANG so UTF-8 handling of
3206        // non-ASCII source and identifiers is preserved (unlike `LC_ALL=C`).
3207        assert!(
3208            !env.contains_key("LC_ALL"),
3209            "must not force LC_ALL (would clobber UTF-8 ctype)"
3210        );
3211        assert!(!env.contains_key("LC_CTYPE"));
3212        assert!(!env.contains_key("LANG"));
3213        // The override-strip constant names the one variable that would defeat
3214        // LC_MESSAGES if inherited.
3215        assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
3216    }
3217
3218    #[test]
3219    fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
3220        let workspace = tempfile::tempdir().unwrap();
3221        let policy = CapabilityPolicy {
3222            sandbox_profile: SandboxProfile::Worktree,
3223            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3224            ..CapabilityPolicy::default()
3225        };
3226        // Caller already pinned TMPDIR; only the untouched siblings get filled.
3227        let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
3228        inject_workspace_tmpdir(&mut env, &policy);
3229
3230        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3231        assert_eq!(
3232            collected.get("TMPDIR").map(String::as_str),
3233            Some("/caller/explicit/tmp"),
3234            "an explicit caller TMPDIR must be preserved untouched"
3235        );
3236        let expected = workspace_local_tmpdir(&policy)
3237            .unwrap()
3238            .display()
3239            .to_string();
3240        assert_eq!(
3241            collected.get("TMP").map(String::as_str),
3242            Some(expected.as_str())
3243        );
3244        assert_eq!(
3245            collected.get("TEMP").map(String::as_str),
3246            Some(expected.as_str())
3247        );
3248        // And no duplicate TMPDIR entry was appended.
3249        assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
3250    }
3251
3252    #[test]
3253    fn sandboxed_process_config_injects_workspace_tmpdir() {
3254        let workspace = tempfile::tempdir().unwrap();
3255        let policy = CapabilityPolicy {
3256            sandbox_profile: SandboxProfile::Worktree,
3257            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3258            ..CapabilityPolicy::default()
3259        };
3260        let config = ProcessCommandConfig {
3261            cwd: Some(workspace.path().to_path_buf()),
3262            ..ProcessCommandConfig::default()
3263        };
3264        let resolved = sandboxed_process_config(&config, &policy).unwrap();
3265        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3266        let expected = workspace_local_tmpdir(&policy)
3267            .unwrap()
3268            .display()
3269            .to_string();
3270        assert_eq!(
3271            env.get("TMPDIR").map(String::as_str),
3272            Some(expected.as_str()),
3273            "the command_output path must inject a workspace-local TMPDIR"
3274        );
3275    }
3276
3277    #[test]
3278    fn read_only_root_outside_workspace_allows_read_denies_write() {
3279        // Models an embedder (burin's in-process TUI) that grants a
3280        // read-only root R holding bundled pipelines/partials outside the
3281        // user's writable workspace. A read under R passes; a write under R
3282        // is denied; a read outside both R and the workspace is denied.
3283        let workspace = tempfile::tempdir().unwrap();
3284        let read_only = tempfile::tempdir().unwrap();
3285        push_execution_policy(CapabilityPolicy {
3286            sandbox_profile: SandboxProfile::Worktree,
3287            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3288            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
3289            ..CapabilityPolicy::default()
3290        });
3291
3292        let asset = read_only
3293            .path()
3294            .join("partials/agent-web-tools.harn.prompt");
3295        // READ under the read-only root is allowed.
3296        assert!(
3297            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
3298            "read under a configured read-only root must be allowed"
3299        );
3300
3301        // WRITE under the read-only root is denied, flagged read_only.
3302        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
3303            .expect_err("write under a read-only root must be denied");
3304        assert!(write_err.read_only, "write rejection must set read_only");
3305
3306        // DELETE under the read-only root is likewise denied.
3307        assert!(
3308            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
3309            "delete under a read-only root must be denied"
3310        );
3311
3312        // A read inside the writable workspace still passes.
3313        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
3314
3315        // A read outside BOTH the workspace and the read-only root is denied
3316        // and is NOT flagged read_only (it fell outside every root).
3317        let stranger = tempfile::tempdir().unwrap();
3318        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
3319            .expect_err("read outside all roots must be denied");
3320        assert!(
3321            !outside_err.read_only,
3322            "out-of-scope rejection must not be flagged read_only"
3323        );
3324
3325        pop_execution_policy();
3326    }
3327
3328    #[cfg(unix)]
3329    #[test]
3330    fn standard_io_device_files_allowed_under_restricted_profile() {
3331        // Writing to the standard process I/O streams is not a workspace
3332        // mutation, so a restricted profile with a workspace root that does
3333        // not contain /dev must still allow them — while a genuine
3334        // out-of-root write is still rejected.
3335        let workspace = tempfile::tempdir().unwrap();
3336        push_execution_policy(CapabilityPolicy {
3337            sandbox_profile: SandboxProfile::Worktree,
3338            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3339            ..CapabilityPolicy::default()
3340        });
3341
3342        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
3343            assert!(
3344                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
3345                "write to standard device {device} must be allowed"
3346            );
3347            // Reads of the same devices are likewise allowed.
3348            assert!(
3349                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
3350                "read of standard device {device} must be allowed"
3351            );
3352        }
3353        assert!(
3354            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
3355            "read of standard device /dev/stdin must be allowed"
3356        );
3357        assert!(
3358            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
3359            "write to /dev/stdin is not a standard output stream"
3360        );
3361        assert!(
3362            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
3363            "standard devices must not bypass delete scoping"
3364        );
3365        // Numeric /dev/fd/<N> descriptors are allowed.
3366        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
3367        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
3368
3369        // A non-device path outside the workspace is still rejected.
3370        let stranger = tempfile::tempdir().unwrap();
3371        assert!(
3372            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
3373            "a real out-of-root write must still be rejected"
3374        );
3375        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
3376        assert!(
3377            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
3378            "/dev/sda must not be allowed by the standard-device allowlist"
3379        );
3380        assert!(
3381            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
3382            "non-numeric /dev/fd/<x> must not be allowed"
3383        );
3384
3385        pop_execution_policy();
3386    }
3387
3388    #[test]
3389    fn is_standard_io_device_matches_only_known_streams() {
3390        assert!(is_standard_io_device_for_access(
3391            Path::new("/dev/stdin"),
3392            FsAccess::Read
3393        ));
3394        assert!(!is_standard_io_device_for_access(
3395            Path::new("/dev/stdin"),
3396            FsAccess::Write
3397        ));
3398        assert!(is_standard_io_device_for_access(
3399            Path::new("/dev/stdout"),
3400            FsAccess::Write
3401        ));
3402        assert!(is_standard_io_device_for_access(
3403            Path::new("/dev/stderr"),
3404            FsAccess::Write
3405        ));
3406        assert!(is_standard_io_device_for_access(
3407            Path::new("/dev/null"),
3408            FsAccess::Write
3409        ));
3410        assert!(is_standard_io_device_for_access(
3411            Path::new("/dev/fd/0"),
3412            FsAccess::Read
3413        ));
3414        assert!(is_standard_io_device_for_access(
3415            Path::new("/dev/fd/12"),
3416            FsAccess::Write
3417        ));
3418        assert!(!is_standard_io_device_for_access(
3419            Path::new("/dev/null"),
3420            FsAccess::Delete
3421        ));
3422        assert!(!is_standard_io_device_for_access(
3423            Path::new("/dev/fd/"),
3424            FsAccess::Write
3425        ));
3426        assert!(!is_standard_io_device_for_access(
3427            Path::new("/dev/fd/1a"),
3428            FsAccess::Write
3429        ));
3430        assert!(!is_standard_io_device_for_access(
3431            Path::new("/dev/stdoutx"),
3432            FsAccess::Write
3433        ));
3434        assert!(!is_standard_io_device_for_access(
3435            Path::new("/dev/random"),
3436            FsAccess::Read
3437        ));
3438        assert!(!is_standard_io_device_for_access(
3439            Path::new("/tmp/dev/null"),
3440            FsAccess::Write
3441        ));
3442    }
3443
3444    #[test]
3445    fn path_within_root_accepts_root_and_children() {
3446        let root = Path::new("/tmp/harn-root");
3447        assert!(path_is_within(root, root));
3448        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
3449        assert!(!path_is_within(
3450            Path::new("/tmp/harn-root-other/file"),
3451            root
3452        ));
3453    }
3454
3455    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
3456    #[test]
3457    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
3458        let temp_home = tempfile::tempdir().expect("temp home");
3459        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
3460        let normalized_home = normalize_for_policy(temp_home.path());
3461
3462        for suffix in [
3463            Path::new(".cargo"),
3464            Path::new(".rustup"),
3465            Path::new(".pyenv"),
3466            Path::new(".nvm"),
3467            Path::new(".volta"),
3468            Path::new(".local/share/uv"),
3469            Path::new("go"),
3470        ] {
3471            assert!(
3472                roots.iter().any(|path| path.ends_with(suffix)),
3473                "expected a developer-toolchain grant for {}",
3474                suffix.display()
3475            );
3476        }
3477        assert!(
3478            roots.iter().all(|path| path.starts_with(&normalized_home)),
3479            "developer-toolchain roots must stay under HOME"
3480        );
3481    }
3482
3483    #[cfg(any(target_os = "linux", target_os = "macos"))]
3484    #[test]
3485    fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
3486        let temp_home = tempfile::tempdir().expect("temp home");
3487        let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
3488        let normalized_home = normalize_for_policy(temp_home.path());
3489
3490        for suffix in [
3491            Path::new(".gradle"),
3492            Path::new(".m2"),
3493            Path::new(".konan"),
3494            Path::new("Library/Caches/CocoaPods"),
3495            Path::new("Library/Developer/Xcode/DerivedData"),
3496        ] {
3497            assert!(
3498                roots.iter().any(|path| path.ends_with(suffix)),
3499                "expected a JVM/iOS toolchain cache grant for {}",
3500                suffix.display()
3501            );
3502        }
3503        assert!(
3504            roots.iter().all(|path| path.starts_with(&normalized_home)),
3505            "toolchain cache roots must stay under HOME"
3506        );
3507    }
3508
3509    #[cfg(any(target_os = "linux", target_os = "macos"))]
3510    #[test]
3511    fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
3512        let mut policy = CapabilityPolicy {
3513            workspace_roots: vec!["/tmp/harn-workspace".to_string()],
3514            ..CapabilityPolicy::default()
3515        };
3516        // Default presets include DeveloperToolchains -> cache roots present
3517        // (only when an absolute HOME is resolvable on this host).
3518        if sandbox_user_home_dir().is_some() {
3519            assert!(
3520                !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3521                "default presets should render JVM/iOS cache roots"
3522            );
3523        }
3524        // Explicitly dropping DeveloperToolchains removes them.
3525        policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
3526        assert!(
3527            process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3528            "cache roots must be gated on the DeveloperToolchains preset"
3529        );
3530    }
3531
3532    #[test]
3533    fn os_hardened_profile_overrides_fallback_env() {
3534        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
3535        // point of the profile is that the OS sandbox is required.
3536        // We cannot mutate the env here without races, so just check
3537        // the pure resolution function.
3538        assert_eq!(
3539            effective_fallback(SandboxProfile::OsHardened),
3540            SandboxFallback::Enforce
3541        );
3542    }
3543
3544    #[test]
3545    fn unrestricted_profile_skips_active_sandbox() {
3546        let policy = CapabilityPolicy {
3547            sandbox_profile: SandboxProfile::Unrestricted,
3548            workspace_roots: vec!["/tmp".to_string()],
3549            ..Default::default()
3550        };
3551        crate::orchestration::push_execution_policy(policy);
3552        let result = active_sandbox_policy();
3553        crate::orchestration::pop_execution_policy();
3554        assert!(
3555            result.is_none(),
3556            "Unrestricted profile must short-circuit sandbox dispatch"
3557        );
3558    }
3559
3560    #[test]
3561    fn worktree_profile_engages_active_sandbox() {
3562        let policy = CapabilityPolicy {
3563            sandbox_profile: SandboxProfile::Worktree,
3564            workspace_roots: vec!["/tmp".to_string()],
3565            ..Default::default()
3566        };
3567        crate::orchestration::push_execution_policy(policy);
3568        let result = active_sandbox_policy();
3569        crate::orchestration::pop_execution_policy();
3570        assert!(
3571            result.is_some(),
3572            "Worktree profile must keep sandbox dispatch active"
3573        );
3574    }
3575}