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 project root a run is bound to even when the OS process cwd differs.
1866/// Prefer the typed execution context and keep `HARN_PROJECT_ROOT` as the
1867/// legacy standalone fallback. This mirrors the `workspace.project_root` host
1868/// fallback so the write jail and reported project root agree.
1869fn project_root_workspace_root() -> Option<PathBuf> {
1870    crate::stdlib::process::project_root_path().or_else(|| {
1871        std::env::var("HARN_PROJECT_ROOT")
1872            .ok()
1873            .map(|value| value.trim().to_string())
1874            .filter(|value| !value.is_empty())
1875            .map(PathBuf::from)
1876    })
1877}
1878
1879fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1880    if policy.workspace_roots.is_empty() {
1881        // An empty `policy.workspace_roots` means no explicit write-jail was
1882        // configured for this call. Historically this fell straight back to the
1883        // process execution root, but under the eval pattern (process cwd !=
1884        // `--project`) and dispatch fan-out children, the process cwd is the
1885        // repo, not the project the run is bound to — so a write that correctly
1886        // resolved INTO the project was rejected as outside the jail
1887        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
1888        // silently compensated. Prefer, in order: (1) the active agent
1889        // session's workspace anchor (primary + writable `Extend` mounts) when
1890        // the session is anchored; (2) the typed execution project root, with
1891        // legacy `HARN_PROJECT_ROOT` as a fallback, robust across session
1892        // nesting that an unanchored dispatch child sees; (3) the process
1893        // 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_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                project_root: None,
2315                source_dir: None,
2316                env: Default::default(),
2317                adapter: None,
2318                repo_path: None,
2319                worktree_path: None,
2320                branch: None,
2321                base_ref: None,
2322                cleanup: None,
2323            },
2324        ));
2325        push_execution_policy(CapabilityPolicy {
2326            sandbox_profile: SandboxProfile::Worktree,
2327            ..CapabilityPolicy::default()
2328        });
2329
2330        assert!(
2331            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
2332        );
2333        let outside = tempfile::tempdir().unwrap();
2334        assert!(enforce_fs_path(
2335            "read_file",
2336            &outside.path().join("outside.txt"),
2337            FsAccess::Read
2338        )
2339        .is_err());
2340
2341        pop_execution_policy();
2342        crate::stdlib::process::set_thread_execution_context(None);
2343    }
2344
2345    /// Regression for burin-labs/burin-code#4266. When a restricted policy has
2346    /// no explicit `workspace_roots`, the write jail must follow the typed
2347    /// execution `project_root` before env/cwd fallbacks.
2348    #[test]
2349    fn empty_workspace_roots_prefer_execution_project_root_over_env_and_execution_root() {
2350        let _env_lock = crate::runtime_paths::test_env_lock()
2351            .lock()
2352            .unwrap_or_else(|poisoned| poisoned.into_inner());
2353        let project = tempfile::tempdir().unwrap();
2354        let env_project = tempfile::tempdir().unwrap();
2355        let execution_cwd = tempfile::tempdir().unwrap();
2356        std::env::set_var("HARN_PROJECT_ROOT", env_project.path());
2357        crate::stdlib::process::set_thread_execution_context(Some(
2358            crate::orchestration::RunExecutionRecord {
2359                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2360                project_root: Some(project.path().to_string_lossy().into_owned()),
2361                source_dir: None,
2362                env: Default::default(),
2363                adapter: None,
2364                repo_path: None,
2365                worktree_path: None,
2366                branch: None,
2367                base_ref: None,
2368                cleanup: None,
2369            },
2370        ));
2371        push_execution_policy(CapabilityPolicy {
2372            sandbox_profile: SandboxProfile::Worktree,
2373            ..CapabilityPolicy::default()
2374        });
2375
2376        assert!(
2377            enforce_fs_path(
2378                "write_file",
2379                &project.path().join("test/created.ts"),
2380                FsAccess::Write,
2381            )
2382            .is_ok(),
2383            "write into typed project_root must be allowed"
2384        );
2385        assert!(
2386            enforce_fs_path(
2387                "write_file",
2388                &env_project.path().join("escape.ts"),
2389                FsAccess::Write,
2390            )
2391            .is_err(),
2392            "legacy HARN_PROJECT_ROOT must not widen an explicit execution project_root"
2393        );
2394        assert!(
2395            enforce_fs_path(
2396                "write_file",
2397                &execution_cwd.path().join("escape.ts"),
2398                FsAccess::Write,
2399            )
2400            .is_err(),
2401            "execution cwd outside the project must be rejected"
2402        );
2403
2404        pop_execution_policy();
2405        crate::stdlib::process::set_thread_execution_context(None);
2406        std::env::remove_var("HARN_PROJECT_ROOT");
2407    }
2408
2409    /// Regression for burin-labs/burin-code#3288. When a restricted policy has
2410    /// no explicit `workspace_roots`, the write jail must follow the
2411    /// host-declared `HARN_PROJECT_ROOT` project — NOT the process/execution
2412    /// cwd. This is the eval/dispatch pattern: `burin-headless` runs from the
2413    /// repo (`execution cwd = repo`) with `--project <fixture>` + matching
2414    /// `HARN_PROJECT_ROOT`, and a dispatched sub-agent worker's writes resolve
2415    /// into the fixture. Before the fix the empty-roots fallback used the
2416    /// execution cwd (the repo), so the in-project write was rejected
2417    /// (HARN-CAP-201) and the dispatched child wrote nothing.
2418    #[test]
2419    fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
2420        let _env_lock = crate::runtime_paths::test_env_lock()
2421            .lock()
2422            .unwrap_or_else(|poisoned| poisoned.into_inner());
2423        let project = tempfile::tempdir().unwrap();
2424        let execution_cwd = tempfile::tempdir().unwrap();
2425        std::env::set_var("HARN_PROJECT_ROOT", project.path());
2426        crate::stdlib::process::set_thread_execution_context(Some(
2427            crate::orchestration::RunExecutionRecord {
2428                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
2429                project_root: None,
2430                source_dir: None,
2431                env: Default::default(),
2432                adapter: None,
2433                repo_path: None,
2434                worktree_path: None,
2435                branch: None,
2436                base_ref: None,
2437                cleanup: None,
2438            },
2439        ));
2440        push_execution_policy(CapabilityPolicy {
2441            sandbox_profile: SandboxProfile::Worktree,
2442            ..CapabilityPolicy::default()
2443        });
2444
2445        // A write that resolves INTO the project is allowed even though the
2446        // process/execution cwd is elsewhere.
2447        assert!(
2448            enforce_fs_path(
2449                "write_file",
2450                &project.path().join("test/created.ts"),
2451                FsAccess::Write,
2452            )
2453            .is_ok(),
2454            "write into HARN_PROJECT_ROOT must be allowed"
2455        );
2456        // A write under the execution cwd (the repo, in the eval pattern) is NOT
2457        // the project and must still be rejected — the jail moved to the
2458        // project, it did not widen to both.
2459        assert!(
2460            enforce_fs_path(
2461                "write_file",
2462                &execution_cwd.path().join("escape.ts"),
2463                FsAccess::Write,
2464            )
2465            .is_err(),
2466            "write under the execution cwd (outside the project) must be rejected"
2467        );
2468
2469        pop_execution_policy();
2470        crate::stdlib::process::set_thread_execution_context(None);
2471        std::env::remove_var("HARN_PROJECT_ROOT");
2472    }
2473
2474    #[test]
2475    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
2476        let dir = tempfile::tempdir().unwrap();
2477        crate::stdlib::process::set_thread_execution_context(Some(
2478            crate::orchestration::RunExecutionRecord {
2479                cwd: Some(dir.path().to_string_lossy().into_owned()),
2480                project_root: None,
2481                source_dir: None,
2482                env: Default::default(),
2483                adapter: None,
2484                repo_path: None,
2485                worktree_path: None,
2486                branch: None,
2487                base_ref: None,
2488                cleanup: None,
2489            },
2490        ));
2491        push_execution_policy(CapabilityPolicy {
2492            sandbox_profile: SandboxProfile::Worktree,
2493            ..CapabilityPolicy::default()
2494        });
2495
2496        assert!(enforce_process_cwd(dir.path()).is_ok());
2497        let outside = tempfile::tempdir().unwrap();
2498        assert!(enforce_process_cwd(outside.path()).is_err());
2499
2500        pop_execution_policy();
2501        crate::stdlib::process::set_thread_execution_context(None);
2502    }
2503
2504    #[test]
2505    fn scoped_process_sandbox_roots_concretize_empty_policy_for_command_cwd() {
2506        let _env_lock = crate::runtime_paths::test_env_lock()
2507            .lock()
2508            .unwrap_or_else(|poisoned| poisoned.into_inner());
2509        std::env::remove_var("HARN_PROJECT_ROOT");
2510        let execution_root = tempfile::tempdir().unwrap();
2511        let command_root = tempfile::tempdir().unwrap();
2512        crate::stdlib::process::set_thread_execution_context(Some(
2513            crate::orchestration::RunExecutionRecord {
2514                cwd: Some(execution_root.path().to_string_lossy().into_owned()),
2515                project_root: None,
2516                source_dir: None,
2517                env: Default::default(),
2518                adapter: None,
2519                repo_path: None,
2520                worktree_path: None,
2521                branch: None,
2522                base_ref: None,
2523                cleanup: None,
2524            },
2525        ));
2526        push_execution_policy(CapabilityPolicy {
2527            sandbox_profile: SandboxProfile::Worktree,
2528            ..CapabilityPolicy::default()
2529        });
2530
2531        assert!(
2532            enforce_process_cwd(command_root.path()).is_err(),
2533            "before the scoped overlay the command temp root is outside the execution-root fallback",
2534        );
2535        {
2536            let _guard = push_process_sandbox_scope(ProcessSandboxScope {
2537                workspace_roots: vec![command_root.path().to_string_lossy().into_owned()],
2538            })
2539            .unwrap();
2540            assert!(
2541                enforce_process_cwd(command_root.path()).is_ok(),
2542                "scoped command root must be usable as the process cwd"
2543            );
2544            assert!(
2545                enforce_process_cwd(execution_root.path()).is_err(),
2546                "the scoped root must narrow the concrete spawn jail instead of widening it"
2547            );
2548        }
2549        assert!(
2550            enforce_process_cwd(command_root.path()).is_err(),
2551            "the scoped command root must pop after the command spawn"
2552        );
2553
2554        pop_execution_policy();
2555        crate::stdlib::process::set_thread_execution_context(None);
2556    }
2557
2558    #[test]
2559    fn scoped_process_sandbox_roots_cannot_widen_explicit_workspace_roots() {
2560        let workspace = tempfile::tempdir().unwrap();
2561        let inside = workspace.path().join("subdir");
2562        std::fs::create_dir(&inside).unwrap();
2563        let outside = tempfile::tempdir().unwrap();
2564        push_execution_policy(CapabilityPolicy {
2565            sandbox_profile: SandboxProfile::Worktree,
2566            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2567            ..CapabilityPolicy::default()
2568        });
2569
2570        assert!(
2571            push_process_sandbox_scope(ProcessSandboxScope {
2572                workspace_roots: vec![inside.to_string_lossy().into_owned()],
2573            })
2574            .is_ok(),
2575            "a command subroot inside the explicit workspace ceiling is allowed"
2576        );
2577        assert!(
2578            push_process_sandbox_scope(ProcessSandboxScope {
2579                workspace_roots: vec![outside.path().to_string_lossy().into_owned()],
2580            })
2581            .is_err(),
2582            "a command root outside the explicit workspace ceiling must be rejected"
2583        );
2584
2585        pop_execution_policy();
2586    }
2587
2588    #[cfg(unix)]
2589    #[test]
2590    fn scoped_atomic_write_rejects_parent_swapped_to_symlink_after_policy_match() {
2591        let workspace = tempfile::tempdir().unwrap();
2592        let outside = tempfile::tempdir().unwrap();
2593        let safe_parent = workspace.path().join("safe");
2594        std::fs::create_dir(&safe_parent).unwrap();
2595        let path = safe_parent.join("state.json");
2596        push_execution_policy(CapabilityPolicy {
2597            sandbox_profile: SandboxProfile::Worktree,
2598            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2599            ..CapabilityPolicy::default()
2600        });
2601        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2602            .unwrap()
2603            .expect("restricted policy yields scoped target");
2604
2605        std::fs::remove_dir(&safe_parent).unwrap();
2606        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
2607        let error = atomic_write_scoped_target(&target, b"escape").unwrap_err();
2608        pop_execution_policy();
2609
2610        assert!(
2611            !outside.path().join("state.json").exists(),
2612            "scoped write must not follow swapped parent symlink; error={error}"
2613        );
2614    }
2615
2616    #[cfg(unix)]
2617    #[test]
2618    fn scoped_write_creates_missing_parent_dirs() {
2619        // Regression guard for #4147: the scoped-write hardening dropped the
2620        // `mkdir -p` contract that `write_file` / `write_text` / `http_download`
2621        // relied on, surfacing downstream as "No such file or directory" when a
2622        // tool wrote into a not-yet-created state dir. A write to a path whose
2623        // ancestors are missing must recreate them (scoped) and succeed.
2624        let workspace = tempfile::tempdir().unwrap();
2625        let path = workspace.path().join("a/b/c/plan.json");
2626        push_execution_policy(CapabilityPolicy {
2627            sandbox_profile: SandboxProfile::Worktree,
2628            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2629            ..CapabilityPolicy::default()
2630        });
2631        let target = scoped_mutation_target("write_file", &path, FsAccess::Write)
2632            .unwrap()
2633            .expect("restricted policy yields scoped target");
2634        let result = atomic_write_scoped_target(&target, b"{\"plan\":\"Redis-backed\"}");
2635        pop_execution_policy();
2636
2637        assert!(
2638            result.is_ok(),
2639            "write must create missing parents: {result:?}"
2640        );
2641        assert_eq!(
2642            std::fs::read(&path).unwrap(),
2643            b"{\"plan\":\"Redis-backed\"}".to_vec()
2644        );
2645    }
2646
2647    #[cfg(unix)]
2648    #[test]
2649    fn scoped_parent_autocreate_refuses_preexisting_symlink_component() {
2650        let workspace = tempfile::tempdir().unwrap();
2651        let outside = tempfile::tempdir().unwrap();
2652        std::fs::create_dir(workspace.path().join("a")).unwrap();
2653        std::os::unix::fs::symlink(outside.path(), workspace.path().join("a/b")).unwrap();
2654        let target = ScopedMutationTarget {
2655            root: workspace.path().to_path_buf(),
2656            relative: PathBuf::from("a/b/c/plan.json"),
2657        };
2658
2659        let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2660
2661        assert!(
2662            !outside.path().join("c/plan.json").exists(),
2663            "parent creation must not follow a symlinked component; error={error}"
2664        );
2665        assert!(
2666            !workspace.path().join("a/b/c").exists(),
2667            "symlinked components must not be treated as satisfied parents"
2668        );
2669    }
2670
2671    #[test]
2672    fn scoped_read_check_does_not_create_missing_parent_dirs() {
2673        let workspace = tempfile::tempdir().unwrap();
2674        let path = workspace.path().join("a/b/c/plan.json");
2675        push_execution_policy(CapabilityPolicy {
2676            sandbox_profile: SandboxProfile::Worktree,
2677            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2678            ..CapabilityPolicy::default()
2679        });
2680        let result = enforce_fs_path("read_file", &path, FsAccess::Read);
2681        pop_execution_policy();
2682
2683        assert!(
2684            result.is_ok(),
2685            "read path inside workspace should be in scope"
2686        );
2687        assert!(
2688            !workspace.path().join("a").exists(),
2689            "read/list/stat/delete scope checks must not create ancestors"
2690        );
2691    }
2692
2693    #[cfg(unix)]
2694    #[test]
2695    fn scoped_paths_refuse_excessive_component_depth() {
2696        let mut relative = PathBuf::new();
2697        for index in 0..=MAX_SCOPED_PATH_COMPONENTS {
2698            relative.push(format!("d{index}"));
2699        }
2700
2701        let error = clean_relative_components(&relative).unwrap_err();
2702
2703        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2704        assert!(
2705            error
2706                .to_string()
2707                .contains("sandbox scoped path exceeds 256 components"),
2708            "unexpected error: {error}"
2709        );
2710    }
2711
2712    // ----------------------------------------------------------------------
2713    // (a) Windows junctions: a junction IS a directory (non-admin creatable),
2714    // so it bypasses every leaf-only symlink check. The scoped walk must refuse
2715    // it as an intermediate component. Junctions (mount points) need no admin
2716    // rights, so `mklink /J` works in CI; NTFS symlinks would need elevation.
2717    // ----------------------------------------------------------------------
2718    #[cfg(windows)]
2719    #[test]
2720    fn scoped_walk_refuses_junction_intermediate_component() {
2721        let workspace = tempfile::tempdir().unwrap();
2722        let outside = tempfile::tempdir().unwrap();
2723        std::fs::create_dir(workspace.path().join("a")).unwrap();
2724        let link = workspace.path().join("a").join("b");
2725        let status = std::process::Command::new("cmd")
2726            .arg("/C")
2727            .arg("mklink")
2728            .arg("/J")
2729            .arg(&link)
2730            .arg(outside.path())
2731            .status()
2732            .expect("spawn mklink /J");
2733        assert!(status.success(), "mklink /J failed to plant a junction");
2734
2735        let target = ScopedMutationTarget {
2736            root: workspace.path().to_path_buf(),
2737            relative: PathBuf::from("a/b/c/plan.json"),
2738        };
2739        let error = win_scoped_parent(&target, true).unwrap_err();
2740
2741        assert_eq!(
2742            error.kind(),
2743            io::ErrorKind::PermissionDenied,
2744            "junction intermediate must be refused, got {error}"
2745        );
2746        assert!(
2747            !outside.path().join("c").exists(),
2748            "walk must not create through a junction; error={error}"
2749        );
2750    }
2751
2752    // ----------------------------------------------------------------------
2753    // (c) Recurrence-guard lint. runc added a linter after this bug class
2754    // recurred; we scan this module's own source so a future edit cannot
2755    // silently reintroduce a path-based mutation inside the scoped walk, and so
2756    // every scoped leaf open keeps `O_NOFOLLOW`. Two invariants are guarded:
2757    //   1. The fd-walk helpers AND the content-open fns (write/append/copy/
2758    //      rename/mkdir) never round-trip through a path-based `std::fs`/`libc`
2759    //      call — they must stay on the *at primitives so the parent fd carried
2760    //      out of the walk (#4210's no-re-resolution contract) is the one the
2761    //      write uses. A path-re-resolving write in any of those fns trips this.
2762    //   2. Every scoped leaf open, and the directory-descent primitives, pass
2763    //      `O_NOFOLLOW`; the Windows walk rejects reparse-point components.
2764    //
2765    // A source scan (not a `clippy.toml` `disallowed-methods` entry) is used
2766    // deliberately: those raw APIs are legitimate elsewhere in this module (the
2767    // *unscoped* fallbacks used when no sandbox is active, and the non-unix
2768    // fallbacks) and across harn-vm, so a crate-wide clippy ban would be all
2769    // false positives; the risky call sites are exactly the functions named
2770    // below, which a targeted scan pins precisely.
2771    //
2772    // Coverage limits (deliberate): the scan is lexical, so it (a) only guards
2773    // the *first* (unix) definition of each dual-cfg fn — the unix fd-walk is
2774    // where the contract lives; the Windows fallbacks legitimately use `std::fs`
2775    // after their own reparse-point validation and are checked structurally via
2776    // the `win_walk_components` assertion below — and (b) matches call spellings,
2777    // not semantics, so a novel escape hatch (e.g. a freshly `use`-aliased fs fn
2778    // under a new name) would need its spelling added here.
2779    // ----------------------------------------------------------------------
2780    #[test]
2781    fn scoped_walk_forbids_raw_path_filesystem_calls() {
2782        let src = include_str!("mod.rs");
2783        // Anchor every scan to the production region (everything before the test
2784        // module) so the guard cannot pass by matching its own denylist literals
2785        // or the fixture code below.
2786        let production = &src[..src.find("mod tests {").expect("test module marker present")];
2787
2788        // Return the `{ ... }` body of the first function whose signature line
2789        // starts with `sig` (the unix definitions precede the fallbacks, so the
2790        // fd-walk / fd-carried versions are the ones scanned).
2791        fn fn_body<'a>(src: &'a str, sig: &str) -> &'a str {
2792            let start = src
2793                .find(sig)
2794                .unwrap_or_else(|| panic!("scoped-walk fn not found: {sig}"));
2795            let open = start
2796                + src[start..]
2797                    .find('{')
2798                    .unwrap_or_else(|| panic!("no body brace for {sig}"));
2799            let bytes = src.as_bytes();
2800            let mut depth = 0usize;
2801            for (offset, byte) in bytes[open..].iter().enumerate() {
2802                match byte {
2803                    b'{' => depth += 1,
2804                    b'}' => {
2805                        depth -= 1;
2806                        if depth == 0 {
2807                            return &src[open..=open + offset];
2808                        }
2809                    }
2810                    _ => {}
2811                }
2812            }
2813            panic!("unbalanced braces scanning {sig}");
2814        }
2815
2816        // Path-based mutations / resolver shortcuts that must never appear in a
2817        // scoped fn: each re-resolves a full path string (re-introducing the
2818        // TOCTOU the fd-walk closes) or shortcuts symlink resolution. Bare
2819        // `create_dir(`/`rename(` catch `use`-imported (unqualified) calls; the
2820        // `std::fs::`-qualified spellings catch the fully-pathed forms not
2821        // subsumed by a bare match. `file.write(`/`io::copy(` (fd-based) are NOT
2822        // forbidden — only the path-taking `std::fs::write(`/`std::fs::copy(`.
2823        const FORBIDDEN: [&str; 10] = [
2824            "create_dir_all(",
2825            "create_dir(",
2826            "File::create(",
2827            "OpenOptions",
2828            "canonicalize(",
2829            "rename(",
2830            "remove_dir_all(",
2831            "std::fs::write(",
2832            "std::fs::copy(",
2833            "libc::open(", // a full-path open; the walk uses openat/open_dir_at
2834        ];
2835        for sig in [
2836            // fd-walk helpers.
2837            "fn ensure_parent_dirs_scoped(",
2838            "fn open_parent_dir_scoped(",
2839            "fn create_dir_all_scoped_target(",
2840            "fn create_dir_scoped_target(",
2841            // content-open fns: this is where #4210's "carry the parent fd into
2842            // the write, never re-resolve the path" contract must hold.
2843            "fn atomic_write_scoped_target(",
2844            "fn append_scoped_target(",
2845            "fn copy_scoped_target(",
2846            "fn rename_scoped_targets(",
2847        ] {
2848            let body = fn_body(production, sig);
2849            for needle in FORBIDDEN {
2850                assert!(
2851                    !body.contains(needle),
2852                    "{sig} must not use raw `{needle}`; stay on the fd-carried *at path"
2853                );
2854            }
2855        }
2856
2857        // Every scoped *leaf* open must carry O_NOFOLLOW so it cannot follow a
2858        // swapped-in leaf symlink. Skip the `openat_file` wrapper definition
2859        // (its flags arrive as a parameter); only the call sites carry literals.
2860        for (idx, _) in production.match_indices("openat_file(") {
2861            if production[..idx].ends_with("fn ") {
2862                continue;
2863            }
2864            let window = &production[idx..(idx + 300).min(production.len())];
2865            assert!(
2866                window.contains("O_NOFOLLOW"),
2867                "openat_file call site near byte {idx} must pass O_NOFOLLOW"
2868            );
2869        }
2870        // The directory-descent primitives must open O_NOFOLLOW too.
2871        for sig in ["fn open_dir_at(", "fn open_dir_absolute("] {
2872            assert!(
2873                fn_body(production, sig).contains("O_NOFOLLOW"),
2874                "{sig} must open directories with O_NOFOLLOW"
2875            );
2876        }
2877
2878        // The Windows walk is the platform's O_NOFOLLOW substitute: it must
2879        // reject reparse-point (junction/symlink) components as it descends.
2880        assert!(
2881            fn_body(production, "fn win_walk_components(").contains("win_reject_reparse_point"),
2882            "the Windows scoped walk must reject reparse-point components"
2883        );
2884    }
2885
2886    #[cfg(unix)]
2887    #[test]
2888    fn scoped_append_creates_missing_parent_dirs() {
2889        let workspace = tempfile::tempdir().unwrap();
2890        let path = workspace.path().join("logs/deep/qa.jsonl");
2891        push_execution_policy(CapabilityPolicy {
2892            sandbox_profile: SandboxProfile::Worktree,
2893            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
2894            ..CapabilityPolicy::default()
2895        });
2896        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
2897            .unwrap()
2898            .expect("restricted policy yields scoped target");
2899        append_scoped_target(&target, b"line1\n").unwrap();
2900        append_scoped_target(&target, b"line2\n").unwrap();
2901        pop_execution_policy();
2902
2903        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2904    }
2905
2906    // ----------------------------------------------------------------------
2907    // (d) Two-thread race on the same auto-create (CVE-2024-45310 class). Both
2908    // threads ensure the same deep parent chain concurrently; the loser of each
2909    // `mkdirat` sees EEXIST and must tolerate it. All calls must succeed, the
2910    // chain must exist exactly once inside the root, and nothing may escape.
2911    // ----------------------------------------------------------------------
2912    #[cfg(unix)]
2913    #[test]
2914    fn scoped_parent_autocreate_tolerates_concurrent_creators() {
2915        let workspace = tempfile::tempdir().unwrap();
2916        let root = workspace.path().to_path_buf();
2917        let target = ScopedMutationTarget {
2918            root: root.clone(),
2919            relative: PathBuf::from("race/deep/nested/tree/plan.json"),
2920        };
2921
2922        let threads: Vec<_> = (0..4)
2923            .map(|_| {
2924                let target = target.clone();
2925                std::thread::spawn(move || {
2926                    for _ in 0..64 {
2927                        // Each call re-walks from the root; the EEXIST branch is
2928                        // the one under contention.
2929                        ensure_parent_dirs_scoped(&target)
2930                            .expect("concurrent ensure must tolerate EEXIST");
2931                    }
2932                })
2933            })
2934            .collect();
2935        for thread in threads {
2936            thread.join().expect("worker thread panicked");
2937        }
2938
2939        assert!(
2940            root.join("race/deep/nested/tree").is_dir(),
2941            "the raced parent chain must exist inside the root"
2942        );
2943        // A final ensure resolves cleanly and yields the leaf name.
2944        let (_parent, leaf) = ensure_parent_dirs_scoped(&target).unwrap();
2945        assert_eq!(leaf, "plan.json");
2946    }
2947
2948    // A *non-directory* planted as an intermediate component must abort the walk
2949    // (ENOTDIR from the O_DIRECTORY open), not be silently mkdir'd over — the
2950    // walk descends by opening each level as a directory.
2951    #[cfg(unix)]
2952    #[test]
2953    fn scoped_parent_autocreate_refuses_file_as_intermediate_component() {
2954        let workspace = tempfile::tempdir().unwrap();
2955        std::fs::create_dir(workspace.path().join("a")).unwrap();
2956        std::fs::write(workspace.path().join("a/b"), b"i am a file").unwrap();
2957        let target = ScopedMutationTarget {
2958            root: workspace.path().to_path_buf(),
2959            relative: PathBuf::from("a/b/c/plan.json"),
2960        };
2961
2962        let error = ensure_parent_dirs_scoped(&target).unwrap_err();
2963
2964        assert_eq!(
2965            error.raw_os_error(),
2966            Some(libc::ENOTDIR),
2967            "a regular-file intermediate must fail with ENOTDIR, got {error:?}"
2968        );
2969        assert!(
2970            !workspace.path().join("a/b/c").exists(),
2971            "a non-directory intermediate must not be traversed or created through"
2972        );
2973    }
2974
2975    #[test]
2976    fn unscoped_write_creates_missing_parent_dirs() {
2977        // With no active sandbox scope (`scoped_mutation_target` returns None),
2978        // writes flow through `atomic_write_unscoped`. That path must honor the
2979        // same `mkdir -p` contract, otherwise a trusted-context write (CLI
2980        // scripts, `harn run`, conformance) into a fresh directory fails.
2981        let dir = tempfile::tempdir().unwrap();
2982        let path = dir.path().join("x/y/z/plan.json");
2983        atomic_write_unscoped(&path, b"{\"plan\":\"Redis-backed\"}").unwrap();
2984        assert_eq!(
2985            std::fs::read(&path).unwrap(),
2986            b"{\"plan\":\"Redis-backed\"}".to_vec()
2987        );
2988    }
2989
2990    #[test]
2991    fn unscoped_append_creates_missing_parent_dirs() {
2992        let dir = tempfile::tempdir().unwrap();
2993        let path = dir.path().join("logs/deep/qa.jsonl");
2994        append_unscoped(&path, b"line1\n").unwrap();
2995        append_unscoped(&path, b"line2\n").unwrap();
2996        assert_eq!(std::fs::read(&path).unwrap(), b"line1\nline2\n".to_vec());
2997    }
2998
2999    #[cfg(unix)]
3000    #[test]
3001    fn scoped_write_parent_autocreate_refuses_symlinked_intermediate() {
3002        // Auto-creating parents must stay symlink-safe: the create walk opens
3003        // each level with O_NOFOLLOW, so a symlinked intermediate directory
3004        // cannot be used to escape the workspace root.
3005        let workspace = tempfile::tempdir().unwrap();
3006        let outside = tempfile::tempdir().unwrap();
3007        std::os::unix::fs::symlink(outside.path(), workspace.path().join("escape")).unwrap();
3008        let path = workspace.path().join("escape/sub/plan.json");
3009        push_execution_policy(CapabilityPolicy {
3010            sandbox_profile: SandboxProfile::Worktree,
3011            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3012            ..CapabilityPolicy::default()
3013        });
3014        // The escape is refused at whichever layer sees it first: path
3015        // resolution (`scoped_mutation_target` canonicalizes and rejects a
3016        // target outside `workspace_roots`) or, for a symlink swapped in after
3017        // resolution, the auto-create walk itself (`O_NOFOLLOW` on each level).
3018        let escaped = match scoped_mutation_target("write_file", &path, FsAccess::Write) {
3019            Ok(Some(target)) => atomic_write_scoped_target(&target, b"escape").is_ok(),
3020            Ok(None) | Err(_) => false,
3021        };
3022        pop_execution_policy();
3023
3024        assert!(
3025            !escaped,
3026            "must not write through a symlinked intermediate dir"
3027        );
3028        assert!(
3029            !outside.path().join("sub/plan.json").exists(),
3030            "scoped write escaped the workspace via a symlinked parent"
3031        );
3032    }
3033
3034    #[cfg(unix)]
3035    #[test]
3036    fn scoped_append_rejects_final_symlink_created_after_policy_match() {
3037        let workspace = tempfile::tempdir().unwrap();
3038        let outside = tempfile::tempdir().unwrap();
3039        let safe_parent = workspace.path().join("safe");
3040        std::fs::create_dir(&safe_parent).unwrap();
3041        let outside_file = outside.path().join("state.log");
3042        std::fs::write(&outside_file, b"outside").unwrap();
3043        let path = safe_parent.join("state.log");
3044        push_execution_policy(CapabilityPolicy {
3045            sandbox_profile: SandboxProfile::Worktree,
3046            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3047            ..CapabilityPolicy::default()
3048        });
3049        let target = scoped_mutation_target("append_file", &path, FsAccess::Write)
3050            .unwrap()
3051            .expect("restricted policy yields scoped target");
3052
3053        std::os::unix::fs::symlink(&outside_file, &path).unwrap();
3054        let error = append_scoped_target(&target, b"\nescape").unwrap_err();
3055        pop_execution_policy();
3056
3057        assert_eq!(std::fs::read(&outside_file).unwrap(), b"outside");
3058        assert!(
3059            error.raw_os_error() == Some(libc::ELOOP)
3060                || error.kind() == io::ErrorKind::PermissionDenied
3061                || error.kind() == io::ErrorKind::Other,
3062            "expected symlink refusal, got {error:?}"
3063        );
3064    }
3065
3066    #[cfg(unix)]
3067    #[test]
3068    fn scoped_create_dir_all_rejects_parent_swapped_to_symlink_after_policy_match() {
3069        let workspace = tempfile::tempdir().unwrap();
3070        let outside = tempfile::tempdir().unwrap();
3071        let safe_parent = workspace.path().join("safe");
3072        std::fs::create_dir(&safe_parent).unwrap();
3073        let path = safe_parent.join("nested/deeper");
3074        push_execution_policy(CapabilityPolicy {
3075            sandbox_profile: SandboxProfile::Worktree,
3076            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3077            ..CapabilityPolicy::default()
3078        });
3079        let target = scoped_mutation_target("mkdir", &path, FsAccess::Write)
3080            .unwrap()
3081            .expect("restricted policy yields scoped target");
3082
3083        std::fs::remove_dir(&safe_parent).unwrap();
3084        std::os::unix::fs::symlink(outside.path(), &safe_parent).unwrap();
3085        let error = create_dir_all_scoped_target(&target).unwrap_err();
3086        pop_execution_policy();
3087
3088        assert!(
3089            !outside.path().join("nested").exists(),
3090            "scoped mkdir must not follow swapped parent symlink; error={error}"
3091        );
3092    }
3093
3094    #[test]
3095    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
3096        let cwd = std::env::current_dir().unwrap();
3097        let policy = CapabilityPolicy {
3098            sandbox_profile: SandboxProfile::Worktree,
3099            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3100            ..CapabilityPolicy::default()
3101        };
3102
3103        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3104
3105        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
3106    }
3107
3108    #[test]
3109    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
3110        let workspace = tempfile::tempdir().unwrap();
3111        let policy = CapabilityPolicy {
3112            sandbox_profile: SandboxProfile::Worktree,
3113            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3114            ..CapabilityPolicy::default()
3115        };
3116
3117        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3118
3119        assert_eq!(
3120            resolved.cwd.unwrap(),
3121            normalize_for_policy(workspace.path())
3122        );
3123    }
3124
3125    #[test]
3126    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
3127        let workspace = tempfile::tempdir().unwrap();
3128        let outside = tempfile::tempdir().unwrap();
3129        let policy = CapabilityPolicy {
3130            sandbox_profile: SandboxProfile::Worktree,
3131            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3132            ..CapabilityPolicy::default()
3133        };
3134        let config = ProcessCommandConfig {
3135            cwd: Some(outside.path().to_path_buf()),
3136            ..ProcessCommandConfig::default()
3137        };
3138
3139        assert!(sandboxed_process_config(&config, &policy).is_err());
3140    }
3141
3142    #[test]
3143    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
3144        let cwd = std::env::current_dir().unwrap();
3145        let policy = CapabilityPolicy {
3146            sandbox_profile: SandboxProfile::Worktree,
3147            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
3148            ..CapabilityPolicy::default()
3149        };
3150
3151        // A sandboxed spawn must bypass sccache so it can never spawn (and
3152        // thereby permanently confine) the shared daemon.
3153        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
3154        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3155        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3156        assert_eq!(
3157            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
3158            Some("")
3159        );
3160    }
3161
3162    #[test]
3163    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
3164        // Even if a caller (or inherited env) asked for sccache, the sandboxed
3165        // config forces it off rather than appending a duplicate entry.
3166        let mut env = vec![
3167            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
3168            ("PATH".to_string(), "/usr/bin".to_string()),
3169        ];
3170        neutralize_rustc_wrapper(&mut env);
3171        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3172        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
3173        assert_eq!(
3174            collected
3175                .get("CARGO_BUILD_RUSTC_WRAPPER")
3176                .map(String::as_str),
3177            Some("")
3178        );
3179        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
3180        // No duplicate RUSTC_WRAPPER entries.
3181        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
3182    }
3183
3184    #[test]
3185    fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
3186        let workspace = tempfile::tempdir().unwrap();
3187        let policy = CapabilityPolicy {
3188            sandbox_profile: SandboxProfile::Worktree,
3189            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3190            ..CapabilityPolicy::default()
3191        };
3192
3193        let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
3194
3195        // The temp dir is created, lives under the writable workspace root, and
3196        // is named by the documented convention.
3197        assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
3198        assert!(
3199            path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
3200            "temp dir {tmpdir:?} must be inside the writable workspace root"
3201        );
3202        assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
3203        // It self-ignores so its churn never shows in a git diff.
3204        let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
3205        assert!(
3206            ignore.lines().any(|line| line.trim() == "*"),
3207            "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
3208        );
3209        // It is within the sandbox's writable scope: a write under it passes the
3210        // same path-scope check the OS sandbox enforces.
3211        push_execution_policy(policy);
3212        assert!(
3213            check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
3214            "writes under the workspace-local temp dir must be in sandbox scope"
3215        );
3216        pop_execution_policy();
3217    }
3218
3219    #[test]
3220    fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
3221        // The unrestricted profile short-circuits the injection helper: an
3222        // unsandboxed child keeps whatever TMPDIR it would otherwise inherit.
3223        let policy = CapabilityPolicy {
3224            sandbox_profile: SandboxProfile::Unrestricted,
3225            workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
3226            ..CapabilityPolicy::default()
3227        };
3228        let mut env = Vec::new();
3229        inject_workspace_tmpdir(&mut env, &policy);
3230        assert!(
3231            env.is_empty(),
3232            "unrestricted profile must not inject a TMPDIR override, got {env:?}"
3233        );
3234    }
3235
3236    #[test]
3237    fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
3238        let workspace = tempfile::tempdir().unwrap();
3239        let policy = CapabilityPolicy {
3240            sandbox_profile: SandboxProfile::Worktree,
3241            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3242            ..CapabilityPolicy::default()
3243        };
3244        let mut env = Vec::new();
3245        inject_workspace_tmpdir(&mut env, &policy);
3246
3247        let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
3248        let expected = workspace_local_tmpdir(&policy)
3249            .unwrap()
3250            .display()
3251            .to_string();
3252        for key in TMPDIR_ENV_KEYS {
3253            assert_eq!(
3254                collected.get(key).map(String::as_str),
3255                Some(expected.as_str()),
3256                "{key} must point at the workspace-local temp dir"
3257            );
3258        }
3259    }
3260
3261    #[test]
3262    fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
3263        let env: std::collections::BTreeMap<_, _> =
3264            deterministic_message_locale_env().into_iter().collect();
3265        // gettext tools (gcc/clang, git-l10n, coreutils, gradle) honor
3266        // LC_MESSAGES; `C` yields untranslated English.
3267        assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
3268        // .NET ignores LC_* and localizes from its own variable.
3269        assert_eq!(
3270            env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
3271            Some("en")
3272        );
3273        // Deliberately NOT setting LC_ALL/LC_CTYPE/LANG so UTF-8 handling of
3274        // non-ASCII source and identifiers is preserved (unlike `LC_ALL=C`).
3275        assert!(
3276            !env.contains_key("LC_ALL"),
3277            "must not force LC_ALL (would clobber UTF-8 ctype)"
3278        );
3279        assert!(!env.contains_key("LC_CTYPE"));
3280        assert!(!env.contains_key("LANG"));
3281        // The override-strip constant names the one variable that would defeat
3282        // LC_MESSAGES if inherited.
3283        assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
3284    }
3285
3286    #[test]
3287    fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
3288        let workspace = tempfile::tempdir().unwrap();
3289        let policy = CapabilityPolicy {
3290            sandbox_profile: SandboxProfile::Worktree,
3291            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3292            ..CapabilityPolicy::default()
3293        };
3294        // Caller already pinned TMPDIR; only the untouched siblings get filled.
3295        let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
3296        inject_workspace_tmpdir(&mut env, &policy);
3297
3298        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
3299        assert_eq!(
3300            collected.get("TMPDIR").map(String::as_str),
3301            Some("/caller/explicit/tmp"),
3302            "an explicit caller TMPDIR must be preserved untouched"
3303        );
3304        let expected = workspace_local_tmpdir(&policy)
3305            .unwrap()
3306            .display()
3307            .to_string();
3308        assert_eq!(
3309            collected.get("TMP").map(String::as_str),
3310            Some(expected.as_str())
3311        );
3312        assert_eq!(
3313            collected.get("TEMP").map(String::as_str),
3314            Some(expected.as_str())
3315        );
3316        // And no duplicate TMPDIR entry was appended.
3317        assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
3318    }
3319
3320    #[test]
3321    fn sandboxed_process_config_injects_workspace_tmpdir() {
3322        let workspace = tempfile::tempdir().unwrap();
3323        let policy = CapabilityPolicy {
3324            sandbox_profile: SandboxProfile::Worktree,
3325            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3326            ..CapabilityPolicy::default()
3327        };
3328        let config = ProcessCommandConfig {
3329            cwd: Some(workspace.path().to_path_buf()),
3330            ..ProcessCommandConfig::default()
3331        };
3332        let resolved = sandboxed_process_config(&config, &policy).unwrap();
3333        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
3334        let expected = workspace_local_tmpdir(&policy)
3335            .unwrap()
3336            .display()
3337            .to_string();
3338        assert_eq!(
3339            env.get("TMPDIR").map(String::as_str),
3340            Some(expected.as_str()),
3341            "the command_output path must inject a workspace-local TMPDIR"
3342        );
3343    }
3344
3345    #[test]
3346    fn read_only_root_outside_workspace_allows_read_denies_write() {
3347        // Models an embedder (burin's in-process TUI) that grants a
3348        // read-only root R holding bundled pipelines/partials outside the
3349        // user's writable workspace. A read under R passes; a write under R
3350        // is denied; a read outside both R and the workspace is denied.
3351        let workspace = tempfile::tempdir().unwrap();
3352        let read_only = tempfile::tempdir().unwrap();
3353        push_execution_policy(CapabilityPolicy {
3354            sandbox_profile: SandboxProfile::Worktree,
3355            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3356            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
3357            ..CapabilityPolicy::default()
3358        });
3359
3360        let asset = read_only
3361            .path()
3362            .join("partials/agent-web-tools.harn.prompt");
3363        // READ under the read-only root is allowed.
3364        assert!(
3365            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
3366            "read under a configured read-only root must be allowed"
3367        );
3368
3369        // WRITE under the read-only root is denied, flagged read_only.
3370        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
3371            .expect_err("write under a read-only root must be denied");
3372        assert!(write_err.read_only, "write rejection must set read_only");
3373
3374        // DELETE under the read-only root is likewise denied.
3375        assert!(
3376            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
3377            "delete under a read-only root must be denied"
3378        );
3379
3380        // A read inside the writable workspace still passes.
3381        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
3382
3383        // A read outside BOTH the workspace and the read-only root is denied
3384        // and is NOT flagged read_only (it fell outside every root).
3385        let stranger = tempfile::tempdir().unwrap();
3386        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
3387            .expect_err("read outside all roots must be denied");
3388        assert!(
3389            !outside_err.read_only,
3390            "out-of-scope rejection must not be flagged read_only"
3391        );
3392
3393        pop_execution_policy();
3394    }
3395
3396    #[cfg(unix)]
3397    #[test]
3398    fn standard_io_device_files_allowed_under_restricted_profile() {
3399        // Writing to the standard process I/O streams is not a workspace
3400        // mutation, so a restricted profile with a workspace root that does
3401        // not contain /dev must still allow them — while a genuine
3402        // out-of-root write is still rejected.
3403        let workspace = tempfile::tempdir().unwrap();
3404        push_execution_policy(CapabilityPolicy {
3405            sandbox_profile: SandboxProfile::Worktree,
3406            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
3407            ..CapabilityPolicy::default()
3408        });
3409
3410        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
3411            assert!(
3412                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
3413                "write to standard device {device} must be allowed"
3414            );
3415            // Reads of the same devices are likewise allowed.
3416            assert!(
3417                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
3418                "read of standard device {device} must be allowed"
3419            );
3420        }
3421        assert!(
3422            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
3423            "read of standard device /dev/stdin must be allowed"
3424        );
3425        assert!(
3426            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
3427            "write to /dev/stdin is not a standard output stream"
3428        );
3429        assert!(
3430            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
3431            "standard devices must not bypass delete scoping"
3432        );
3433        // Numeric /dev/fd/<N> descriptors are allowed.
3434        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
3435        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
3436
3437        // A non-device path outside the workspace is still rejected.
3438        let stranger = tempfile::tempdir().unwrap();
3439        assert!(
3440            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
3441            "a real out-of-root write must still be rejected"
3442        );
3443        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
3444        assert!(
3445            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
3446            "/dev/sda must not be allowed by the standard-device allowlist"
3447        );
3448        assert!(
3449            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
3450            "non-numeric /dev/fd/<x> must not be allowed"
3451        );
3452
3453        pop_execution_policy();
3454    }
3455
3456    #[test]
3457    fn is_standard_io_device_matches_only_known_streams() {
3458        assert!(is_standard_io_device_for_access(
3459            Path::new("/dev/stdin"),
3460            FsAccess::Read
3461        ));
3462        assert!(!is_standard_io_device_for_access(
3463            Path::new("/dev/stdin"),
3464            FsAccess::Write
3465        ));
3466        assert!(is_standard_io_device_for_access(
3467            Path::new("/dev/stdout"),
3468            FsAccess::Write
3469        ));
3470        assert!(is_standard_io_device_for_access(
3471            Path::new("/dev/stderr"),
3472            FsAccess::Write
3473        ));
3474        assert!(is_standard_io_device_for_access(
3475            Path::new("/dev/null"),
3476            FsAccess::Write
3477        ));
3478        assert!(is_standard_io_device_for_access(
3479            Path::new("/dev/fd/0"),
3480            FsAccess::Read
3481        ));
3482        assert!(is_standard_io_device_for_access(
3483            Path::new("/dev/fd/12"),
3484            FsAccess::Write
3485        ));
3486        assert!(!is_standard_io_device_for_access(
3487            Path::new("/dev/null"),
3488            FsAccess::Delete
3489        ));
3490        assert!(!is_standard_io_device_for_access(
3491            Path::new("/dev/fd/"),
3492            FsAccess::Write
3493        ));
3494        assert!(!is_standard_io_device_for_access(
3495            Path::new("/dev/fd/1a"),
3496            FsAccess::Write
3497        ));
3498        assert!(!is_standard_io_device_for_access(
3499            Path::new("/dev/stdoutx"),
3500            FsAccess::Write
3501        ));
3502        assert!(!is_standard_io_device_for_access(
3503            Path::new("/dev/random"),
3504            FsAccess::Read
3505        ));
3506        assert!(!is_standard_io_device_for_access(
3507            Path::new("/tmp/dev/null"),
3508            FsAccess::Write
3509        ));
3510    }
3511
3512    #[test]
3513    fn path_within_root_accepts_root_and_children() {
3514        let root = Path::new("/tmp/harn-root");
3515        assert!(path_is_within(root, root));
3516        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
3517        assert!(!path_is_within(
3518            Path::new("/tmp/harn-root-other/file"),
3519            root
3520        ));
3521    }
3522
3523    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
3524    #[test]
3525    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
3526        let temp_home = tempfile::tempdir().expect("temp home");
3527        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
3528        let normalized_home = normalize_for_policy(temp_home.path());
3529
3530        for suffix in [
3531            Path::new(".cargo"),
3532            Path::new(".rustup"),
3533            Path::new(".pyenv"),
3534            Path::new(".nvm"),
3535            Path::new(".volta"),
3536            Path::new(".local/share/uv"),
3537            Path::new("go"),
3538        ] {
3539            assert!(
3540                roots.iter().any(|path| path.ends_with(suffix)),
3541                "expected a developer-toolchain grant for {}",
3542                suffix.display()
3543            );
3544        }
3545        assert!(
3546            roots.iter().all(|path| path.starts_with(&normalized_home)),
3547            "developer-toolchain roots must stay under HOME"
3548        );
3549    }
3550
3551    #[cfg(any(target_os = "linux", target_os = "macos"))]
3552    #[test]
3553    fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
3554        let temp_home = tempfile::tempdir().expect("temp home");
3555        let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
3556        let normalized_home = normalize_for_policy(temp_home.path());
3557
3558        for suffix in [
3559            Path::new(".gradle"),
3560            Path::new(".m2"),
3561            Path::new(".konan"),
3562            Path::new("Library/Caches/CocoaPods"),
3563            Path::new("Library/Developer/Xcode/DerivedData"),
3564        ] {
3565            assert!(
3566                roots.iter().any(|path| path.ends_with(suffix)),
3567                "expected a JVM/iOS toolchain cache grant for {}",
3568                suffix.display()
3569            );
3570        }
3571        assert!(
3572            roots.iter().all(|path| path.starts_with(&normalized_home)),
3573            "toolchain cache roots must stay under HOME"
3574        );
3575    }
3576
3577    #[cfg(any(target_os = "linux", target_os = "macos"))]
3578    #[test]
3579    fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
3580        let mut policy = CapabilityPolicy {
3581            workspace_roots: vec!["/tmp/harn-workspace".to_string()],
3582            ..CapabilityPolicy::default()
3583        };
3584        // Default presets include DeveloperToolchains -> cache roots present
3585        // (only when an absolute HOME is resolvable on this host).
3586        if sandbox_user_home_dir().is_some() {
3587            assert!(
3588                !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3589                "default presets should render JVM/iOS cache roots"
3590            );
3591        }
3592        // Explicitly dropping DeveloperToolchains removes them.
3593        policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
3594        assert!(
3595            process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
3596            "cache roots must be gated on the DeveloperToolchains preset"
3597        );
3598    }
3599
3600    #[test]
3601    fn os_hardened_profile_overrides_fallback_env() {
3602        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
3603        // point of the profile is that the OS sandbox is required.
3604        // We cannot mutate the env here without races, so just check
3605        // the pure resolution function.
3606        assert_eq!(
3607            effective_fallback(SandboxProfile::OsHardened),
3608            SandboxFallback::Enforce
3609        );
3610    }
3611
3612    #[test]
3613    fn unrestricted_profile_skips_active_sandbox() {
3614        let policy = CapabilityPolicy {
3615            sandbox_profile: SandboxProfile::Unrestricted,
3616            workspace_roots: vec!["/tmp".to_string()],
3617            ..Default::default()
3618        };
3619        crate::orchestration::push_execution_policy(policy);
3620        let result = active_sandbox_policy();
3621        crate::orchestration::pop_execution_policy();
3622        assert!(
3623            result.is_none(),
3624            "Unrestricted profile must short-circuit sandbox dispatch"
3625        );
3626    }
3627
3628    #[test]
3629    fn worktree_profile_engages_active_sandbox() {
3630        let policy = CapabilityPolicy {
3631            sandbox_profile: SandboxProfile::Worktree,
3632            workspace_roots: vec!["/tmp".to_string()],
3633            ..Default::default()
3634        };
3635        crate::orchestration::push_execution_policy(policy);
3636        let result = active_sandbox_policy();
3637        crate::orchestration::pop_execution_policy();
3638        assert!(
3639            result.is_some(),
3640            "Worktree profile must keep sandbox dispatch active"
3641        );
3642    }
3643}