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;
52mod locked_append;
53#[cfg(target_os = "macos")]
54mod macos;
55#[cfg(target_os = "openbsd")]
56mod openbsd;
57#[cfg(target_os = "windows")]
58mod windows;
59
60pub(crate) use locked_append::AppendLockOptions;
61
62const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
63#[cfg(any(unix, windows))]
64const MAX_SCOPED_PATH_COMPONENTS: usize = 256;
65
66thread_local! {
67    static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
68}
69
70/// The kind of filesystem access a path-scope check is guarding. This drives
71/// the verb rendered in rejection messages and the narrow standard-device
72/// exception; ordinary files are otherwise scoped by the same workspace roots.
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74pub enum FsAccess {
75    Read,
76    Write,
77    Delete,
78}
79
80#[derive(Clone, Debug, Default)]
81pub struct ProcessCommandConfig {
82    pub cwd: Option<PathBuf>,
83    pub env: Vec<(String, String)>,
84    pub stdin_null: bool,
85}
86
87#[derive(Clone, Debug, Default)]
88pub struct ProcessSandboxScope {
89    pub workspace_roots: Vec<String>,
90}
91
92#[must_use]
93pub struct ProcessSandboxScopeGuard {
94    pushed: bool,
95}
96
97impl Drop for ProcessSandboxScopeGuard {
98    fn drop(&mut self) {
99        if self.pushed {
100            crate::orchestration::pop_execution_policy();
101        }
102    }
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub(crate) enum SandboxFallback {
107    Off,
108    Warn,
109    Enforce,
110}
111
112/// Trait implemented once per supported host OS. Each backend knows
113/// how to attach the active capability ceiling to a `Command` /
114/// `tokio::process::Command`, or — on Windows where the standard
115/// process types cannot carry an AppContainer — how to drive an
116/// equivalent custom spawn that returns an `Output`.
117///
118/// One concrete implementation is selected at compile time via `cfg`
119/// gating in this module. Callers should not reach for the trait
120/// directly; the module-level `command_output` / `std_command_for` /
121/// `tokio_command_for` entry points dispatch through it.
122pub(crate) trait SandboxBackend {
123    /// Stable identifier used in diagnostics and conformance fixtures.
124    fn name() -> &'static str;
125
126    /// Whether the platform mechanism this backend uses is available
127    /// on the running host (e.g. Landlock kernel support, the
128    /// `/usr/bin/sandbox-exec` binary, AppContainer APIs).
129    fn available() -> bool;
130
131    /// Apply the per-spawn confinement to a [`std::process::Command`].
132    /// Returns `Ok(())` if the backend can attach inline (Linux
133    /// `pre_exec`, OpenBSD pledge/unveil), or
134    /// [`PrepareOutcome::WrappedExec`] when the spawn must be
135    /// re-routed through a wrapper binary (macOS `sandbox-exec`).
136    fn prepare_std_command(
137        program: &str,
138        args: &[String],
139        command: &mut Command,
140        policy: &CapabilityPolicy,
141        profile: SandboxProfile,
142    ) -> Result<PrepareOutcome, VmError>;
143
144    /// Same as [`prepare_std_command`], but for `tokio::process::Command`.
145    fn prepare_tokio_command(
146        program: &str,
147        args: &[String],
148        command: &mut tokio::process::Command,
149        policy: &CapabilityPolicy,
150        profile: SandboxProfile,
151    ) -> Result<PrepareOutcome, VmError>;
152
153    /// Direct spawn that returns the captured `Output`. Windows uses
154    /// this because AppContainer cannot be attached to a vanilla
155    /// `Command`; other platforms can fall back to the default
156    /// implementation that builds a `Command` and runs it.
157    fn run_to_output(
158        program: &str,
159        args: &[String],
160        config: &ProcessCommandConfig,
161        policy: &CapabilityPolicy,
162        profile: SandboxProfile,
163    ) -> Result<Output, VmError> {
164        let mut command = build_std_command::<Self>(program, args, policy, profile)?;
165        apply_process_config(&mut command, config);
166        crate::op_interrupt::capture_output_interruptible(&mut command)
167            .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
168    }
169}
170
171/// What [`SandboxBackend::prepare_std_command`] / `_tokio_command`
172/// produced: either the original spawn target with sandboxing applied
173/// inline, or a wrapper binary that should be invoked instead.
174pub(crate) enum PrepareOutcome {
175    /// Use the prepared command unchanged.
176    Direct,
177    /// Replace the spawn target with the wrapper binary and args
178    /// (e.g. `sandbox-exec -p '<profile>' -- <program> <args...>`).
179    /// Only macOS produces this today; on other platforms the variant
180    /// stays defined so the trait surface is portable, but the
181    /// build-time dead-code lint would otherwise flip.
182    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
183    WrappedExec { wrapper: String, args: Vec<String> },
184}
185
186#[cfg(target_os = "linux")]
187type ActiveBackend = linux::Backend;
188#[cfg(target_os = "macos")]
189type ActiveBackend = macos::Backend;
190#[cfg(target_os = "openbsd")]
191type ActiveBackend = openbsd::Backend;
192#[cfg(target_os = "windows")]
193type ActiveBackend = windows::Backend;
194#[cfg(not(any(
195    target_os = "linux",
196    target_os = "macos",
197    target_os = "openbsd",
198    target_os = "windows"
199)))]
200type ActiveBackend = NoopBackend;
201
202#[cfg(not(any(
203    target_os = "linux",
204    target_os = "macos",
205    target_os = "openbsd",
206    target_os = "windows"
207)))]
208pub(crate) struct NoopBackend;
209
210#[cfg(not(any(
211    target_os = "linux",
212    target_os = "macos",
213    target_os = "openbsd",
214    target_os = "windows"
215)))]
216impl SandboxBackend for NoopBackend {
217    fn name() -> &'static str {
218        "noop"
219    }
220    fn available() -> bool {
221        false
222    }
223    fn prepare_std_command(
224        _program: &str,
225        _args: &[String],
226        _command: &mut Command,
227        _policy: &CapabilityPolicy,
228        _profile: SandboxProfile,
229    ) -> Result<PrepareOutcome, VmError> {
230        Ok(PrepareOutcome::Direct)
231    }
232    fn prepare_tokio_command(
233        _program: &str,
234        _args: &[String],
235        _command: &mut tokio::process::Command,
236        _policy: &CapabilityPolicy,
237        _profile: SandboxProfile,
238    ) -> Result<PrepareOutcome, VmError> {
239        Ok(PrepareOutcome::Direct)
240    }
241}
242
243pub(crate) fn reset_sandbox_state() {
244    WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
245}
246
247/// Stable identifier for the platform sandbox backend selected at
248/// compile time. Surfaced for diagnostics and conformance fixtures so
249/// callers can record which backend produced a recorded run.
250pub fn active_backend_name() -> &'static str {
251    ActiveBackend::name()
252}
253
254/// Whether the platform mechanism backing the active sandbox backend
255/// is available on the running host. Used by conformance fixtures and
256/// the `harn doctor` flow to skip OS-hardened checks on hosts without
257/// the required kernel support.
258pub fn active_backend_available() -> bool {
259    ActiveBackend::available()
260}
261
262/// Register Harn-callable introspection builtins for the sandbox.
263/// Intended for diagnostics, `harn doctor`, and conformance fixtures —
264/// not as a way to mutate runtime sandbox behavior from a script.
265pub fn register_sandbox_builtins(vm: &mut Vm) {
266    for def in MODULE_BUILTINS {
267        vm.register_builtin_def(def);
268    }
269}
270
271pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
272    &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
273    &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
274    &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
275];
276
277#[crate::stdlib::macros::harn_builtin(
278    sig = "sandbox_active_backend() -> string",
279    category = "sandbox"
280)]
281fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
282    Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
283}
284
285#[crate::stdlib::macros::harn_builtin(
286    sig = "sandbox_backend_available() -> bool",
287    category = "sandbox"
288)]
289fn sandbox_backend_available_impl(
290    _args: &[VmValue],
291    _out: &mut String,
292) -> Result<VmValue, VmError> {
293    Ok(VmValue::Bool(active_backend_available()))
294}
295
296#[crate::stdlib::macros::harn_builtin(
297    sig = "sandbox_active_profile() -> string",
298    category = "sandbox"
299)]
300fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
301    let profile = crate::orchestration::current_execution_policy()
302        .map(|policy| policy.sandbox_profile)
303        .unwrap_or(SandboxProfile::Unrestricted);
304    Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
305}
306
307/// A workspace-root scope violation: a path that resolved outside every
308/// configured workspace root under a restricted [`SandboxProfile`].
309///
310/// This is the `VmError`-free shape returned by [`check_fs_path_scope`] so
311/// that crates outside `harn-vm` (today: `harn-hostlib`) can enforce the
312/// same scope policy and render the violation onto their own error type.
313#[derive(Clone, Debug)]
314pub struct SandboxViolation {
315    /// The path the call attempted to touch, normalized against the
316    /// active policy (CWD-relative paths resolved to absolute, `..`
317    /// collapsed, symlinks canonicalized where the path exists).
318    pub attempted: PathBuf,
319    /// The writable workspace roots the path was checked against,
320    /// normalized the same way as `attempted`.
321    pub roots: Vec<PathBuf>,
322    /// Whether the rejected access was a read, write, or delete.
323    pub access: FsAccess,
324    /// True when the path resolved *inside* a read-only root: it is in
325    /// scope for reads, and only the attempted mutation is denied. False
326    /// when the path fell outside every configured root entirely.
327    pub read_only: bool,
328}
329
330impl SandboxViolation {
331    /// Render the canonical rejection message. Matches the text produced
332    /// by [`enforce_fs_path`] so the `harness.fs.*` and hostlib surfaces
333    /// reject an out-of-root path identically.
334    pub fn message(&self, builtin: &str) -> String {
335        if self.read_only {
336            return format!(
337                "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
338                self.access.verb(),
339                self.attempted.display(),
340            );
341        }
342        format!(
343            "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
344            self.access.verb(),
345            self.attempted.display(),
346            self.roots
347                .iter()
348                .map(|root| root.display().to_string())
349                .collect::<Vec<_>>()
350                .join(", ")
351        )
352    }
353}
354
355/// Check whether `path` is inside the active policy's workspace roots.
356///
357/// Returns `Ok(())` when no execution policy is active, when the active
358/// profile is [`SandboxProfile::Unrestricted`], when the normalized path
359/// falls within a writable workspace root, or — for [`FsAccess::Read`]
360/// only — when it falls within a read-only root. A write/delete that
361/// resolves under a read-only root is rejected with `read_only` set, as
362/// is any access that falls outside every configured root.
363///
364/// This is the public, `VmError`-free entry point embedders use to apply
365/// workspace-root scoping to their own host calls. The in-crate
366/// `harness.fs.*` builtins funnel through [`enforce_fs_path`], which wraps
367/// this with a `VmError`; both share the same path normalization and
368/// rejection text.
369pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
370    let Some(policy) = crate::orchestration::current_execution_policy() else {
371        return Ok(());
372    };
373    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
374        return Ok(());
375    }
376    // Standard process I/O device files are not workspace filesystem
377    // mutations: writing to /dev/stdout, /dev/stderr, or /dev/null (and the
378    // numeric /dev/fd/<N> descriptors they alias) targets the process's own
379    // output streams, not the sandboxed tree. A pipeline that falls back to
380    // /dev/stdout for debug output must not read as a sandbox violation, so
381    // allow these regardless of the configured roots. Matched on the
382    // lexically-normalized path (not the canonicalized form): canonicalize()
383    // rewrites /dev/stdout to a per-process /dev/fd/<…>.output alias that no
384    // longer looks like a standard device. Kept deliberately narrow — only
385    // the well-known device files, no broader /dev access.
386    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
387        return Ok(());
388    }
389    let candidate = normalize_for_policy(path);
390    let roots = normalized_workspace_roots(&policy);
391    if roots.iter().any(|root| path_is_within(&candidate, root)) {
392        return Ok(());
393    }
394    let read_only_roots = normalized_read_only_roots(&policy);
395    let within_read_only = read_only_roots
396        .iter()
397        .any(|root| path_is_within(&candidate, root));
398    if within_read_only && access == FsAccess::Read {
399        return Ok(());
400    }
401    Err(SandboxViolation {
402        attempted: candidate,
403        roots,
404        access,
405        read_only: within_read_only,
406    })
407}
408
409pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
410    check_fs_path_scope(path, access)
411        .map_err(|violation| sandbox_rejection(violation.message(builtin)))
412}
413
414pub(crate) fn atomic_write_scoped_at_open(
415    builtin: &str,
416    path: &Path,
417    contents: &[u8],
418) -> io::Result<()> {
419    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
420        return atomic_write_unscoped(path, contents);
421    };
422    atomic_write_scoped_target(&target, contents)
423}
424
425pub(crate) fn append_scoped_at_open(builtin: &str, path: &Path, contents: &[u8]) -> io::Result<()> {
426    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
427        return append_unscoped(path, contents);
428    };
429    append_scoped_target(&target, contents)
430}
431
432pub(crate) fn append_locked_scoped_at_open(
433    builtin: &str,
434    path: &Path,
435    contents: &[u8],
436    options: AppendLockOptions,
437) -> io::Result<()> {
438    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
439        return locked_append::append_locked_unscoped(path, contents, options);
440    };
441    locked_append::append_locked_scoped_target(&target, contents, options)
442}
443
444pub(crate) fn copy_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<u64> {
445    let Some(target) = scoped_mutation_target(builtin, dst, FsAccess::Write)? else {
446        return std::fs::copy(src, dst);
447    };
448    copy_scoped_target(src, &target)
449}
450
451pub(crate) fn rename_scoped_at_open(builtin: &str, src: &Path, dst: &Path) -> io::Result<()> {
452    let Some(src_target) = scoped_mutation_target(builtin, src, FsAccess::Delete)? else {
453        return std::fs::rename(src, dst);
454    };
455    let dst_target = scoped_mutation_target(builtin, dst, FsAccess::Write)?.ok_or_else(|| {
456        io::Error::new(
457            io::ErrorKind::PermissionDenied,
458            format!(
459                "sandbox violation: builtin '{builtin}' attempted to rename '{}' without an active destination sandbox scope",
460                dst.display()
461            ),
462        )
463    })?;
464    rename_scoped_targets(&src_target, &dst_target)
465}
466
467pub(crate) fn create_dir_scoped_at_open(
468    builtin: &str,
469    path: &Path,
470    recursive: bool,
471) -> io::Result<()> {
472    let Some(target) = scoped_mutation_target(builtin, path, FsAccess::Write)? else {
473        return if recursive {
474            std::fs::create_dir_all(path)
475        } else {
476            std::fs::create_dir(path)
477        };
478    };
479    if recursive {
480        create_dir_all_scoped_target(&target)
481    } else {
482        create_dir_scoped_target(&target)
483    }
484}
485
486#[derive(Clone, Debug)]
487struct ScopedMutationTarget {
488    root: PathBuf,
489    relative: PathBuf,
490}
491
492fn scoped_mutation_target(
493    builtin: &str,
494    path: &Path,
495    access: FsAccess,
496) -> io::Result<Option<ScopedMutationTarget>> {
497    let Some(policy) = crate::orchestration::current_execution_policy() else {
498        return Ok(None);
499    };
500    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
501        return Ok(None);
502    }
503    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
504        return Ok(None);
505    }
506    check_fs_path_scope(path, access).map_err(|violation| {
507        io::Error::new(io::ErrorKind::PermissionDenied, violation.message(builtin))
508    })?;
509    let candidate = normalize_for_policy(path);
510    let roots = normalized_workspace_roots(&policy);
511    let Some(root) = roots
512        .into_iter()
513        .find(|root| path_is_within(&candidate, root))
514    else {
515        return Err(io::Error::new(
516            io::ErrorKind::PermissionDenied,
517            format!(
518                "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside writable workspace_roots",
519                access.verb(),
520                candidate.display()
521            ),
522        ));
523    };
524    let relative = candidate.strip_prefix(&root).map_err(|_| {
525        io::Error::new(
526            io::ErrorKind::PermissionDenied,
527            format!(
528                "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace root '{}'",
529                access.verb(),
530                candidate.display(),
531                root.display()
532            ),
533        )
534    })?;
535    if relative.as_os_str().is_empty() {
536        return Err(io::Error::new(
537            io::ErrorKind::InvalidInput,
538            format!(
539                "sandbox violation: builtin '{builtin}' attempted to {} workspace root '{}'",
540                access.verb(),
541                root.display()
542            ),
543        ));
544    }
545    Ok(Some(ScopedMutationTarget {
546        root,
547        relative: relative.to_path_buf(),
548    }))
549}
550
551fn atomic_write_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
552    let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
553    let dir = parent.unwrap_or_else(|| Path::new("."));
554    // Restore the pre-hardening `mkdir -p` contract for content-producing
555    // writes: an unrestricted (no active sandbox scope) write into a
556    // not-yet-created directory recreates its ancestor chain, matching the
557    // scoped path's `ensure_parent_dirs_scoped`.
558    if let Some(parent) = parent {
559        std::fs::create_dir_all(parent)?;
560    }
561    let tmp_path = dir.join(scoped_tmp_name(path));
562    let write_result = (|| -> io::Result<()> {
563        let mut file = std::fs::File::create(&tmp_path)?;
564        file.write_all(contents)?;
565        file.flush()?;
566        file.sync_all()?;
567        Ok(())
568    })();
569    if let Err(err) = write_result {
570        let _ = std::fs::remove_file(&tmp_path);
571        return Err(err);
572    }
573    if let Err(err) = std::fs::rename(&tmp_path, path) {
574        let _ = std::fs::remove_file(&tmp_path);
575        return Err(err);
576    }
577    Ok(())
578}
579
580fn append_unscoped(path: &Path, contents: &[u8]) -> io::Result<()> {
581    // Match the `append_file` contract: appending to a new log in a
582    // not-yet-created directory recreates the parent chain.
583    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
584        std::fs::create_dir_all(parent)?;
585    }
586    std::fs::OpenOptions::new()
587        .create(true)
588        .append(true)
589        .open(path)
590        .and_then(|mut file| file.write_all(contents))
591}
592
593fn scoped_tmp_name(path: &Path) -> String {
594    use std::sync::atomic::{AtomicU64, Ordering};
595    static COUNTER: AtomicU64 = AtomicU64::new(0);
596    let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
597    let file_name = path
598        .file_name()
599        .map(|name| name.to_string_lossy().into_owned())
600        .unwrap_or_else(|| "file".to_string());
601    format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id())
602}
603
604#[cfg(unix)]
605fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
606    use std::os::fd::AsRawFd;
607
608    // Content-producing writes recreate their parent chain (`mkdir -p`),
609    // restoring the pre-hardening `write_file`/`http_download` contract that
610    // downstream `.harn` relies on. The creation stays inside the scope root
611    // and reuses the same symlink-safe parent-fd walk as the write itself.
612    let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
613    let tmp_name = scoped_tmp_name(Path::new(&file_name));
614    let mut file = openat_file(
615        parent.as_raw_fd(),
616        &tmp_name,
617        libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
618        0o666,
619    )?;
620    let write_result = (|| -> io::Result<()> {
621        file.write_all(contents)?;
622        file.flush()?;
623        file.sync_all()?;
624        Ok(())
625    })();
626    if let Err(err) = write_result {
627        let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
628        return Err(err);
629    }
630    if let Err(err) = renameat_name(
631        parent.as_raw_fd(),
632        &tmp_name,
633        parent.as_raw_fd(),
634        &file_name,
635    ) {
636        let _ = unlinkat_name(parent.as_raw_fd(), &tmp_name, 0);
637        return Err(err);
638    }
639    sync_dir_fd(parent.as_raw_fd());
640    Ok(())
641}
642
643#[cfg(windows)]
644fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
645    let (parent, file_name) = win_scoped_parent(target, true)?;
646    let full = parent.join(&file_name);
647    win_reject_reparse_leaf(&full)?;
648    atomic_write_unscoped(&full, contents)
649}
650
651#[cfg(all(not(unix), not(windows)))]
652fn atomic_write_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
653    let full = target.root.join(&target.relative);
654    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
655        std::fs::create_dir_all(parent)?;
656    }
657    atomic_write_unscoped(&full, contents)
658}
659
660#[cfg(unix)]
661fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
662    use std::os::fd::AsRawFd;
663
664    // Append creates the file (and its parent chain) when absent, matching the
665    // pre-hardening `append_file` contract (append-to-a-new-log-in-a-new-dir).
666    let (parent, file_name) = ensure_parent_dirs_scoped(target)?;
667    let mut file = openat_file(
668        parent.as_raw_fd(),
669        &file_name,
670        libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND | libc::O_CLOEXEC | libc::O_NOFOLLOW,
671        0o666,
672    )?;
673    file.write_all(contents)
674}
675
676#[cfg(windows)]
677fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
678    let (parent, file_name) = win_scoped_parent(target, true)?;
679    let full = parent.join(&file_name);
680    win_reject_reparse_leaf(&full)?;
681    append_unscoped(&full, contents)
682}
683
684#[cfg(all(not(unix), not(windows)))]
685fn append_scoped_target(target: &ScopedMutationTarget, contents: &[u8]) -> io::Result<()> {
686    let full = target.root.join(&target.relative);
687    if let Some(parent) = full.parent().filter(|p| !p.as_os_str().is_empty()) {
688        std::fs::create_dir_all(parent)?;
689    }
690    append_unscoped(&full, contents)
691}
692
693#[cfg(unix)]
694fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
695    use std::os::fd::AsRawFd;
696
697    let mut source = std::fs::File::open(src)?;
698    let source_metadata = source.metadata().ok();
699    let (parent, file_name) = open_parent_dir_scoped(target)?;
700    let mut destination = openat_file(
701        parent.as_raw_fd(),
702        &file_name,
703        libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_CLOEXEC | libc::O_NOFOLLOW,
704        0o666,
705    )?;
706    let copied = io::copy(&mut source, &mut destination)?;
707    destination.sync_all()?;
708    if let Some(metadata) = source_metadata {
709        let _ = destination.set_permissions(metadata.permissions());
710    }
711    sync_dir_fd(parent.as_raw_fd());
712    Ok(copied)
713}
714
715#[cfg(windows)]
716fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
717    // Copy destinations keep the "parent must already exist" contract, so the
718    // walk does not auto-create (create_parents = false), matching the unix
719    // `open_parent_dir_scoped` path.
720    let (parent, file_name) = win_scoped_parent(target, false)?;
721    let full = parent.join(&file_name);
722    win_reject_reparse_leaf(&full)?;
723    std::fs::copy(src, full)
724}
725
726#[cfg(all(not(unix), not(windows)))]
727fn copy_scoped_target(src: &Path, target: &ScopedMutationTarget) -> io::Result<u64> {
728    std::fs::copy(src, target.root.join(&target.relative))
729}
730
731#[cfg(unix)]
732fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
733    use std::os::fd::AsRawFd;
734
735    let (src_parent, src_name) = open_parent_dir_scoped(src)?;
736    let (dst_parent, dst_name) = open_parent_dir_scoped(dst)?;
737    renameat_name(
738        src_parent.as_raw_fd(),
739        &src_name,
740        dst_parent.as_raw_fd(),
741        &dst_name,
742    )?;
743    sync_dir_fd(dst_parent.as_raw_fd());
744    Ok(())
745}
746
747#[cfg(windows)]
748fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
749    // No `win_reject_reparse_leaf` on the leaves here: rename operates on the
750    // directory entry (the name), not by traversing through the target, and may
751    // legitimately move/replace a reparse point. The junction-traversal defense
752    // is the ancestor-chain validation in `win_scoped_parent`.
753    let (src_parent, src_name) = win_scoped_parent(src, false)?;
754    let (dst_parent, dst_name) = win_scoped_parent(dst, false)?;
755    std::fs::rename(src_parent.join(&src_name), dst_parent.join(&dst_name))
756}
757
758#[cfg(all(not(unix), not(windows)))]
759fn rename_scoped_targets(src: &ScopedMutationTarget, dst: &ScopedMutationTarget) -> io::Result<()> {
760    std::fs::rename(src.root.join(&src.relative), dst.root.join(&dst.relative))
761}
762
763#[cfg(unix)]
764fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
765    use std::os::fd::AsRawFd;
766
767    let (parent, file_name) = open_parent_dir_scoped(target)?;
768    mkdirat_name(parent.as_raw_fd(), &file_name)?;
769    sync_dir_fd(parent.as_raw_fd());
770    Ok(())
771}
772
773#[cfg(windows)]
774fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
775    // Single `mkdir` keeps the "parent must already exist" contract; only the
776    // leaf is created, after verifying no ancestor is a junction/symlink. No
777    // `win_reject_reparse_leaf` on the leaf: `CreateDirectoryW` creates a NEW
778    // name and fails `AlreadyExists` if anything (reparse point or not) already
779    // occupies it — it never writes *through* an existing leaf — so the
780    // ancestor-chain validation is the whole defense.
781    let (parent, file_name) = win_scoped_parent(target, false)?;
782    win_create_dir_raw(&parent.join(&file_name))
783}
784
785#[cfg(all(not(unix), not(windows)))]
786fn create_dir_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
787    std::fs::create_dir(target.root.join(&target.relative))
788}
789
790#[cfg(unix)]
791fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
792    use std::os::fd::AsRawFd;
793
794    let root = open_dir_absolute(&target.root)?;
795    let mut current = root;
796    for component in clean_relative_components(&target.relative)? {
797        match open_dir_at(current.as_raw_fd(), &component) {
798            Ok(next) => current = next,
799            Err(error) if error.kind() == io::ErrorKind::NotFound => {
800                mkdirat_name(current.as_raw_fd(), &component)?;
801                let next = open_dir_at(current.as_raw_fd(), &component)?;
802                current = next;
803            }
804            Err(error) => return Err(error),
805        }
806    }
807    Ok(())
808}
809
810#[cfg(windows)]
811fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
812    // `mkdir -p`: every component (including the leaf) is created, and each is
813    // verified not to be a reparse point (junction/symlink) as the walk descends.
814    let components = win_clean_relative_components(&target.relative)?;
815    win_walk_components(&target.root, &components, true)?;
816    Ok(())
817}
818
819#[cfg(all(not(unix), not(windows)))]
820fn create_dir_all_scoped_target(target: &ScopedMutationTarget) -> io::Result<()> {
821    std::fs::create_dir_all(target.root.join(&target.relative))
822}
823
824#[cfg(unix)]
825/// Create the ancestor directory chain of a scoped write/append target,
826/// mirroring the pre-hardening `mkdir -p` behavior of the content-producing
827/// filesystem builtins (`write_file`, `write_file_bytes`, `append_file`,
828/// `append_file_locked`) and `http_download`. Only the ancestors are created —
829/// the final path component
830/// is the file the caller writes. Traversal stays scoped to `target.root` and
831/// symlink-safe (each level is opened with `O_NOFOLLOW` via `open_dir_at`), so
832/// this preserves the security properties #4147 added while restoring the
833/// directory-autovivification contract downstream code depends on. Concurrent
834/// creators are tolerated (a losing `mkdirat` that sees `EEXIST` is ignored).
835///
836/// The returned parent fd is the one content-producing callers must use for
837/// their final `openat`/`renameat`, so the path is not resolved again between
838/// mkdir-p and the write.
839///
840/// Structural operations (copy destination, rename, remove, single `mkdir`)
841/// intentionally do NOT call this — they keep `open_parent_dir_scoped`'s
842/// "parent must already exist" semantics.
843#[cfg(unix)]
844fn ensure_parent_dirs_scoped(
845    target: &ScopedMutationTarget,
846) -> io::Result<(std::os::fd::OwnedFd, String)> {
847    use std::os::fd::AsRawFd;
848
849    let mut components = clean_relative_components(&target.relative)?;
850    let file_name = components.pop().ok_or_else(|| {
851        io::Error::new(
852            io::ErrorKind::InvalidInput,
853            format!(
854                "sandbox scoped open requires a file name: {}",
855                target.relative.display()
856            ),
857        )
858    })?;
859    let root = open_dir_absolute(&target.root)?;
860    let mut current = root;
861    for component in components {
862        match open_dir_at(current.as_raw_fd(), &component) {
863            Ok(next) => current = next,
864            Err(error) if error.kind() == io::ErrorKind::NotFound => {
865                if let Err(mkerr) = mkdirat_name(current.as_raw_fd(), &component) {
866                    if mkerr.kind() != io::ErrorKind::AlreadyExists {
867                        return Err(mkerr);
868                    }
869                }
870                current = open_dir_at(current.as_raw_fd(), &component)?;
871            }
872            Err(error) => return Err(error),
873        }
874    }
875    Ok((current, file_name))
876}
877
878#[cfg(unix)]
879fn open_parent_dir_scoped(
880    target: &ScopedMutationTarget,
881) -> io::Result<(std::os::fd::OwnedFd, String)> {
882    use std::os::fd::AsRawFd;
883
884    let mut components = clean_relative_components(&target.relative)?;
885    let file_name = components.pop().ok_or_else(|| {
886        io::Error::new(
887            io::ErrorKind::InvalidInput,
888            format!(
889                "sandbox scoped open requires a file name: {}",
890                target.relative.display()
891            ),
892        )
893    })?;
894    let root = open_dir_absolute(&target.root)?;
895    let mut current = root;
896    for component in components {
897        current = open_dir_at(current.as_raw_fd(), &component)?;
898    }
899    Ok((current, file_name))
900}
901
902#[cfg(unix)]
903fn clean_relative_components(path: &Path) -> io::Result<Vec<String>> {
904    use std::os::unix::ffi::OsStrExt;
905
906    let mut out = Vec::new();
907    for component in path.components() {
908        match component {
909            Component::Normal(value) => {
910                let bytes = value.as_bytes();
911                if bytes.contains(&0) {
912                    return Err(io::Error::new(
913                        io::ErrorKind::InvalidInput,
914                        format!("path component contains NUL: {}", path.display()),
915                    ));
916                }
917                out.push(value.to_string_lossy().into_owned());
918                if out.len() > MAX_SCOPED_PATH_COMPONENTS {
919                    return Err(io::Error::new(
920                        io::ErrorKind::InvalidInput,
921                        format!(
922                            "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
923                            path.display()
924                        ),
925                    ));
926                }
927            }
928            Component::CurDir => {}
929            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
930                return Err(io::Error::new(
931                    io::ErrorKind::InvalidInput,
932                    format!("sandbox scoped path must stay relative: {}", path.display()),
933                ));
934            }
935        }
936    }
937    Ok(out)
938}
939
940#[cfg(unix)]
941fn open_dir_absolute(path: &Path) -> io::Result<std::os::fd::OwnedFd> {
942    use std::os::fd::{FromRawFd, OwnedFd};
943    use std::os::unix::ffi::OsStrExt;
944
945    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).map_err(|_| {
946        io::Error::new(
947            io::ErrorKind::InvalidInput,
948            format!("path contains NUL: {}", path.display()),
949        )
950    })?;
951    let fd = unsafe {
952        libc::open(
953            c_path.as_ptr(),
954            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
955        )
956    };
957    if fd < 0 {
958        return Err(io::Error::last_os_error());
959    }
960    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
961}
962
963#[cfg(unix)]
964fn open_dir_at(parent_fd: libc::c_int, name: &str) -> io::Result<std::os::fd::OwnedFd> {
965    use std::os::fd::{FromRawFd, OwnedFd};
966
967    let c_name = c_name(name)?;
968    let fd = unsafe {
969        libc::openat(
970            parent_fd,
971            c_name.as_ptr(),
972            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
973        )
974    };
975    if fd < 0 {
976        return Err(io::Error::last_os_error());
977    }
978    Ok(unsafe { OwnedFd::from_raw_fd(fd) })
979}
980
981#[cfg(unix)]
982fn openat_file(
983    parent_fd: libc::c_int,
984    name: &str,
985    flags: libc::c_int,
986    mode: libc::mode_t,
987) -> io::Result<std::fs::File> {
988    use std::os::fd::FromRawFd;
989
990    let c_name = c_name(name)?;
991    let fd = unsafe { libc::openat(parent_fd, c_name.as_ptr(), flags, mode as libc::c_uint) };
992    if fd < 0 {
993        return Err(io::Error::last_os_error());
994    }
995    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
996}
997
998#[cfg(unix)]
999fn mkdirat_name(parent_fd: libc::c_int, name: &str) -> io::Result<()> {
1000    let c_name = c_name(name)?;
1001    let rc = unsafe { libc::mkdirat(parent_fd, c_name.as_ptr(), 0o777) };
1002    if rc != 0 {
1003        return Err(io::Error::last_os_error());
1004    }
1005    Ok(())
1006}
1007
1008#[cfg(unix)]
1009fn renameat_name(
1010    old_parent_fd: libc::c_int,
1011    old_name: &str,
1012    new_parent_fd: libc::c_int,
1013    new_name: &str,
1014) -> io::Result<()> {
1015    let old_name = c_name(old_name)?;
1016    let new_name = c_name(new_name)?;
1017    let rc = unsafe {
1018        libc::renameat(
1019            old_parent_fd,
1020            old_name.as_ptr(),
1021            new_parent_fd,
1022            new_name.as_ptr(),
1023        )
1024    };
1025    if rc != 0 {
1026        return Err(io::Error::last_os_error());
1027    }
1028    Ok(())
1029}
1030
1031#[cfg(unix)]
1032fn unlinkat_name(parent_fd: libc::c_int, name: &str, flags: libc::c_int) -> io::Result<()> {
1033    let c_name = c_name(name)?;
1034    let rc = unsafe { libc::unlinkat(parent_fd, c_name.as_ptr(), flags) };
1035    if rc != 0 {
1036        return Err(io::Error::last_os_error());
1037    }
1038    Ok(())
1039}
1040
1041#[cfg(unix)]
1042fn sync_dir_fd(fd: libc::c_int) {
1043    let _ = unsafe { libc::fsync(fd) };
1044}
1045
1046#[cfg(unix)]
1047fn c_name(name: &str) -> io::Result<std::ffi::CString> {
1048    std::ffi::CString::new(name).map_err(|_| {
1049        io::Error::new(
1050            io::ErrorKind::InvalidInput,
1051            format!("path component contains NUL: {name:?}"),
1052        )
1053    })
1054}
1055
1056// ---------------------------------------------------------------------------
1057// Windows scoped-walk primitives (junction/symlink-safe directory descent).
1058//
1059// Windows has no `openat`, and `O_NOFOLLOW` has no equivalent that a plain
1060// `std::fs` path open honors — worse, a *junction* (mount-point reparse point)
1061// IS a directory and is creatable by a non-admin user, so it slips past every
1062// "is this a symlink" check that only inspects the leaf. The unix path defends
1063// the whole chain by opening each component `O_NOFOLLOW`; the Windows path here
1064// mirrors that by opening each walked component with
1065// `FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS` (so the reparse
1066// point itself is opened, not its target) and refusing the walk the moment any
1067// component reports a mount-point or symlink reparse tag. See
1068// research/scoped-fs-mkdir-footguns (#12, RedirectionGuard) for the class.
1069//
1070// Residual, Windows-CI-only: because there is no handle-relative openat here,
1071// each component is re-resolved by string as the walk descends, so a
1072// concurrent attacker who swaps an *already-validated* ancestor for a junction
1073// between our check and the next open is not fully closed (the unix fd-walk is;
1074// the true fix is `NtCreateFile` with a `RootDirectory` handle). The
1075// intermediate-junction class the acceptance test covers IS closed.
1076// ---------------------------------------------------------------------------
1077
1078/// Reparse tags Windows assigns to the two "traverses out of the tree"
1079/// reparse-point kinds the scoped walk must refuse. Defined locally so the
1080/// module does not need the `Win32_System_SystemServices` feature just for two
1081/// stable ABI constants.
1082#[cfg(windows)]
1083const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003;
1084#[cfg(windows)]
1085const IO_REPARSE_TAG_SYMLINK: u32 = 0xA000_000C;
1086
1087#[cfg(windows)]
1088fn win_wide(path: &Path) -> Vec<u16> {
1089    use std::os::windows::ffi::OsStrExt;
1090    path.as_os_str()
1091        .encode_wide()
1092        .chain(std::iter::once(0))
1093        .collect()
1094}
1095
1096/// Refuse `path` if it is a mount-point or symlink reparse point. The handle is
1097/// opened with `FILE_FLAG_OPEN_REPARSE_POINT` so we inspect the reparse point
1098/// itself rather than following it, and `FILE_FLAG_BACKUP_SEMANTICS` so a
1099/// directory handle is permitted. A `NotFound` error is propagated unchanged so
1100/// callers can distinguish "does not exist yet" (create it) from "exists and is
1101/// hostile" (refuse).
1102#[cfg(windows)]
1103fn win_reject_reparse_point(path: &Path) -> io::Result<()> {
1104    use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1105    use windows_sys::Win32::Storage::FileSystem::{
1106        CreateFileW, FileAttributeTagInfo, GetFileInformationByHandleEx,
1107        FILE_ATTRIBUTE_REPARSE_POINT, FILE_ATTRIBUTE_TAG_INFO, FILE_FLAG_BACKUP_SEMANTICS,
1108        FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1109        OPEN_EXISTING,
1110    };
1111
1112    // Query attributes only; no read/write access to the object is needed.
1113    const FILE_READ_ATTRIBUTES: u32 = 0x0080;
1114
1115    let wide = win_wide(path);
1116    let handle = unsafe {
1117        CreateFileW(
1118            wide.as_ptr(),
1119            FILE_READ_ATTRIBUTES,
1120            FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
1121            std::ptr::null(),
1122            OPEN_EXISTING,
1123            FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
1124            std::ptr::null_mut(),
1125        )
1126    };
1127    if handle == INVALID_HANDLE_VALUE {
1128        return Err(io::Error::last_os_error());
1129    }
1130    let mut info = FILE_ATTRIBUTE_TAG_INFO::default();
1131    let ok = unsafe {
1132        GetFileInformationByHandleEx(
1133            handle,
1134            FileAttributeTagInfo,
1135            std::ptr::from_mut(&mut info).cast(),
1136            std::mem::size_of::<FILE_ATTRIBUTE_TAG_INFO>() as u32,
1137        )
1138    };
1139    let result = if ok == 0 {
1140        Err(io::Error::last_os_error())
1141    } else if info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1142        && matches!(
1143            info.ReparseTag,
1144            IO_REPARSE_TAG_MOUNT_POINT | IO_REPARSE_TAG_SYMLINK
1145        )
1146    {
1147        Err(io::Error::new(
1148            io::ErrorKind::PermissionDenied,
1149            format!(
1150                "sandbox scoped walk refuses reparse-point (junction/symlink) component: {}",
1151                path.display()
1152            ),
1153        ))
1154    } else {
1155        Ok(())
1156    };
1157    unsafe {
1158        CloseHandle(handle);
1159    }
1160    result
1161}
1162
1163/// A reparse point that squats on a *leaf* target name is refused; a leaf that
1164/// simply does not exist yet is fine (the caller is about to create it).
1165#[cfg(windows)]
1166fn win_reject_reparse_leaf(path: &Path) -> io::Result<()> {
1167    match win_reject_reparse_point(path) {
1168        Ok(()) => Ok(()),
1169        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1170        Err(err) => Err(err),
1171    }
1172}
1173
1174/// Low-level `CreateDirectoryW` used by the scoped walk. Kept raw (rather than
1175/// `std::fs::create_dir`) so the recurrence-guard lint can assert the scoped
1176/// Windows walk never reaches for a path-based `std::fs` mutation.
1177#[cfg(windows)]
1178fn win_create_dir_raw(path: &Path) -> io::Result<()> {
1179    use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW;
1180    let wide = win_wide(path);
1181    let ok = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) };
1182    if ok == 0 {
1183        return Err(io::Error::last_os_error());
1184    }
1185    Ok(())
1186}
1187
1188/// Windows analogue of [`clean_relative_components`]: reject `..`, absolute, and
1189/// drive-prefixed components, cap the depth, and refuse embedded NULs — keeping
1190/// the same invariants the unix walk enforces before descending.
1191#[cfg(windows)]
1192fn win_clean_relative_components(path: &Path) -> io::Result<Vec<std::ffi::OsString>> {
1193    use std::os::windows::ffi::OsStrExt;
1194
1195    let mut out: Vec<std::ffi::OsString> = Vec::new();
1196    for component in path.components() {
1197        match component {
1198            Component::Normal(value) => {
1199                if value.encode_wide().any(|unit| unit == 0) {
1200                    return Err(io::Error::new(
1201                        io::ErrorKind::InvalidInput,
1202                        format!("path component contains NUL: {}", path.display()),
1203                    ));
1204                }
1205                out.push(value.to_os_string());
1206                if out.len() > MAX_SCOPED_PATH_COMPONENTS {
1207                    return Err(io::Error::new(
1208                        io::ErrorKind::InvalidInput,
1209                        format!(
1210                            "sandbox scoped path exceeds {MAX_SCOPED_PATH_COMPONENTS} components: {}",
1211                            path.display()
1212                        ),
1213                    ));
1214                }
1215            }
1216            Component::CurDir => {}
1217            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1218                return Err(io::Error::new(
1219                    io::ErrorKind::InvalidInput,
1220                    format!("sandbox scoped path must stay relative: {}", path.display()),
1221                ));
1222            }
1223        }
1224    }
1225    Ok(out)
1226}
1227
1228/// Descend `root` through `components`, refusing any component that is a
1229/// junction/symlink reparse point. When `create` is set, missing directories
1230/// are created (`mkdir -p`) and re-validated immediately, so a directory we
1231/// just made cannot be a reparse point. Returns the validated deepest path.
1232#[cfg(windows)]
1233fn win_walk_components(
1234    root: &Path,
1235    components: &[std::ffi::OsString],
1236    create: bool,
1237) -> io::Result<PathBuf> {
1238    // The configured workspace root is trusted, but verify it resolves to a real
1239    // directory and is not itself a reparse point, mirroring the unix
1240    // `open_dir_absolute` `O_NOFOLLOW` open of the root.
1241    win_reject_reparse_point(root)?;
1242    let mut current = root.to_path_buf();
1243    for component in components {
1244        current.push(component);
1245        match win_reject_reparse_point(&current) {
1246            Ok(()) => {}
1247            Err(err) if create && err.kind() == io::ErrorKind::NotFound => {
1248                match win_create_dir_raw(&current) {
1249                    Ok(()) => {}
1250                    // A concurrent creator won the race; tolerate and re-validate.
1251                    Err(mkerr) if mkerr.kind() == io::ErrorKind::AlreadyExists => {}
1252                    Err(mkerr) => return Err(mkerr),
1253                }
1254                win_reject_reparse_point(&current)?;
1255            }
1256            Err(err) => return Err(err),
1257        }
1258    }
1259    Ok(current)
1260}
1261
1262/// Validate the ancestor chain of a scoped target on Windows and return the
1263/// verified `(parent_dir, leaf_name)`. With `create_parents`, missing ancestors
1264/// are created; without it, the parent must already exist (structural ops).
1265#[cfg(windows)]
1266fn win_scoped_parent(
1267    target: &ScopedMutationTarget,
1268    create_parents: bool,
1269) -> io::Result<(PathBuf, std::ffi::OsString)> {
1270    let mut components = win_clean_relative_components(&target.relative)?;
1271    let file_name = components.pop().ok_or_else(|| {
1272        io::Error::new(
1273            io::ErrorKind::InvalidInput,
1274            format!(
1275                "sandbox scoped open requires a file name: {}",
1276                target.relative.display()
1277            ),
1278        )
1279    })?;
1280    let parent = win_walk_components(&target.root, &components, create_parents)?;
1281    Ok((parent, file_name))
1282}
1283
1284pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
1285    let Some(policy) = crate::orchestration::current_execution_policy() else {
1286        return Ok(());
1287    };
1288    enforce_process_cwd_for_policy(path, &policy)
1289}
1290
1291pub fn push_process_sandbox_scope(
1292    scope: ProcessSandboxScope,
1293) -> Result<ProcessSandboxScopeGuard, VmError> {
1294    let Some(mut policy) = crate::orchestration::current_execution_policy() else {
1295        return Ok(ProcessSandboxScopeGuard { pushed: false });
1296    };
1297    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1298        return Ok(ProcessSandboxScopeGuard { pushed: false });
1299    }
1300
1301    let requested_roots: Vec<PathBuf> = scope
1302        .workspace_roots
1303        .iter()
1304        .filter_map(|root| {
1305            let trimmed = root.trim();
1306            (!trimmed.is_empty()).then(|| normalize_for_policy(&resolve_policy_path(trimmed)))
1307        })
1308        .collect();
1309    if requested_roots.is_empty() {
1310        return Ok(ProcessSandboxScopeGuard { pushed: false });
1311    }
1312
1313    if !policy.workspace_roots.is_empty() {
1314        let ceiling_roots = normalized_workspace_roots(&policy);
1315        if let Some(rejected) = requested_roots.iter().find(|root| {
1316            !ceiling_roots
1317                .iter()
1318                .any(|ceiling| path_is_within(root, ceiling))
1319        }) {
1320            return Err(sandbox_rejection(format!(
1321                "sandbox violation: process sandbox workspace root '{}' is outside workspace_roots [{}]",
1322                rejected.display(),
1323                ceiling_roots
1324                    .iter()
1325                    .map(|root| root.display().to_string())
1326                    .collect::<Vec<_>>()
1327                    .join(", ")
1328            )));
1329        }
1330    }
1331
1332    let mut merged_roots = if policy.workspace_roots.is_empty() {
1333        Vec::new()
1334    } else {
1335        normalized_workspace_roots(&policy)
1336    };
1337    for requested in requested_roots {
1338        if !merged_roots
1339            .iter()
1340            .any(|existing| path_is_within(&requested, existing))
1341        {
1342            merged_roots.push(requested);
1343        }
1344    }
1345    policy.workspace_roots = merged_roots
1346        .into_iter()
1347        .map(|root| root.display().to_string())
1348        .collect();
1349    crate::orchestration::push_execution_policy(policy);
1350    Ok(ProcessSandboxScopeGuard { pushed: true })
1351}
1352
1353fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
1354    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1355        return Ok(());
1356    }
1357    let candidate = normalize_for_policy(path);
1358    let roots = normalized_workspace_roots(policy);
1359    if roots.iter().any(|root| path_is_within(&candidate, root)) {
1360        return Ok(());
1361    }
1362    Err(sandbox_rejection(format!(
1363        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
1364        candidate.display(),
1365        roots
1366            .iter()
1367            .map(|root| root.display().to_string())
1368            .collect::<Vec<_>>()
1369            .join(", ")
1370    )))
1371}
1372
1373pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
1374    let (policy, profile) = match active_sandbox_policy() {
1375        Some(value) => value,
1376        None => {
1377            let mut command = Command::new(program);
1378            command.args(args);
1379            return Ok(command);
1380        }
1381    };
1382    build_std_command::<ActiveBackend>(program, args, &policy, profile)
1383}
1384
1385pub fn tokio_command_for(
1386    program: &str,
1387    args: &[String],
1388) -> Result<tokio::process::Command, VmError> {
1389    let (policy, profile) = match active_sandbox_policy() {
1390        Some(value) => value,
1391        None => {
1392            let mut command = tokio::process::Command::new(program);
1393            command.args(args);
1394            return Ok(command);
1395        }
1396    };
1397    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
1398}
1399
1400pub fn command_output(
1401    program: &str,
1402    args: &[String],
1403    config: &ProcessCommandConfig,
1404) -> Result<Output, VmError> {
1405    // Testbench replay mode short-circuits the spawn entirely.
1406    // Recording mode falls through; the duration is captured by the
1407    // recording handle below using the injected mock clock when one
1408    // is active.
1409    if let Some(intercepted) =
1410        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
1411    {
1412        return intercepted.map_err(|message| {
1413            VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
1414        });
1415    }
1416
1417    let recording =
1418        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
1419
1420    let output = match active_sandbox_policy() {
1421        Some((policy, profile)) => {
1422            let config = sandboxed_process_config(config, &policy)?;
1423            ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
1424        }
1425        None => {
1426            let mut command = Command::new(program);
1427            command.args(args);
1428            apply_process_config(&mut command, config);
1429            // Interrupt-aware `Command::output()`: puts the child in its own
1430            // kill group and gracefully terminates the whole group when the
1431            // invoking scope is cancelled, a deadline fires, or the VM is
1432            // dropped. See `crate::op_interrupt`.
1433            crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
1434                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
1435            })?
1436        }
1437    };
1438    if let Some(error) = process_violation_error(&output) {
1439        return Err(error);
1440    }
1441    if let Some(span) = recording {
1442        span.finish(&output);
1443    }
1444    Ok(output)
1445}
1446
1447fn sandboxed_process_config(
1448    config: &ProcessCommandConfig,
1449    policy: &CapabilityPolicy,
1450) -> Result<ProcessCommandConfig, VmError> {
1451    let mut resolved = config.clone();
1452    if let Some(cwd) = resolved.cwd.as_ref() {
1453        enforce_process_cwd_for_policy(cwd, policy)?;
1454    } else {
1455        resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
1456    }
1457    neutralize_rustc_wrapper(&mut resolved.env);
1458    inject_workspace_tmpdir(&mut resolved.env, policy);
1459    Ok(resolved)
1460}
1461
1462/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
1463///
1464/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
1465/// cargo build is the first caller to spawn it, the daemon inherits the
1466/// `sandbox-exec` confinement permanently — even after it reparents to
1467/// launchd — and then fails *every* later build machine-wide with
1468/// `Operation not permitted` (it can no longer read build inputs outside the
1469/// sandbox root nor write its cache dir under `~/Library/Caches`). A
1470/// per-command sandbox must never be allowed to poison a cross-workspace
1471/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
1472/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
1473/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
1474/// on-disk cache and all unsandboxed builds are unaffected.
1475fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
1476    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
1477        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
1478            entry.1.clear();
1479        } else {
1480            env.push((key.to_string(), String::new()));
1481        }
1482    }
1483}
1484
1485/// Workspace-relative directory name for the sandbox-writable temp dir that
1486/// [`workspace_local_tmpdir`] points `TMPDIR`/`TMP`/`TEMP` at. Lives inside a
1487/// writable workspace root (which both OS backends already grant) so any
1488/// toolchain that honors `TMPDIR` writes its intermediates somewhere the
1489/// sandbox permits, instead of the unwritable system `/tmp`.
1490pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
1491
1492/// The environment keys a workspace-local temp dir is exported under. `TMPDIR`
1493/// is the POSIX/Rust/clang/gcc/Go/Swift convention; `TMP`/`TEMP` cover tools
1494/// (and Windows toolchains) that read those instead.
1495pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
1496
1497/// Resolve the sandbox-writable, workspace-local temp directory for `policy`,
1498/// creating it lazily.
1499///
1500/// Compiler linkers (`rustc`/`cc`/`ld`, Go, Swift, …) and countless other
1501/// toolchains write intermediate object/temp files to `$TMPDIR`, defaulting to
1502/// the system `/tmp` when it is unset. Under a restricted profile `/tmp` is
1503/// outside the writable workspace roots, so those writes are denied and a build
1504/// that would otherwise succeed FALSE-FAILS for an infrastructure reason. By
1505/// pointing the child's temp dir at a directory *inside* the first writable
1506/// workspace root — which the OS sandbox already grants write access to — the
1507/// build's temp writes land somewhere permitted without widening the sandbox.
1508///
1509/// Returns `None` when the policy declares no writable workspace root (there is
1510/// nowhere sandbox-writable to anchor the temp dir) or when the directory could
1511/// not be created (the caller then leaves the child's inherited temp dir
1512/// untouched rather than failing the spawn).
1513pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
1514    let root = normalized_workspace_roots(policy).into_iter().next()?;
1515    let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
1516    if let Err(error) = std::fs::create_dir_all(&tmpdir) {
1517        warn_once(
1518            "handler_sandbox_workspace_tmpdir",
1519            &format!(
1520                "could not create workspace-local temp dir '{}': {error}; \
1521                 leaving the child's inherited temp dir in place",
1522                tmpdir.display()
1523            ),
1524        );
1525        return None;
1526    }
1527    // Keep the temp dir's churn out of every git-based diff/status (so it never
1528    // leaks into an agent's view, a PR, or eval grading) by self-ignoring its
1529    // own contents. A `.gitignore` of `*` inside the dir excludes everything,
1530    // including itself, regardless of whether the workspace tracks it. Written
1531    // best-effort and only when absent so we don't thrash an existing file.
1532    let ignore = tmpdir.join(".gitignore");
1533    if !ignore.exists() {
1534        let _ = std::fs::write(
1535            &ignore,
1536            "# Created by the Harn sandbox; safe to delete.\n*\n",
1537        );
1538    }
1539    Some(tmpdir)
1540}
1541
1542/// Overlay `TMPDIR`/`TMP`/`TEMP` onto a child's env so a sandboxed toolchain
1543/// writes its intermediates to a workspace-local, sandbox-writable directory
1544/// instead of the unwritable system `/tmp` (see [`workspace_local_tmpdir`]).
1545///
1546/// A key the caller set explicitly in `env` is left untouched — an intentional
1547/// per-call `TMPDIR` is honored. The inherited-from-parent value is *not*
1548/// preserved: that is exactly the non-writable `/tmp` (or empty) we must
1549/// override. No-op under an unrestricted/absent policy or when no writable
1550/// workspace root is available.
1551pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
1552    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1553        return;
1554    }
1555    let Some(tmpdir) = workspace_local_tmpdir(policy) else {
1556        return;
1557    };
1558    let tmpdir = tmpdir.display().to_string();
1559    for key in TMPDIR_ENV_KEYS {
1560        if env.iter().any(|(existing, _)| existing == key) {
1561            // The caller pinned this key explicitly; respect it.
1562            continue;
1563        }
1564        env.push((key.to_string(), tmpdir.clone()));
1565    }
1566}
1567
1568/// The `TMPDIR`/`TMP`/`TEMP` overrides for the *currently active* execution
1569/// policy, as `(key, value)` pairs, or an empty vec when no restricted policy
1570/// is active or no writable workspace root exists.
1571///
1572/// This reads the active execution policy directly (gating only on a restricted
1573/// `sandbox_profile`), deliberately *not* through [`active_sandbox_policy`]:
1574/// the workspace-local temp dir is a benefit of the child env, independent of
1575/// whether OS confinement is enforced, so it must still engage under
1576/// `HARN_HANDLER_SANDBOX=warn`/`off` (which only weaken *enforcement*, not the
1577/// profile). [`inject_workspace_tmpdir`] still no-ops under `Unrestricted`.
1578///
1579/// This is the entry point the `host_call("process", …)` exec/spawn builder and
1580/// the `harn-hostlib` real spawner use to overlay the keys onto a
1581/// `Command`/`tokio::process::Command`, skipping any the caller already pinned.
1582pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
1583    let Some(policy) = crate::orchestration::current_execution_policy() else {
1584        return Vec::new();
1585    };
1586    let mut env = Vec::new();
1587    inject_workspace_tmpdir(&mut env, &policy);
1588    env
1589}
1590
1591/// Environment overlay that pins a child tool's *message* output to a
1592/// deterministic, English, UTF-8-preserving locale, as `(key, value)` pairs.
1593///
1594/// Build/test/verify commands inherit the parent environment, so a user whose
1595/// shell sets `LC_ALL=ja_JP.UTF-8` (or `LANG=de_DE.UTF-8`) would otherwise get
1596/// *localized* compiler/test output. Every downstream matcher that keys on
1597/// English diagnostics — deterministic syntax repair, error-signature
1598/// grounding, completion/pass-fail classification — would then silently
1599/// misfire for a non-Anglosphere user. Forcing a stable message locale is the
1600/// root-cause fix: it keeps the English matchers correct by construction,
1601/// without shipping per-locale translations of every toolchain.
1602///
1603/// `LC_MESSAGES=C` forces untranslated (English) messages for gettext-based
1604/// tools (gcc/clang, git-l10n, GNU coreutils, gradle) while deliberately *not*
1605/// touching `LC_CTYPE`/`LANG`, so UTF-8 handling of non-ASCII source and
1606/// identifiers is preserved (unlike the blunt `LC_ALL=C`, which forces an ASCII
1607/// ctype and can mangle non-ASCII identifiers in diagnostics). The .NET CLI
1608/// ignores `LC_*` and localizes from its own variable / the OS UI language, so
1609/// `DOTNET_CLI_UI_LANGUAGE=en` is required in addition.
1610///
1611/// A user-inherited `LC_ALL` would override `LC_MESSAGES`, so the spawn sites
1612/// additionally strip `LC_ALL` (unless the caller pinned it) before applying
1613/// this overlay. Both are subject to the caller-pinned-key rule (like the
1614/// `TMPDIR` overlay): an explicit `env`/`env_remove` still wins.
1615pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
1616    vec![
1617        ("LC_MESSAGES".to_string(), "C".to_string()),
1618        ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
1619    ]
1620}
1621
1622/// The environment variable a user-inherited value of which would override
1623/// [`deterministic_message_locale_env`]'s `LC_MESSAGES`. Spawn sites strip this
1624/// (unless the caller pinned it) so the forced message locale actually takes
1625/// effect. Kept as a named constant so both spawn paths stay in sync.
1626pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
1627
1628fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
1629    let roots = normalized_workspace_roots(policy);
1630    let current = std::env::current_dir().map_err(|error| {
1631        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1632            format!("process cwd resolution failed: {error}"),
1633        )))
1634    })?;
1635    let current = normalize_for_policy(&current);
1636    if roots.iter().any(|root| path_is_within(&current, root)) {
1637        return Ok(current);
1638    }
1639    roots.first().cloned().ok_or_else(|| {
1640        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1641            "process cwd resolution failed: no workspace root available",
1642        )))
1643    })
1644}
1645
1646fn build_std_command<B: SandboxBackend + ?Sized>(
1647    program: &str,
1648    args: &[String],
1649    policy: &CapabilityPolicy,
1650    profile: SandboxProfile,
1651) -> Result<Command, VmError> {
1652    let mut command = Command::new(program);
1653    command.args(args);
1654    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
1655        PrepareOutcome::Direct => Ok(command),
1656        PrepareOutcome::WrappedExec { wrapper, args } => {
1657            let mut wrapped = Command::new(wrapper);
1658            wrapped.args(args);
1659            Ok(wrapped)
1660        }
1661    }
1662}
1663
1664fn build_tokio_command<B: SandboxBackend + ?Sized>(
1665    program: &str,
1666    args: &[String],
1667    policy: &CapabilityPolicy,
1668    profile: SandboxProfile,
1669) -> Result<tokio::process::Command, VmError> {
1670    let mut command = tokio::process::Command::new(program);
1671    command.args(args);
1672    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
1673        PrepareOutcome::Direct => Ok(command),
1674        PrepareOutcome::WrappedExec { wrapper, args } => {
1675            let mut wrapped = tokio::process::Command::new(wrapper);
1676            wrapped.args(args);
1677            Ok(wrapped)
1678        }
1679    }
1680}
1681
1682pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
1683    let policy = crate::orchestration::current_execution_policy()?;
1684    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1685        return None;
1686    }
1687    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1688        || !ActiveBackend::available()
1689    {
1690        return None;
1691    }
1692    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
1693    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
1694    if !output.status.success()
1695        && (stderr.contains("operation not permitted")
1696            || stderr.contains("permission denied")
1697            || stderr.contains("access is denied")
1698            || stdout.contains("operation not permitted"))
1699    {
1700        return Some(sandbox_rejection(sandbox_process_violation_message(
1701            format!(
1702                "sandbox violation: process was denied by the OS sandbox (status {})",
1703                output.status.code().unwrap_or(-1)
1704            ),
1705        )));
1706    }
1707    if sandbox_signal_status(output) {
1708        return Some(sandbox_rejection(sandbox_process_violation_message(
1709            format!(
1710                "sandbox violation: process was terminated by the OS sandbox (status {})",
1711                output.status
1712            ),
1713        )));
1714    }
1715    None
1716}
1717
1718pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
1719    let policy = crate::orchestration::current_execution_policy()?;
1720    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
1721        return None;
1722    }
1723    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
1724        || !ActiveBackend::available()
1725    {
1726        return None;
1727    }
1728    let message = error.to_string().to_ascii_lowercase();
1729    if error.kind() == std::io::ErrorKind::PermissionDenied
1730        || message.contains("operation not permitted")
1731        || message.contains("permission denied")
1732        || message.contains("access is denied")
1733    {
1734        return Some(sandbox_rejection(sandbox_process_violation_message(
1735            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
1736        )));
1737    }
1738    None
1739}
1740
1741#[cfg(unix)]
1742fn sandbox_signal_status(output: &std::process::Output) -> bool {
1743    use std::os::unix::process::ExitStatusExt;
1744
1745    matches!(
1746        output.status.signal(),
1747        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
1748    )
1749}
1750
1751#[cfg(not(unix))]
1752fn sandbox_signal_status(_output: &std::process::Output) -> bool {
1753    false
1754}
1755
1756/// Returns the active capability policy and the resolved sandbox
1757/// profile, or `None` if confinement should be skipped entirely. The
1758/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
1759/// hatch both produce `None`. The `Wasi` profile also produces `None`
1760/// on the host spawn path — testbench mode intercepts subprocesses
1761/// before they reach this layer, so the host-spawn fallback should be
1762/// a normal direct exec.
1763pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
1764    let policy = crate::orchestration::current_execution_policy()?;
1765    let profile = policy.sandbox_profile;
1766    match profile {
1767        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
1768        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
1769            if effective_fallback(profile) == SandboxFallback::Off {
1770                None
1771            } else {
1772                Some((policy, profile))
1773            }
1774        }
1775    }
1776}
1777
1778fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
1779    if let Some(cwd) = config.cwd.as_ref() {
1780        command.current_dir(cwd);
1781    }
1782    command.envs(config.env.iter().map(|(key, value)| (key, value)));
1783    if config.stdin_null {
1784        command.stdin(Stdio::null());
1785    }
1786}
1787
1788fn spawn_error(error: std::io::Error) -> VmError {
1789    VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
1790        format!("process spawn failed: {error}"),
1791    )))
1792}
1793
1794/// Resolve the fallback policy for the requested profile. `OsHardened`
1795/// always enforces — that is the entire point of the profile, so the
1796/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
1797/// the env var (default `warn`).
1798pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
1799    if matches!(profile, SandboxProfile::OsHardened) {
1800        return SandboxFallback::Enforce;
1801    }
1802    match std::env::var(HANDLER_SANDBOX_ENV)
1803        .unwrap_or_else(|_| "warn".to_string())
1804        .trim()
1805        .to_ascii_lowercase()
1806        .as_str()
1807    {
1808        "0" | "false" | "off" | "none" => SandboxFallback::Off,
1809        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
1810        _ => SandboxFallback::Warn,
1811    }
1812}
1813
1814pub(crate) fn warn_once(key: &str, message: &str) {
1815    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
1816    if inserted {
1817        crate::events::log_warn("handler_sandbox", message);
1818    }
1819}
1820
1821pub(crate) fn sandbox_rejection(message: String) -> VmError {
1822    VmError::CategorizedError {
1823        message,
1824        category: ErrorCategory::ToolRejected,
1825    }
1826}
1827
1828fn sandbox_process_violation_message(summary: String) -> String {
1829    format!(
1830        "{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"
1831    )
1832}
1833
1834/// Helper for backends that can't attach confinement at all (macOS
1835/// without `/usr/bin/sandbox-exec`, Windows when called through the
1836/// `Command`-returning entry points): either fail loudly under
1837/// `OsHardened` / `enforce`, or warn once and proceed direct.
1838///
1839/// Linux and OpenBSD don't reach this path — they install confinement
1840/// in `pre_exec` and surface unavailability through `landlock_profile`
1841/// directly. The dead-code lint allow keeps the helper compilable on
1842/// targets where no backend uses it.
1843#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
1844pub(crate) fn unavailable(
1845    message: &str,
1846    profile: SandboxProfile,
1847) -> Result<PrepareOutcome, VmError> {
1848    match effective_fallback(profile) {
1849        SandboxFallback::Off | SandboxFallback::Warn => {
1850            warn_once("handler_sandbox_unavailable", message);
1851            Ok(PrepareOutcome::Direct)
1852        }
1853        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
1854            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
1855        ))),
1856    }
1857}
1858
1859/// Writable workspace roots derived from the active agent session's
1860/// workspace anchor: the anchor `primary` plus any `Extend` (writable)
1861/// mounts. Read-only mounts are intentionally excluded — they are not
1862/// writable jail roots (a read of one is permitted via the read-only-roots
1863/// path, but a write must not be). Returns `None` when there is no current
1864/// session or the session has no anchor, so the caller falls back to the
1865/// process execution root.
1866fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
1867    let session_id = crate::agent_sessions::current_session_id()?;
1868    let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
1869    let mut roots = vec![anchor.primary.clone()];
1870    for mounted in &anchor.additional_roots {
1871        if matches!(
1872            mounted.mount_mode,
1873            crate::workspace_anchor::MountMode::Extend
1874        ) {
1875            roots.push(mounted.path.clone());
1876        }
1877    }
1878    Some(roots)
1879}
1880
1881/// The project root a run is bound to even when the OS process cwd differs.
1882/// Prefer the typed execution context and keep `HARN_PROJECT_ROOT` as the
1883/// legacy standalone fallback. This mirrors the `workspace.project_root` host
1884/// fallback so the write jail and reported project root agree.
1885fn project_root_workspace_root() -> Option<PathBuf> {
1886    crate::stdlib::process::project_root_path().or_else(|| {
1887        std::env::var("HARN_PROJECT_ROOT")
1888            .ok()
1889            .map(|value| value.trim().to_string())
1890            .filter(|value| !value.is_empty())
1891            .map(PathBuf::from)
1892    })
1893}
1894
1895fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1896    let mut roots = base_workspace_roots(policy);
1897    // Git keeps a linked worktree's real git dir and shared common dir outside
1898    // the working tree; both need read-write scope or every git subprocess
1899    // fails inside an otherwise ordinary worktree checkout. See
1900    // [`crate::stdlib::git_topology`].
1901    for dir in git_scope_extension_for_roots(&roots).read_write {
1902        if !roots.iter().any(|existing| existing == &dir) {
1903            roots.push(dir);
1904        }
1905    }
1906    roots
1907}
1908
1909/// The workspace roots as configured by the policy (or the anchored/project/
1910/// execution-root fallback), before any git-topology extension. Kept separate
1911/// from [`normalized_workspace_roots`] so the git-topology detection runs
1912/// against the real project roots and never re-inspects the git dirs it adds.
1913fn base_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1914    if policy.workspace_roots.is_empty() {
1915        // An empty `policy.workspace_roots` means no explicit write-jail was
1916        // configured for this call. Historically this fell straight back to the
1917        // process execution root, but under the eval pattern (process cwd !=
1918        // `--project`) and dispatch fan-out children, the process cwd is the
1919        // repo, not the project the run is bound to — so a write that correctly
1920        // resolved INTO the project was rejected as outside the jail
1921        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
1922        // silently compensated. Prefer, in order: (1) the active agent
1923        // session's workspace anchor (primary + writable `Extend` mounts) when
1924        // the session is anchored; (2) the typed execution project root, with
1925        // legacy `HARN_PROJECT_ROOT` as a fallback, robust across session
1926        // nesting that an unanchored dispatch child sees; (3) the process
1927        // execution root, the historical
1928        // default. Explicit `policy.workspace_roots` still take precedence
1929        // (handled in the non-empty branch below).
1930        if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
1931            return anchor_roots
1932                .iter()
1933                .map(|root| normalize_for_policy(root))
1934                .collect();
1935        }
1936        if let Some(project_root) = project_root_workspace_root() {
1937            return vec![normalize_for_policy(&project_root)];
1938        }
1939        return vec![normalize_for_policy(
1940            &crate::stdlib::process::execution_root_path(),
1941        )];
1942    }
1943    policy
1944        .workspace_roots
1945        .iter()
1946        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1947        .collect()
1948}
1949
1950pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1951    normalized_workspace_roots(policy)
1952}
1953
1954/// Normalize the policy's read-only roots. Unlike
1955/// [`normalized_workspace_roots`], an empty list stays empty — read-only
1956/// scope is purely additive, so there is no execution-root fallback to
1957/// synthesize.
1958fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1959    let mut roots: Vec<PathBuf> = policy
1960        .read_only_roots
1961        .iter()
1962        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1963        .collect();
1964    // Object stores borrowed through `objects/info/alternates` (e.g. a
1965    // `git clone --shared`) live outside the workspace and are only ever read
1966    // by git; grant them read-only scope. See [`crate::stdlib::git_topology`].
1967    for dir in git_scope_extension_for_roots(&base_workspace_roots(policy)).read_only {
1968        if !roots.iter().any(|existing| existing == &dir) {
1969            roots.push(dir);
1970        }
1971    }
1972    roots
1973}
1974
1975/// Merge the git-topology scope extension across every workspace `base_root`,
1976/// normalizing each discovered directory the same way as a configured root so
1977/// scope checks and dedup compare canonical paths. Both the OS sandbox backends
1978/// and the pure `check_fs_path_scope` enforcement consume the extended roots.
1979fn git_scope_extension_for_roots(
1980    base_roots: &[PathBuf],
1981) -> crate::stdlib::git_topology::GitScopeExtension {
1982    let mut merged = crate::stdlib::git_topology::GitScopeExtension::default();
1983    for root in base_roots {
1984        let ext = crate::stdlib::git_topology::git_scope_extension(root);
1985        for dir in ext.read_write {
1986            let dir = normalize_for_policy(&dir);
1987            if !merged.read_write.iter().any(|existing| existing == &dir) {
1988                merged.read_write.push(dir);
1989            }
1990        }
1991        for dir in ext.read_only {
1992            let dir = normalize_for_policy(&dir);
1993            if !merged.read_only.iter().any(|existing| existing == &dir) {
1994                merged.read_only.push(dir);
1995            }
1996        }
1997    }
1998    merged
1999}
2000
2001#[cfg(any(
2002    target_os = "linux",
2003    target_os = "macos",
2004    target_os = "openbsd",
2005    target_os = "windows"
2006))]
2007pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2008    normalized_read_only_roots(policy)
2009}
2010
2011#[cfg(any(
2012    target_os = "linux",
2013    target_os = "macos",
2014    target_os = "openbsd",
2015    target_os = "windows"
2016))]
2017pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2018    normalized_process_roots(&policy.process_sandbox.read_roots)
2019}
2020
2021#[cfg(any(
2022    target_os = "linux",
2023    target_os = "macos",
2024    target_os = "openbsd",
2025    target_os = "windows"
2026))]
2027pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
2028    normalized_process_roots(&policy.process_sandbox.write_roots)
2029}
2030
2031#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2032pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
2033    policy.process_sandbox.effective_presets()
2034}
2035
2036#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2037pub(crate) fn process_sandbox_developer_toolchain_read_roots(
2038    policy: &CapabilityPolicy,
2039) -> Vec<PathBuf> {
2040    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2041        return Vec::new();
2042    }
2043    let Some(home) = sandbox_user_home_dir() else {
2044        return Vec::new();
2045    };
2046    developer_toolchain_read_roots_for_home(&home)
2047}
2048
2049#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2050pub(crate) fn process_sandbox_package_manager_config_read_roots(
2051    policy: &CapabilityPolicy,
2052) -> Vec<PathBuf> {
2053    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
2054        return Vec::new();
2055    }
2056    let Some(home) = sandbox_user_home_dir() else {
2057        return Vec::new();
2058    };
2059    package_manager_config_read_roots_for_home(&home)
2060}
2061
2062/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
2063/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
2064/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
2065/// read-only: a build legitimately populates `~/.gradle/caches`,
2066/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
2067/// gated on the `DeveloperToolchains` preset and granted *write* only when the
2068/// active policy already permits workspace writes (mirroring `UserTemp`); under
2069/// a read-only policy they fall back to read access so dependency resolution
2070/// still works.
2071// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
2072// (Landlock) sandbox backends; the Windows backend deliberately does not grant
2073// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
2074// targets keeps `-D warnings` happy on Windows, where this would otherwise be
2075// dead code.
2076#[cfg(any(target_os = "linux", target_os = "macos"))]
2077pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
2078    policy: &CapabilityPolicy,
2079) -> Vec<PathBuf> {
2080    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
2081        return Vec::new();
2082    }
2083    let Some(home) = sandbox_user_home_dir() else {
2084        return Vec::new();
2085    };
2086    developer_toolchain_cache_write_roots_for_home(&home)
2087}
2088
2089#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2090fn sandbox_user_home_dir() -> Option<PathBuf> {
2091    // Only an absolute home grounds the user-scope read-roots below; a
2092    // relative or unset home yields no extra roots (the safe direction).
2093    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
2094}
2095
2096#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2097pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2098    let mut roots: Vec<_> = [
2099        ".asdf",
2100        ".bun",
2101        ".cargo",
2102        ".fnm",
2103        ".juliaup",
2104        ".local/bin",
2105        ".local/share/mise",
2106        ".local/share/uv",
2107        ".nvm",
2108        ".pyenv",
2109        ".rbenv",
2110        ".rustup",
2111        ".sdkman",
2112        ".swiftly",
2113        ".volta",
2114        "go",
2115    ]
2116    .into_iter()
2117    .map(|entry| normalize_for_policy(&home.join(entry)))
2118    .collect();
2119    #[cfg(target_os = "windows")]
2120    roots.extend(
2121        [
2122            "AppData/Local/Programs/Python",
2123            "AppData/Local/uv",
2124            "AppData/Roaming/uv",
2125            "scoop",
2126        ]
2127        .into_iter()
2128        .map(|entry| normalize_for_policy(&home.join(entry))),
2129    );
2130    roots.sort_unstable();
2131    roots.dedup();
2132    roots
2133}
2134
2135/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
2136/// the macOS seatbelt and Linux Landlock backends render the same set; the
2137/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
2138/// `optional`/NotFound handling in each backend skips roots that do not exist.
2139#[cfg(any(target_os = "linux", target_os = "macos"))]
2140pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
2141    let mut roots: Vec<_> = [
2142        ".gradle",                             // Gradle (JVM/Android/Kotlin)
2143        ".m2",                                 // Maven (JVM)
2144        ".konan",                              // Kotlin/Native
2145        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
2146        "Library/Developer/Xcode/DerivedData", // Xcode build products
2147    ]
2148    .into_iter()
2149    .map(|entry| normalize_for_policy(&home.join(entry)))
2150    .collect();
2151    roots.sort_unstable();
2152    roots.dedup();
2153    roots
2154}
2155
2156#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
2157pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
2158    let mut roots: Vec<_> = [
2159        ".npmrc",
2160        ".gitconfig",
2161        ".netrc",
2162        ".yarnrc.yml",
2163        ".config",
2164        ".npm",
2165        ".cache",
2166        ".pip",
2167        ".pypirc",
2168        ".cargo/config",
2169        ".cargo/config.toml",
2170        ".cargo/credentials",
2171        ".cargo/credentials.toml",
2172        ".cargo/registry",
2173        ".cargo/git",
2174    ]
2175    .into_iter()
2176    .map(|entry| normalize_for_policy(&home.join(entry)))
2177    .collect();
2178    roots.sort_unstable();
2179    roots.dedup();
2180    roots
2181}
2182
2183#[cfg(any(
2184    target_os = "linux",
2185    target_os = "macos",
2186    target_os = "openbsd",
2187    target_os = "windows"
2188))]
2189fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
2190    roots
2191        .iter()
2192        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
2193        .collect()
2194}
2195
2196fn resolve_policy_path(path: &str) -> PathBuf {
2197    let candidate = PathBuf::from(path);
2198    if candidate.is_absolute() {
2199        candidate
2200    } else {
2201        crate::stdlib::process::execution_root_path().join(candidate)
2202    }
2203}
2204
2205fn normalize_for_policy(path: &Path) -> PathBuf {
2206    let absolute = if path.is_absolute() {
2207        path.to_path_buf()
2208    } else {
2209        crate::stdlib::process::execution_root_path().join(path)
2210    };
2211    let absolute = normalize_lexically(&absolute);
2212    if let Ok(canonical) = absolute.canonicalize() {
2213        return canonical;
2214    }
2215
2216    let mut existing = absolute.as_path();
2217    let mut suffix = Vec::new();
2218    while !existing.exists() {
2219        let Some(parent) = existing.parent() else {
2220            return normalize_lexically(&absolute);
2221        };
2222        if let Some(name) = existing.file_name() {
2223            suffix.push(name.to_os_string());
2224        }
2225        existing = parent;
2226    }
2227
2228    let mut normalized = existing
2229        .canonicalize()
2230        .unwrap_or_else(|_| normalize_lexically(existing));
2231    for component in suffix.iter().rev() {
2232        normalized.push(component);
2233    }
2234    normalize_lexically(&normalized)
2235}
2236
2237fn normalize_lexically(path: &Path) -> PathBuf {
2238    let mut normalized = PathBuf::new();
2239    for component in path.components() {
2240        match component {
2241            Component::CurDir => {}
2242            Component::ParentDir => {
2243                normalized.pop();
2244            }
2245            other => normalized.push(other.as_os_str()),
2246        }
2247    }
2248    normalized
2249}
2250
2251fn path_is_within(path: &Path, root: &Path) -> bool {
2252    path == root || path.starts_with(root)
2253}
2254
2255/// Resolve `path` to an absolute, lexically-normalized form for the standard
2256/// I/O device check. Unlike [`normalize_for_policy`] this never calls
2257/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
2258/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
2259fn normalize_io_device_path(path: &Path) -> PathBuf {
2260    let absolute = if path.is_absolute() {
2261        path.to_path_buf()
2262    } else {
2263        crate::stdlib::process::execution_root_path().join(path)
2264    };
2265    normalize_lexically(&absolute)
2266}
2267
2268/// Whether `path` is one of the standard process I/O device files that the
2269/// sandbox treats as a stream rather than a workspace mutation for this access:
2270/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
2271/// stream operation. `path` must already be absolute and lexically normalized.
2272fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
2273    match access {
2274        FsAccess::Read => {
2275            matches!(
2276                path.to_str(),
2277                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
2278            ) || is_dev_fd_descriptor(path)
2279        }
2280        FsAccess::Write => {
2281            matches!(
2282                path.to_str(),
2283                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
2284            ) || is_dev_fd_descriptor(path)
2285        }
2286        FsAccess::Delete => false,
2287    }
2288}
2289
2290/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
2291/// digits (the numeric file-descriptor aliases for the standard streams).
2292fn is_dev_fd_descriptor(path: &Path) -> bool {
2293    let Some(text) = path.to_str() else {
2294        return false;
2295    };
2296    let Some(fd) = text.strip_prefix("/dev/fd/") else {
2297        return false;
2298    };
2299    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
2300}
2301
2302#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
2303pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
2304    use crate::tool_annotations::SideEffectLevel;
2305    policy
2306        .side_effect_level
2307        .as_ref()
2308        .map(|level| SideEffectLevel::rank_str(level) >= SideEffectLevel::Network.rank())
2309        .unwrap_or(true)
2310}
2311
2312#[cfg(any(
2313    target_os = "linux",
2314    target_os = "macos",
2315    target_os = "openbsd",
2316    target_os = "windows"
2317))]
2318pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
2319    policy.capabilities.is_empty()
2320        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
2321}
2322
2323#[cfg(any(
2324    target_os = "linux",
2325    target_os = "macos",
2326    target_os = "openbsd",
2327    target_os = "windows"
2328))]
2329pub(crate) fn policy_allows_capability(
2330    policy: &CapabilityPolicy,
2331    capability: &str,
2332    ops: &[&str],
2333) -> bool {
2334    policy
2335        .capabilities
2336        .get(capability)
2337        .map(|allowed| {
2338            ops.iter()
2339                .any(|op| allowed.iter().any(|candidate| candidate == op))
2340        })
2341        .unwrap_or(false)
2342}
2343
2344impl FsAccess {
2345    fn verb(self) -> &'static str {
2346        match self {
2347            FsAccess::Read => "read",
2348            FsAccess::Write => "write",
2349            FsAccess::Delete => "delete",
2350        }
2351    }
2352}
2353
2354#[cfg(test)]
2355mod tests;