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