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 blocklist 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::path::{Component, Path, PathBuf};
40use std::process::{Command, Output, Stdio};
41
42#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
43use crate::orchestration::ProcessSandboxPreset;
44use crate::orchestration::{CapabilityPolicy, SandboxProfile};
45use crate::value::{ErrorCategory, VmError, VmValue};
46use crate::vm::Vm;
47
48#[cfg(target_os = "linux")]
49mod linux;
50#[cfg(target_os = "macos")]
51mod macos;
52#[cfg(target_os = "openbsd")]
53mod openbsd;
54#[cfg(target_os = "windows")]
55mod windows;
56
57const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
58
59thread_local! {
60    static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
61}
62
63/// The kind of filesystem access a path-scope check is guarding. This drives
64/// the verb rendered in rejection messages and the narrow standard-device
65/// exception; ordinary files are otherwise scoped by the same workspace roots.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FsAccess {
68    Read,
69    Write,
70    Delete,
71}
72
73#[derive(Clone, Debug, Default)]
74pub struct ProcessCommandConfig {
75    pub cwd: Option<PathBuf>,
76    pub env: Vec<(String, String)>,
77    pub stdin_null: bool,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub(crate) enum SandboxFallback {
82    Off,
83    Warn,
84    Enforce,
85}
86
87/// Trait implemented once per supported host OS. Each backend knows
88/// how to attach the active capability ceiling to a `Command` /
89/// `tokio::process::Command`, or — on Windows where the standard
90/// process types cannot carry an AppContainer — how to drive an
91/// equivalent custom spawn that returns an `Output`.
92///
93/// One concrete implementation is selected at compile time via `cfg`
94/// gating in this module. Callers should not reach for the trait
95/// directly; the module-level `command_output` / `std_command_for` /
96/// `tokio_command_for` entry points dispatch through it.
97pub(crate) trait SandboxBackend {
98    /// Stable identifier used in diagnostics and conformance fixtures.
99    fn name() -> &'static str;
100
101    /// Whether the platform mechanism this backend uses is available
102    /// on the running host (e.g. Landlock kernel support, the
103    /// `/usr/bin/sandbox-exec` binary, AppContainer APIs).
104    fn available() -> bool;
105
106    /// Apply the per-spawn confinement to a [`std::process::Command`].
107    /// Returns `Ok(())` if the backend can attach inline (Linux
108    /// `pre_exec`, OpenBSD pledge/unveil), or
109    /// [`PrepareOutcome::WrappedExec`] when the spawn must be
110    /// re-routed through a wrapper binary (macOS `sandbox-exec`).
111    fn prepare_std_command(
112        program: &str,
113        args: &[String],
114        command: &mut Command,
115        policy: &CapabilityPolicy,
116        profile: SandboxProfile,
117    ) -> Result<PrepareOutcome, VmError>;
118
119    /// Same as [`prepare_std_command`], but for `tokio::process::Command`.
120    fn prepare_tokio_command(
121        program: &str,
122        args: &[String],
123        command: &mut tokio::process::Command,
124        policy: &CapabilityPolicy,
125        profile: SandboxProfile,
126    ) -> Result<PrepareOutcome, VmError>;
127
128    /// Direct spawn that returns the captured `Output`. Windows uses
129    /// this because AppContainer cannot be attached to a vanilla
130    /// `Command`; other platforms can fall back to the default
131    /// implementation that builds a `Command` and runs it.
132    fn run_to_output(
133        program: &str,
134        args: &[String],
135        config: &ProcessCommandConfig,
136        policy: &CapabilityPolicy,
137        profile: SandboxProfile,
138    ) -> Result<Output, VmError> {
139        let mut command = build_std_command::<Self>(program, args, policy, profile)?;
140        apply_process_config(&mut command, config);
141        crate::op_interrupt::capture_output_interruptible(&mut command)
142            .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
143    }
144}
145
146/// What [`SandboxBackend::prepare_std_command`] / `_tokio_command`
147/// produced: either the original spawn target with sandboxing applied
148/// inline, or a wrapper binary that should be invoked instead.
149pub(crate) enum PrepareOutcome {
150    /// Use the prepared command unchanged.
151    Direct,
152    /// Replace the spawn target with the wrapper binary and args
153    /// (e.g. `sandbox-exec -p '<profile>' -- <program> <args...>`).
154    /// Only macOS produces this today; on other platforms the variant
155    /// stays defined so the trait surface is portable, but the
156    /// build-time dead-code lint would otherwise flip.
157    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
158    WrappedExec { wrapper: String, args: Vec<String> },
159}
160
161#[cfg(target_os = "linux")]
162type ActiveBackend = linux::Backend;
163#[cfg(target_os = "macos")]
164type ActiveBackend = macos::Backend;
165#[cfg(target_os = "openbsd")]
166type ActiveBackend = openbsd::Backend;
167#[cfg(target_os = "windows")]
168type ActiveBackend = windows::Backend;
169#[cfg(not(any(
170    target_os = "linux",
171    target_os = "macos",
172    target_os = "openbsd",
173    target_os = "windows"
174)))]
175type ActiveBackend = NoopBackend;
176
177#[cfg(not(any(
178    target_os = "linux",
179    target_os = "macos",
180    target_os = "openbsd",
181    target_os = "windows"
182)))]
183pub(crate) struct NoopBackend;
184
185#[cfg(not(any(
186    target_os = "linux",
187    target_os = "macos",
188    target_os = "openbsd",
189    target_os = "windows"
190)))]
191impl SandboxBackend for NoopBackend {
192    fn name() -> &'static str {
193        "noop"
194    }
195    fn available() -> bool {
196        false
197    }
198    fn prepare_std_command(
199        _program: &str,
200        _args: &[String],
201        _command: &mut Command,
202        _policy: &CapabilityPolicy,
203        _profile: SandboxProfile,
204    ) -> Result<PrepareOutcome, VmError> {
205        Ok(PrepareOutcome::Direct)
206    }
207    fn prepare_tokio_command(
208        _program: &str,
209        _args: &[String],
210        _command: &mut tokio::process::Command,
211        _policy: &CapabilityPolicy,
212        _profile: SandboxProfile,
213    ) -> Result<PrepareOutcome, VmError> {
214        Ok(PrepareOutcome::Direct)
215    }
216}
217
218pub(crate) fn reset_sandbox_state() {
219    WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
220}
221
222/// Stable identifier for the platform sandbox backend selected at
223/// compile time. Surfaced for diagnostics and conformance fixtures so
224/// callers can record which backend produced a recorded run.
225pub fn active_backend_name() -> &'static str {
226    ActiveBackend::name()
227}
228
229/// Whether the platform mechanism backing the active sandbox backend
230/// is available on the running host. Used by conformance fixtures and
231/// the `harn doctor` flow to skip OS-hardened checks on hosts without
232/// the required kernel support.
233pub fn active_backend_available() -> bool {
234    ActiveBackend::available()
235}
236
237/// Register Harn-callable introspection builtins for the sandbox.
238/// Intended for diagnostics, `harn doctor`, and conformance fixtures —
239/// not as a way to mutate runtime sandbox behavior from a script.
240pub fn register_sandbox_builtins(vm: &mut Vm) {
241    for def in MODULE_BUILTINS {
242        vm.register_builtin_def(def);
243    }
244}
245
246pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
247    &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
248    &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
249    &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
250];
251
252#[crate::stdlib::macros::harn_builtin(
253    sig = "sandbox_active_backend() -> string",
254    category = "sandbox"
255)]
256fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
257    Ok(VmValue::String(arcstr::ArcStr::from(active_backend_name())))
258}
259
260#[crate::stdlib::macros::harn_builtin(
261    sig = "sandbox_backend_available() -> bool",
262    category = "sandbox"
263)]
264fn sandbox_backend_available_impl(
265    _args: &[VmValue],
266    _out: &mut String,
267) -> Result<VmValue, VmError> {
268    Ok(VmValue::Bool(active_backend_available()))
269}
270
271#[crate::stdlib::macros::harn_builtin(
272    sig = "sandbox_active_profile() -> string",
273    category = "sandbox"
274)]
275fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
276    let profile = crate::orchestration::current_execution_policy()
277        .map(|policy| policy.sandbox_profile)
278        .unwrap_or(SandboxProfile::Unrestricted);
279    Ok(VmValue::String(arcstr::ArcStr::from(profile.as_str())))
280}
281
282/// A workspace-root scope violation: a path that resolved outside every
283/// configured workspace root under a restricted [`SandboxProfile`].
284///
285/// This is the `VmError`-free shape returned by [`check_fs_path_scope`] so
286/// that crates outside `harn-vm` (today: `harn-hostlib`) can enforce the
287/// same scope policy and render the violation onto their own error type.
288#[derive(Clone, Debug)]
289pub struct SandboxViolation {
290    /// The path the call attempted to touch, normalized against the
291    /// active policy (CWD-relative paths resolved to absolute, `..`
292    /// collapsed, symlinks canonicalized where the path exists).
293    pub attempted: PathBuf,
294    /// The writable workspace roots the path was checked against,
295    /// normalized the same way as `attempted`.
296    pub roots: Vec<PathBuf>,
297    /// Whether the rejected access was a read, write, or delete.
298    pub access: FsAccess,
299    /// True when the path resolved *inside* a read-only root: it is in
300    /// scope for reads, and only the attempted mutation is denied. False
301    /// when the path fell outside every configured root entirely.
302    pub read_only: bool,
303}
304
305impl SandboxViolation {
306    /// Render the canonical rejection message. Matches the text produced
307    /// by [`enforce_fs_path`] so the `harness.fs.*` and hostlib surfaces
308    /// reject an out-of-root path identically.
309    pub fn message(&self, builtin: &str) -> String {
310        if self.read_only {
311            return format!(
312                "sandbox violation: builtin '{builtin}' attempted to {} '{}' under a read-only workspace root",
313                self.access.verb(),
314                self.attempted.display(),
315            );
316        }
317        format!(
318            "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
319            self.access.verb(),
320            self.attempted.display(),
321            self.roots
322                .iter()
323                .map(|root| root.display().to_string())
324                .collect::<Vec<_>>()
325                .join(", ")
326        )
327    }
328}
329
330/// Check whether `path` is inside the active policy's workspace roots.
331///
332/// Returns `Ok(())` when no execution policy is active, when the active
333/// profile is [`SandboxProfile::Unrestricted`], when the normalized path
334/// falls within a writable workspace root, or — for [`FsAccess::Read`]
335/// only — when it falls within a read-only root. A write/delete that
336/// resolves under a read-only root is rejected with `read_only` set, as
337/// is any access that falls outside every configured root.
338///
339/// This is the public, `VmError`-free entry point embedders use to apply
340/// workspace-root scoping to their own host calls. The in-crate
341/// `harness.fs.*` builtins funnel through [`enforce_fs_path`], which wraps
342/// this with a `VmError`; both share the same path normalization and
343/// rejection text.
344pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
345    let Some(policy) = crate::orchestration::current_execution_policy() else {
346        return Ok(());
347    };
348    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
349        return Ok(());
350    }
351    // Standard process I/O device files are not workspace filesystem
352    // mutations: writing to /dev/stdout, /dev/stderr, or /dev/null (and the
353    // numeric /dev/fd/<N> descriptors they alias) targets the process's own
354    // output streams, not the sandboxed tree. A pipeline that falls back to
355    // /dev/stdout for debug output must not read as a sandbox violation, so
356    // allow these regardless of the configured roots. Matched on the
357    // lexically-normalized path (not the canonicalized form): canonicalize()
358    // rewrites /dev/stdout to a per-process /dev/fd/<…>.output alias that no
359    // longer looks like a standard device. Kept deliberately narrow — only
360    // the well-known device files, no broader /dev access.
361    if is_standard_io_device_for_access(&normalize_io_device_path(path), access) {
362        return Ok(());
363    }
364    let candidate = normalize_for_policy(path);
365    let roots = normalized_workspace_roots(&policy);
366    if roots.iter().any(|root| path_is_within(&candidate, root)) {
367        return Ok(());
368    }
369    let read_only_roots = normalized_read_only_roots(&policy);
370    let within_read_only = read_only_roots
371        .iter()
372        .any(|root| path_is_within(&candidate, root));
373    if within_read_only && access == FsAccess::Read {
374        return Ok(());
375    }
376    Err(SandboxViolation {
377        attempted: candidate,
378        roots,
379        access,
380        read_only: within_read_only,
381    })
382}
383
384pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
385    check_fs_path_scope(path, access)
386        .map_err(|violation| sandbox_rejection(violation.message(builtin)))
387}
388
389pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
390    let Some(policy) = crate::orchestration::current_execution_policy() else {
391        return Ok(());
392    };
393    enforce_process_cwd_for_policy(path, &policy)
394}
395
396fn enforce_process_cwd_for_policy(path: &Path, policy: &CapabilityPolicy) -> Result<(), VmError> {
397    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
398        return Ok(());
399    }
400    let candidate = normalize_for_policy(path);
401    let roots = normalized_workspace_roots(policy);
402    if roots.iter().any(|root| path_is_within(&candidate, root)) {
403        return Ok(());
404    }
405    Err(sandbox_rejection(format!(
406        "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
407        candidate.display(),
408        roots
409            .iter()
410            .map(|root| root.display().to_string())
411            .collect::<Vec<_>>()
412            .join(", ")
413    )))
414}
415
416pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
417    let (policy, profile) = match active_sandbox_policy() {
418        Some(value) => value,
419        None => {
420            let mut command = Command::new(program);
421            command.args(args);
422            return Ok(command);
423        }
424    };
425    build_std_command::<ActiveBackend>(program, args, &policy, profile)
426}
427
428pub fn tokio_command_for(
429    program: &str,
430    args: &[String],
431) -> Result<tokio::process::Command, VmError> {
432    let (policy, profile) = match active_sandbox_policy() {
433        Some(value) => value,
434        None => {
435            let mut command = tokio::process::Command::new(program);
436            command.args(args);
437            return Ok(command);
438        }
439    };
440    build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
441}
442
443pub fn command_output(
444    program: &str,
445    args: &[String],
446    config: &ProcessCommandConfig,
447) -> Result<Output, VmError> {
448    // Testbench replay mode short-circuits the spawn entirely.
449    // Recording mode falls through; the duration is captured by the
450    // recording handle below using the injected mock clock when one
451    // is active.
452    if let Some(intercepted) =
453        crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
454    {
455        return intercepted.map_err(|message| {
456            VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(message)))
457        });
458    }
459
460    let recording =
461        crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
462
463    let output = match active_sandbox_policy() {
464        Some((policy, profile)) => {
465            let config = sandboxed_process_config(config, &policy)?;
466            ActiveBackend::run_to_output(program, args, &config, &policy, profile)?
467        }
468        None => {
469            let mut command = Command::new(program);
470            command.args(args);
471            apply_process_config(&mut command, config);
472            // Interrupt-aware `Command::output()`: puts the child in its own
473            // kill group and gracefully terminates the whole group when the
474            // invoking scope is cancelled, a deadline fires, or the VM is
475            // dropped. See `crate::op_interrupt`.
476            crate::op_interrupt::capture_output_interruptible(&mut command).map_err(|error| {
477                process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
478            })?
479        }
480    };
481    if let Some(error) = process_violation_error(&output) {
482        return Err(error);
483    }
484    if let Some(span) = recording {
485        span.finish(&output);
486    }
487    Ok(output)
488}
489
490fn sandboxed_process_config(
491    config: &ProcessCommandConfig,
492    policy: &CapabilityPolicy,
493) -> Result<ProcessCommandConfig, VmError> {
494    let mut resolved = config.clone();
495    if let Some(cwd) = resolved.cwd.as_ref() {
496        enforce_process_cwd_for_policy(cwd, policy)?;
497    } else {
498        resolved.cwd = Some(default_process_cwd_for_policy(policy)?);
499    }
500    neutralize_rustc_wrapper(&mut resolved.env);
501    inject_workspace_tmpdir(&mut resolved.env, policy);
502    Ok(resolved)
503}
504
505/// Disable any Cargo `rustc` wrapper (e.g. `sccache`) for a sandboxed spawn.
506///
507/// `sccache` is a single shared, long-lived per-user daemon. If a sandboxed
508/// cargo build is the first caller to spawn it, the daemon inherits the
509/// `sandbox-exec` confinement permanently — even after it reparents to
510/// launchd — and then fails *every* later build machine-wide with
511/// `Operation not permitted` (it can no longer read build inputs outside the
512/// sandbox root nor write its cache dir under `~/Library/Caches`). A
513/// per-command sandbox must never be allowed to poison a cross-workspace
514/// daemon, so sandboxed builds bypass the wrapper entirely. Cargo treats an
515/// empty `CARGO_BUILD_RUSTC_WRAPPER` / `RUSTC_WRAPPER` as "no wrapper", which
516/// overrides any `build.rustc-wrapper` set in `.cargo/config.toml`. The
517/// on-disk cache and all unsandboxed builds are unaffected.
518fn neutralize_rustc_wrapper(env: &mut Vec<(String, String)>) {
519    for key in ["RUSTC_WRAPPER", "CARGO_BUILD_RUSTC_WRAPPER"] {
520        if let Some(entry) = env.iter_mut().find(|(existing, _)| existing == key) {
521            entry.1.clear();
522        } else {
523            env.push((key.to_string(), String::new()));
524        }
525    }
526}
527
528/// Workspace-relative directory name for the sandbox-writable temp dir that
529/// [`workspace_local_tmpdir`] points `TMPDIR`/`TMP`/`TEMP` at. Lives inside a
530/// writable workspace root (which both OS backends already grant) so any
531/// toolchain that honors `TMPDIR` writes its intermediates somewhere the
532/// sandbox permits, instead of the unwritable system `/tmp`.
533pub(crate) const WORKSPACE_TMPDIR_NAME: &str = ".harn-tmp";
534
535/// The environment keys a workspace-local temp dir is exported under. `TMPDIR`
536/// is the POSIX/Rust/clang/gcc/Go/Swift convention; `TMP`/`TEMP` cover tools
537/// (and Windows toolchains) that read those instead.
538pub(crate) const TMPDIR_ENV_KEYS: [&str; 3] = ["TMPDIR", "TMP", "TEMP"];
539
540/// Resolve the sandbox-writable, workspace-local temp directory for `policy`,
541/// creating it lazily.
542///
543/// Compiler linkers (`rustc`/`cc`/`ld`, Go, Swift, …) and countless other
544/// toolchains write intermediate object/temp files to `$TMPDIR`, defaulting to
545/// the system `/tmp` when it is unset. Under a restricted profile `/tmp` is
546/// outside the writable workspace roots, so those writes are denied and a build
547/// that would otherwise succeed FALSE-FAILS for an infrastructure reason. By
548/// pointing the child's temp dir at a directory *inside* the first writable
549/// workspace root — which the OS sandbox already grants write access to — the
550/// build's temp writes land somewhere permitted without widening the sandbox.
551///
552/// Returns `None` when the policy declares no writable workspace root (there is
553/// nowhere sandbox-writable to anchor the temp dir) or when the directory could
554/// not be created (the caller then leaves the child's inherited temp dir
555/// untouched rather than failing the spawn).
556pub(crate) fn workspace_local_tmpdir(policy: &CapabilityPolicy) -> Option<PathBuf> {
557    let root = normalized_workspace_roots(policy).into_iter().next()?;
558    let tmpdir = root.join(WORKSPACE_TMPDIR_NAME);
559    if let Err(error) = std::fs::create_dir_all(&tmpdir) {
560        warn_once(
561            "handler_sandbox_workspace_tmpdir",
562            &format!(
563                "could not create workspace-local temp dir '{}': {error}; \
564                 leaving the child's inherited temp dir in place",
565                tmpdir.display()
566            ),
567        );
568        return None;
569    }
570    // Keep the temp dir's churn out of every git-based diff/status (so it never
571    // leaks into an agent's view, a PR, or eval grading) by self-ignoring its
572    // own contents. A `.gitignore` of `*` inside the dir excludes everything,
573    // including itself, regardless of whether the workspace tracks it. Written
574    // best-effort and only when absent so we don't thrash an existing file.
575    let ignore = tmpdir.join(".gitignore");
576    if !ignore.exists() {
577        let _ = std::fs::write(
578            &ignore,
579            "# Created by the Harn sandbox; safe to delete.\n*\n",
580        );
581    }
582    Some(tmpdir)
583}
584
585/// Overlay `TMPDIR`/`TMP`/`TEMP` onto a child's env so a sandboxed toolchain
586/// writes its intermediates to a workspace-local, sandbox-writable directory
587/// instead of the unwritable system `/tmp` (see [`workspace_local_tmpdir`]).
588///
589/// A key the caller set explicitly in `env` is left untouched — an intentional
590/// per-call `TMPDIR` is honored. The inherited-from-parent value is *not*
591/// preserved: that is exactly the non-writable `/tmp` (or empty) we must
592/// override. No-op under an unrestricted/absent policy or when no writable
593/// workspace root is available.
594pub(crate) fn inject_workspace_tmpdir(env: &mut Vec<(String, String)>, policy: &CapabilityPolicy) {
595    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
596        return;
597    }
598    let Some(tmpdir) = workspace_local_tmpdir(policy) else {
599        return;
600    };
601    let tmpdir = tmpdir.display().to_string();
602    for key in TMPDIR_ENV_KEYS {
603        if env.iter().any(|(existing, _)| existing == key) {
604            // The caller pinned this key explicitly; respect it.
605            continue;
606        }
607        env.push((key.to_string(), tmpdir.clone()));
608    }
609}
610
611/// The `TMPDIR`/`TMP`/`TEMP` overrides for the *currently active* execution
612/// policy, as `(key, value)` pairs, or an empty vec when no restricted policy
613/// is active or no writable workspace root exists.
614///
615/// This reads the active execution policy directly (gating only on a restricted
616/// `sandbox_profile`), deliberately *not* through [`active_sandbox_policy`]:
617/// the workspace-local temp dir is a benefit of the child env, independent of
618/// whether OS confinement is enforced, so it must still engage under
619/// `HARN_HANDLER_SANDBOX=warn`/`off` (which only weaken *enforcement*, not the
620/// profile). [`inject_workspace_tmpdir`] still no-ops under `Unrestricted`.
621///
622/// This is the entry point the `host_call("process", …)` exec/spawn builder and
623/// the `harn-hostlib` real spawner use to overlay the keys onto a
624/// `Command`/`tokio::process::Command`, skipping any the caller already pinned.
625pub fn active_workspace_tmpdir_env() -> Vec<(String, String)> {
626    let Some(policy) = crate::orchestration::current_execution_policy() else {
627        return Vec::new();
628    };
629    let mut env = Vec::new();
630    inject_workspace_tmpdir(&mut env, &policy);
631    env
632}
633
634/// Environment overlay that pins a child tool's *message* output to a
635/// deterministic, English, UTF-8-preserving locale, as `(key, value)` pairs.
636///
637/// Build/test/verify commands inherit the parent environment, so a user whose
638/// shell sets `LC_ALL=ja_JP.UTF-8` (or `LANG=de_DE.UTF-8`) would otherwise get
639/// *localized* compiler/test output. Every downstream matcher that keys on
640/// English diagnostics — deterministic syntax repair, error-signature
641/// grounding, completion/pass-fail classification — would then silently
642/// misfire for a non-Anglosphere user. Forcing a stable message locale is the
643/// root-cause fix: it keeps the English matchers correct by construction,
644/// without shipping per-locale translations of every toolchain.
645///
646/// `LC_MESSAGES=C` forces untranslated (English) messages for gettext-based
647/// tools (gcc/clang, git-l10n, GNU coreutils, gradle) while deliberately *not*
648/// touching `LC_CTYPE`/`LANG`, so UTF-8 handling of non-ASCII source and
649/// identifiers is preserved (unlike the blunt `LC_ALL=C`, which forces an ASCII
650/// ctype and can mangle non-ASCII identifiers in diagnostics). The .NET CLI
651/// ignores `LC_*` and localizes from its own variable / the OS UI language, so
652/// `DOTNET_CLI_UI_LANGUAGE=en` is required in addition.
653///
654/// A user-inherited `LC_ALL` would override `LC_MESSAGES`, so the spawn sites
655/// additionally strip `LC_ALL` (unless the caller pinned it) before applying
656/// this overlay. Both are subject to the caller-pinned-key rule (like the
657/// `TMPDIR` overlay): an explicit `env`/`env_remove` still wins.
658pub fn deterministic_message_locale_env() -> Vec<(String, String)> {
659    vec![
660        ("LC_MESSAGES".to_string(), "C".to_string()),
661        ("DOTNET_CLI_UI_LANGUAGE".to_string(), "en".to_string()),
662    ]
663}
664
665/// The environment variable a user-inherited value of which would override
666/// [`deterministic_message_locale_env`]'s `LC_MESSAGES`. Spawn sites strip this
667/// (unless the caller pinned it) so the forced message locale actually takes
668/// effect. Kept as a named constant so both spawn paths stay in sync.
669pub const MESSAGE_LOCALE_OVERRIDE_ENV: &str = "LC_ALL";
670
671fn default_process_cwd_for_policy(policy: &CapabilityPolicy) -> Result<PathBuf, VmError> {
672    let roots = normalized_workspace_roots(policy);
673    let current = std::env::current_dir().map_err(|error| {
674        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
675            format!("process cwd resolution failed: {error}"),
676        )))
677    })?;
678    let current = normalize_for_policy(&current);
679    if roots.iter().any(|root| path_is_within(&current, root)) {
680        return Ok(current);
681    }
682    roots.first().cloned().ok_or_else(|| {
683        VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
684            "process cwd resolution failed: no workspace root available",
685        )))
686    })
687}
688
689fn build_std_command<B: SandboxBackend + ?Sized>(
690    program: &str,
691    args: &[String],
692    policy: &CapabilityPolicy,
693    profile: SandboxProfile,
694) -> Result<Command, VmError> {
695    let mut command = Command::new(program);
696    command.args(args);
697    match B::prepare_std_command(program, args, &mut command, policy, profile)? {
698        PrepareOutcome::Direct => Ok(command),
699        PrepareOutcome::WrappedExec { wrapper, args } => {
700            let mut wrapped = Command::new(wrapper);
701            wrapped.args(args);
702            Ok(wrapped)
703        }
704    }
705}
706
707fn build_tokio_command<B: SandboxBackend + ?Sized>(
708    program: &str,
709    args: &[String],
710    policy: &CapabilityPolicy,
711    profile: SandboxProfile,
712) -> Result<tokio::process::Command, VmError> {
713    let mut command = tokio::process::Command::new(program);
714    command.args(args);
715    match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
716        PrepareOutcome::Direct => Ok(command),
717        PrepareOutcome::WrappedExec { wrapper, args } => {
718            let mut wrapped = tokio::process::Command::new(wrapper);
719            wrapped.args(args);
720            Ok(wrapped)
721        }
722    }
723}
724
725pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
726    let policy = crate::orchestration::current_execution_policy()?;
727    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
728        return None;
729    }
730    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
731        || !ActiveBackend::available()
732    {
733        return None;
734    }
735    let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
736    let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
737    if !output.status.success()
738        && (stderr.contains("operation not permitted")
739            || stderr.contains("permission denied")
740            || stderr.contains("access is denied")
741            || stdout.contains("operation not permitted"))
742    {
743        return Some(sandbox_rejection(sandbox_process_violation_message(
744            format!(
745                "sandbox violation: process was denied by the OS sandbox (status {})",
746                output.status.code().unwrap_or(-1)
747            ),
748        )));
749    }
750    if sandbox_signal_status(output) {
751        return Some(sandbox_rejection(sandbox_process_violation_message(
752            format!(
753                "sandbox violation: process was terminated by the OS sandbox (status {})",
754                output.status
755            ),
756        )));
757    }
758    None
759}
760
761pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
762    let policy = crate::orchestration::current_execution_policy()?;
763    if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
764        return None;
765    }
766    if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
767        || !ActiveBackend::available()
768    {
769        return None;
770    }
771    let message = error.to_string().to_ascii_lowercase();
772    if error.kind() == std::io::ErrorKind::PermissionDenied
773        || message.contains("operation not permitted")
774        || message.contains("permission denied")
775        || message.contains("access is denied")
776    {
777        return Some(sandbox_rejection(sandbox_process_violation_message(
778            format!("sandbox violation: process was denied by the OS sandbox before exec: {error}"),
779        )));
780    }
781    None
782}
783
784#[cfg(unix)]
785fn sandbox_signal_status(output: &std::process::Output) -> bool {
786    use std::os::unix::process::ExitStatusExt;
787
788    matches!(
789        output.status.signal(),
790        Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
791    )
792}
793
794#[cfg(not(unix))]
795fn sandbox_signal_status(_output: &std::process::Output) -> bool {
796    false
797}
798
799/// Returns the active capability policy and the resolved sandbox
800/// profile, or `None` if confinement should be skipped entirely. The
801/// `Unrestricted` profile and the `HARN_HANDLER_SANDBOX=off` escape
802/// hatch both produce `None`. The `Wasi` profile also produces `None`
803/// on the host spawn path — testbench mode intercepts subprocesses
804/// before they reach this layer, so the host-spawn fallback should be
805/// a normal direct exec.
806pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
807    let policy = crate::orchestration::current_execution_policy()?;
808    let profile = policy.sandbox_profile;
809    match profile {
810        SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
811        SandboxProfile::Worktree | SandboxProfile::OsHardened => {
812            if effective_fallback(profile) == SandboxFallback::Off {
813                None
814            } else {
815                Some((policy, profile))
816            }
817        }
818    }
819}
820
821fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
822    if let Some(cwd) = config.cwd.as_ref() {
823        command.current_dir(cwd);
824    }
825    command.envs(config.env.iter().map(|(key, value)| (key, value)));
826    if config.stdin_null {
827        command.stdin(Stdio::null());
828    }
829}
830
831fn spawn_error(error: std::io::Error) -> VmError {
832    VmError::Thrown(crate::value::VmValue::String(arcstr::ArcStr::from(
833        format!("process spawn failed: {error}"),
834    )))
835}
836
837/// Resolve the fallback policy for the requested profile. `OsHardened`
838/// always enforces — that is the entire point of the profile, so the
839/// `HARN_HANDLER_SANDBOX` env var cannot weaken it. `Worktree` honors
840/// the env var (default `warn`).
841pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
842    if matches!(profile, SandboxProfile::OsHardened) {
843        return SandboxFallback::Enforce;
844    }
845    match std::env::var(HANDLER_SANDBOX_ENV)
846        .unwrap_or_else(|_| "warn".to_string())
847        .trim()
848        .to_ascii_lowercase()
849        .as_str()
850    {
851        "0" | "false" | "off" | "none" => SandboxFallback::Off,
852        "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
853        _ => SandboxFallback::Warn,
854    }
855}
856
857pub(crate) fn warn_once(key: &str, message: &str) {
858    let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
859    if inserted {
860        crate::events::log_warn("handler_sandbox", message);
861    }
862}
863
864pub(crate) fn sandbox_rejection(message: String) -> VmError {
865    VmError::CategorizedError {
866        message,
867        category: ErrorCategory::ToolRejected,
868    }
869}
870
871fn sandbox_process_violation_message(summary: String) -> String {
872    format!(
873        "{summary}; if the command depends on a user-managed toolchain or cache outside the workspace, add that root to process_sandbox.read_roots or process_sandbox.write_roots"
874    )
875}
876
877/// Helper for backends that can't attach confinement at all (macOS
878/// without `/usr/bin/sandbox-exec`, Windows when called through the
879/// `Command`-returning entry points): either fail loudly under
880/// `OsHardened` / `enforce`, or warn once and proceed direct.
881///
882/// Linux and OpenBSD don't reach this path — they install confinement
883/// in `pre_exec` and surface unavailability through `landlock_profile`
884/// directly. The dead-code lint allow keeps the helper compilable on
885/// targets where no backend uses it.
886#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
887pub(crate) fn unavailable(
888    message: &str,
889    profile: SandboxProfile,
890) -> Result<PrepareOutcome, VmError> {
891    match effective_fallback(profile) {
892        SandboxFallback::Off | SandboxFallback::Warn => {
893            warn_once("handler_sandbox_unavailable", message);
894            Ok(PrepareOutcome::Direct)
895        }
896        SandboxFallback::Enforce => Err(sandbox_rejection(format!(
897            "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
898        ))),
899    }
900}
901
902/// Writable workspace roots derived from the active agent session's
903/// workspace anchor: the anchor `primary` plus any `Extend` (writable)
904/// mounts. Read-only mounts are intentionally excluded — they are not
905/// writable jail roots (a read of one is permitted via the read-only-roots
906/// path, but a write must not be). Returns `None` when there is no current
907/// session or the session has no anchor, so the caller falls back to the
908/// process execution root.
909fn current_session_anchor_workspace_roots() -> Option<Vec<PathBuf>> {
910    let session_id = crate::agent_sessions::current_session_id()?;
911    let anchor = crate::agent_sessions::workspace_anchor(&session_id)?;
912    let mut roots = vec![anchor.primary.clone()];
913    for mounted in &anchor.additional_roots {
914        if matches!(
915            mounted.mount_mode,
916            crate::workspace_anchor::MountMode::Extend
917        ) {
918            roots.push(mounted.path.clone());
919        }
920    }
921    Some(roots)
922}
923
924/// The host-declared project root (`HARN_PROJECT_ROOT`), if set and
925/// non-empty. This mirrors the standalone project-root fallback the
926/// `workspace.project_root` host capability already uses
927/// (`stdlib::host`), so the write jail and the reported project root
928/// agree. It is the project a run is bound to even when the OS process
929/// cwd differs (the eval harness runs `burin-headless` from the repo with
930/// `--project <fixture>`, and the real IDE may launch from elsewhere).
931fn project_root_env_workspace_root() -> Option<PathBuf> {
932    std::env::var("HARN_PROJECT_ROOT")
933        .ok()
934        .map(|value| value.trim().to_string())
935        .filter(|value| !value.is_empty())
936        .map(PathBuf::from)
937}
938
939fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
940    if policy.workspace_roots.is_empty() {
941        // An empty `policy.workspace_roots` means no explicit write-jail was
942        // configured for this call. Historically this fell straight back to the
943        // process execution root, but under the eval pattern (process cwd !=
944        // `--project`) and dispatch fan-out children, the process cwd is the
945        // repo, not the project the run is bound to — so a write that correctly
946        // resolved INTO the project was rejected as outside the jail
947        // (HARN-CAP-201), the dispatched child wrote nothing, and the parent
948        // silently compensated. Prefer, in order: (1) the active agent
949        // session's workspace anchor (primary + writable `Extend` mounts) when
950        // the session is anchored; (2) the host-declared `HARN_PROJECT_ROOT`
951        // project root (robust across the session nesting that an unanchored
952        // dispatch child sees); (3) the process execution root, the historical
953        // default. Explicit `policy.workspace_roots` still take precedence
954        // (handled in the non-empty branch below).
955        if let Some(anchor_roots) = current_session_anchor_workspace_roots() {
956            return anchor_roots
957                .iter()
958                .map(|root| normalize_for_policy(root))
959                .collect();
960        }
961        if let Some(project_root) = project_root_env_workspace_root() {
962            return vec![normalize_for_policy(&project_root)];
963        }
964        return vec![normalize_for_policy(
965            &crate::stdlib::process::execution_root_path(),
966        )];
967    }
968    policy
969        .workspace_roots
970        .iter()
971        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
972        .collect()
973}
974
975pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
976    normalized_workspace_roots(policy)
977}
978
979/// Normalize the policy's read-only roots. Unlike
980/// [`normalized_workspace_roots`], an empty list stays empty — read-only
981/// scope is purely additive, so there is no execution-root fallback to
982/// synthesize.
983fn normalized_read_only_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
984    policy
985        .read_only_roots
986        .iter()
987        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
988        .collect()
989}
990
991#[cfg(any(
992    target_os = "linux",
993    target_os = "macos",
994    target_os = "openbsd",
995    target_os = "windows"
996))]
997pub(crate) fn process_sandbox_readonly_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
998    normalized_read_only_roots(policy)
999}
1000
1001#[cfg(any(
1002    target_os = "linux",
1003    target_os = "macos",
1004    target_os = "openbsd",
1005    target_os = "windows"
1006))]
1007pub(crate) fn process_sandbox_policy_read_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1008    normalized_process_roots(&policy.process_sandbox.read_roots)
1009}
1010
1011#[cfg(any(
1012    target_os = "linux",
1013    target_os = "macos",
1014    target_os = "openbsd",
1015    target_os = "windows"
1016))]
1017pub(crate) fn process_sandbox_policy_write_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1018    normalized_process_roots(&policy.process_sandbox.write_roots)
1019}
1020
1021#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1022pub(crate) fn process_sandbox_presets(policy: &CapabilityPolicy) -> Vec<ProcessSandboxPreset> {
1023    policy.process_sandbox.effective_presets()
1024}
1025
1026#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1027pub(crate) fn process_sandbox_developer_toolchain_read_roots(
1028    policy: &CapabilityPolicy,
1029) -> Vec<PathBuf> {
1030    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1031        return Vec::new();
1032    }
1033    let Some(home) = sandbox_user_home_dir() else {
1034        return Vec::new();
1035    };
1036    developer_toolchain_read_roots_for_home(&home)
1037}
1038
1039#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1040pub(crate) fn process_sandbox_package_manager_config_read_roots(
1041    policy: &CapabilityPolicy,
1042) -> Vec<PathBuf> {
1043    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::PackageManagerConfig) {
1044        return Vec::new();
1045    }
1046    let Some(home) = sandbox_user_home_dir() else {
1047        return Vec::new();
1048    };
1049    package_manager_config_read_roots_for_home(&home)
1050}
1051
1052/// Per-user toolchain *cache* roots that JVM/iOS build tools read **and write**
1053/// while a sandboxed build runs (Gradle, Maven, CocoaPods, Xcode, Kotlin
1054/// Native). Unlike [`developer_toolchain_read_roots_for_home`] these are not
1055/// read-only: a build legitimately populates `~/.gradle/caches`,
1056/// `~/.m2/repository`, `~/Library/Developer/Xcode/DerivedData`, etc. They are
1057/// gated on the `DeveloperToolchains` preset and granted *write* only when the
1058/// active policy already permits workspace writes (mirroring `UserTemp`); under
1059/// a read-only policy they fall back to read access so dependency resolution
1060/// still works.
1061// Cache *write* roots are only consumed by the macOS (seatbelt) and Linux
1062// (Landlock) sandbox backends; the Windows backend deliberately does not grant
1063// recursive home-scoped cache roots (see `windows.rs`). Gating to those two
1064// targets keeps `-D warnings` happy on Windows, where this would otherwise be
1065// dead code.
1066#[cfg(any(target_os = "linux", target_os = "macos"))]
1067pub(crate) fn process_sandbox_developer_toolchain_cache_roots(
1068    policy: &CapabilityPolicy,
1069) -> Vec<PathBuf> {
1070    if !process_sandbox_presets(policy).contains(&ProcessSandboxPreset::DeveloperToolchains) {
1071        return Vec::new();
1072    }
1073    let Some(home) = sandbox_user_home_dir() else {
1074        return Vec::new();
1075    };
1076    developer_toolchain_cache_write_roots_for_home(&home)
1077}
1078
1079#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1080fn sandbox_user_home_dir() -> Option<PathBuf> {
1081    // Only an absolute home grounds the user-scope read-roots below; a
1082    // relative or unset home yields no extra roots (the safe direction).
1083    crate::user_dirs::home_dir().filter(|path| path.is_absolute())
1084}
1085
1086#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1087pub(crate) fn developer_toolchain_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1088    let mut roots: Vec<_> = [
1089        ".asdf",
1090        ".bun",
1091        ".cargo",
1092        ".fnm",
1093        ".juliaup",
1094        ".local/bin",
1095        ".local/share/mise",
1096        ".local/share/uv",
1097        ".nvm",
1098        ".pyenv",
1099        ".rbenv",
1100        ".rustup",
1101        ".sdkman",
1102        ".swiftly",
1103        ".volta",
1104        "go",
1105    ]
1106    .into_iter()
1107    .map(|entry| normalize_for_policy(&home.join(entry)))
1108    .collect();
1109    #[cfg(target_os = "windows")]
1110    roots.extend(
1111        [
1112            "AppData/Local/Programs/Python",
1113            "AppData/Local/uv",
1114            "AppData/Roaming/uv",
1115            "scoop",
1116        ]
1117        .into_iter()
1118        .map(|entry| normalize_for_policy(&home.join(entry))),
1119    );
1120    roots.sort_unstable();
1121    roots.dedup();
1122    roots
1123}
1124
1125/// Per-user JVM/iOS toolchain cache roots (read+write). Kept platform-shared so
1126/// the macOS seatbelt and Linux Landlock backends render the same set; the
1127/// macOS-only `~/Library/...` entries are simply absent on Linux disk and the
1128/// `optional`/NotFound handling in each backend skips roots that do not exist.
1129#[cfg(any(target_os = "linux", target_os = "macos"))]
1130pub(crate) fn developer_toolchain_cache_write_roots_for_home(home: &Path) -> Vec<PathBuf> {
1131    let mut roots: Vec<_> = [
1132        ".gradle",                             // Gradle (JVM/Android/Kotlin)
1133        ".m2",                                 // Maven (JVM)
1134        ".konan",                              // Kotlin/Native
1135        "Library/Caches/CocoaPods",            // CocoaPods (iOS/macOS)
1136        "Library/Developer/Xcode/DerivedData", // Xcode build products
1137    ]
1138    .into_iter()
1139    .map(|entry| normalize_for_policy(&home.join(entry)))
1140    .collect();
1141    roots.sort_unstable();
1142    roots.dedup();
1143    roots
1144}
1145
1146#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1147pub(crate) fn package_manager_config_read_roots_for_home(home: &Path) -> Vec<PathBuf> {
1148    let mut roots: Vec<_> = [
1149        ".npmrc",
1150        ".gitconfig",
1151        ".netrc",
1152        ".yarnrc.yml",
1153        ".config",
1154        ".npm",
1155        ".cache",
1156        ".pip",
1157        ".pypirc",
1158        ".cargo/config",
1159        ".cargo/config.toml",
1160        ".cargo/credentials",
1161        ".cargo/credentials.toml",
1162        ".cargo/registry",
1163        ".cargo/git",
1164    ]
1165    .into_iter()
1166    .map(|entry| normalize_for_policy(&home.join(entry)))
1167    .collect();
1168    roots.sort_unstable();
1169    roots.dedup();
1170    roots
1171}
1172
1173#[cfg(any(
1174    target_os = "linux",
1175    target_os = "macos",
1176    target_os = "openbsd",
1177    target_os = "windows"
1178))]
1179fn normalized_process_roots(roots: &[String]) -> Vec<PathBuf> {
1180    roots
1181        .iter()
1182        .map(|root| normalize_for_policy(&resolve_policy_path(root)))
1183        .collect()
1184}
1185
1186fn resolve_policy_path(path: &str) -> PathBuf {
1187    let candidate = PathBuf::from(path);
1188    if candidate.is_absolute() {
1189        candidate
1190    } else {
1191        crate::stdlib::process::execution_root_path().join(candidate)
1192    }
1193}
1194
1195fn normalize_for_policy(path: &Path) -> PathBuf {
1196    let absolute = if path.is_absolute() {
1197        path.to_path_buf()
1198    } else {
1199        crate::stdlib::process::execution_root_path().join(path)
1200    };
1201    let absolute = normalize_lexically(&absolute);
1202    if let Ok(canonical) = absolute.canonicalize() {
1203        return canonical;
1204    }
1205
1206    let mut existing = absolute.as_path();
1207    let mut suffix = Vec::new();
1208    while !existing.exists() {
1209        let Some(parent) = existing.parent() else {
1210            return normalize_lexically(&absolute);
1211        };
1212        if let Some(name) = existing.file_name() {
1213            suffix.push(name.to_os_string());
1214        }
1215        existing = parent;
1216    }
1217
1218    let mut normalized = existing
1219        .canonicalize()
1220        .unwrap_or_else(|_| normalize_lexically(existing));
1221    for component in suffix.iter().rev() {
1222        normalized.push(component);
1223    }
1224    normalize_lexically(&normalized)
1225}
1226
1227fn normalize_lexically(path: &Path) -> PathBuf {
1228    let mut normalized = PathBuf::new();
1229    for component in path.components() {
1230        match component {
1231            Component::CurDir => {}
1232            Component::ParentDir => {
1233                normalized.pop();
1234            }
1235            other => normalized.push(other.as_os_str()),
1236        }
1237    }
1238    normalized
1239}
1240
1241fn path_is_within(path: &Path, root: &Path) -> bool {
1242    path == root || path.starts_with(root)
1243}
1244
1245/// Resolve `path` to an absolute, lexically-normalized form for the standard
1246/// I/O device check. Unlike [`normalize_for_policy`] this never calls
1247/// `canonicalize`, which on macOS rewrites `/dev/stdout` to a per-process
1248/// `/dev/fd/<…>.output` alias that no longer matches a known device file.
1249fn normalize_io_device_path(path: &Path) -> PathBuf {
1250    let absolute = if path.is_absolute() {
1251        path.to_path_buf()
1252    } else {
1253        crate::stdlib::process::execution_root_path().join(path)
1254    };
1255    normalize_lexically(&absolute)
1256}
1257
1258/// Whether `path` is one of the standard process I/O device files that the
1259/// sandbox treats as a stream rather than a workspace mutation for this access:
1260/// stdin is read-only, stdout/stderr/null are read/write, and delete is never a
1261/// stream operation. `path` must already be absolute and lexically normalized.
1262fn is_standard_io_device_for_access(path: &Path, access: FsAccess) -> bool {
1263    match access {
1264        FsAccess::Read => {
1265            matches!(
1266                path.to_str(),
1267                Some("/dev/stdin" | "/dev/stdout" | "/dev/stderr" | "/dev/null")
1268            ) || is_dev_fd_descriptor(path)
1269        }
1270        FsAccess::Write => {
1271            matches!(
1272                path.to_str(),
1273                Some("/dev/stdout" | "/dev/stderr" | "/dev/null")
1274            ) || is_dev_fd_descriptor(path)
1275        }
1276        FsAccess::Delete => false,
1277    }
1278}
1279
1280/// Whether `path` is exactly `/dev/fd/<N>` for a non-empty run of ASCII
1281/// digits (the numeric file-descriptor aliases for the standard streams).
1282fn is_dev_fd_descriptor(path: &Path) -> bool {
1283    let Some(text) = path.to_str() else {
1284        return false;
1285    };
1286    let Some(fd) = text.strip_prefix("/dev/fd/") else {
1287        return false;
1288    };
1289    !fd.is_empty() && fd.bytes().all(|byte| byte.is_ascii_digit())
1290}
1291
1292#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
1293pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
1294    fn rank(value: &str) -> usize {
1295        match value {
1296            "none" => 0,
1297            "read_only" => 1,
1298            "workspace_write" => 2,
1299            "process_exec" => 3,
1300            "network" => 4,
1301            _ => 5,
1302        }
1303    }
1304    policy
1305        .side_effect_level
1306        .as_ref()
1307        .map(|level| rank(level) >= rank("network"))
1308        .unwrap_or(true)
1309}
1310
1311#[cfg(any(
1312    target_os = "linux",
1313    target_os = "macos",
1314    target_os = "openbsd",
1315    target_os = "windows"
1316))]
1317pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
1318    policy.capabilities.is_empty()
1319        || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
1320}
1321
1322#[cfg(any(
1323    target_os = "linux",
1324    target_os = "macos",
1325    target_os = "openbsd",
1326    target_os = "windows"
1327))]
1328pub(crate) fn policy_allows_capability(
1329    policy: &CapabilityPolicy,
1330    capability: &str,
1331    ops: &[&str],
1332) -> bool {
1333    policy
1334        .capabilities
1335        .get(capability)
1336        .map(|allowed| {
1337            ops.iter()
1338                .any(|op| allowed.iter().any(|candidate| candidate == op))
1339        })
1340        .unwrap_or(false)
1341}
1342
1343impl FsAccess {
1344    fn verb(self) -> &'static str {
1345        match self {
1346            FsAccess::Read => "read",
1347            FsAccess::Write => "write",
1348            FsAccess::Delete => "delete",
1349        }
1350    }
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355    use super::*;
1356    use crate::orchestration::{pop_execution_policy, push_execution_policy};
1357
1358    #[test]
1359    fn missing_create_path_normalizes_against_existing_parent() {
1360        let dir = tempfile::tempdir().unwrap();
1361        let nested = dir.path().join("a/../new.txt");
1362        let normalized = normalize_for_policy(&nested);
1363        assert_eq!(
1364            normalized,
1365            normalize_for_policy(&dir.path().join("new.txt"))
1366        );
1367    }
1368
1369    #[test]
1370    fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
1371        // Serialize env mutation and clear HARN_PROJECT_ROOT so this asserts the
1372        // pure execution-root fallback (the project-root-env preference is
1373        // covered by the next test).
1374        let _env_lock = crate::runtime_paths::test_env_lock()
1375            .lock()
1376            .unwrap_or_else(|poisoned| poisoned.into_inner());
1377        std::env::remove_var("HARN_PROJECT_ROOT");
1378        let dir = tempfile::tempdir().unwrap();
1379        crate::stdlib::process::set_thread_execution_context(Some(
1380            crate::orchestration::RunExecutionRecord {
1381                cwd: Some(dir.path().to_string_lossy().into_owned()),
1382                source_dir: None,
1383                env: Default::default(),
1384                adapter: None,
1385                repo_path: None,
1386                worktree_path: None,
1387                branch: None,
1388                base_ref: None,
1389                cleanup: None,
1390            },
1391        ));
1392        push_execution_policy(CapabilityPolicy {
1393            sandbox_profile: SandboxProfile::Worktree,
1394            ..CapabilityPolicy::default()
1395        });
1396
1397        assert!(
1398            enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
1399        );
1400        let outside = tempfile::tempdir().unwrap();
1401        assert!(enforce_fs_path(
1402            "read_file",
1403            &outside.path().join("outside.txt"),
1404            FsAccess::Read
1405        )
1406        .is_err());
1407
1408        pop_execution_policy();
1409        crate::stdlib::process::set_thread_execution_context(None);
1410    }
1411
1412    /// Regression for burin-labs/burin-code#3288. When a restricted policy has
1413    /// no explicit `workspace_roots`, the write jail must follow the
1414    /// host-declared `HARN_PROJECT_ROOT` project — NOT the process/execution
1415    /// cwd. This is the eval/dispatch pattern: `burin-headless` runs from the
1416    /// repo (`execution cwd = repo`) with `--project <fixture>` + matching
1417    /// `HARN_PROJECT_ROOT`, and a dispatched sub-agent worker's writes resolve
1418    /// into the fixture. Before the fix the empty-roots fallback used the
1419    /// execution cwd (the repo), so the in-project write was rejected
1420    /// (HARN-CAP-201) and the dispatched child wrote nothing.
1421    #[test]
1422    fn empty_workspace_roots_prefer_project_root_env_over_execution_root() {
1423        let _env_lock = crate::runtime_paths::test_env_lock()
1424            .lock()
1425            .unwrap_or_else(|poisoned| poisoned.into_inner());
1426        let project = tempfile::tempdir().unwrap();
1427        let execution_cwd = tempfile::tempdir().unwrap();
1428        std::env::set_var("HARN_PROJECT_ROOT", project.path());
1429        crate::stdlib::process::set_thread_execution_context(Some(
1430            crate::orchestration::RunExecutionRecord {
1431                cwd: Some(execution_cwd.path().to_string_lossy().into_owned()),
1432                source_dir: None,
1433                env: Default::default(),
1434                adapter: None,
1435                repo_path: None,
1436                worktree_path: None,
1437                branch: None,
1438                base_ref: None,
1439                cleanup: None,
1440            },
1441        ));
1442        push_execution_policy(CapabilityPolicy {
1443            sandbox_profile: SandboxProfile::Worktree,
1444            ..CapabilityPolicy::default()
1445        });
1446
1447        // A write that resolves INTO the project is allowed even though the
1448        // process/execution cwd is elsewhere.
1449        assert!(
1450            enforce_fs_path(
1451                "write_file",
1452                &project.path().join("test/created.ts"),
1453                FsAccess::Write,
1454            )
1455            .is_ok(),
1456            "write into HARN_PROJECT_ROOT must be allowed"
1457        );
1458        // A write under the execution cwd (the repo, in the eval pattern) is NOT
1459        // the project and must still be rejected — the jail moved to the
1460        // project, it did not widen to both.
1461        assert!(
1462            enforce_fs_path(
1463                "write_file",
1464                &execution_cwd.path().join("escape.ts"),
1465                FsAccess::Write,
1466            )
1467            .is_err(),
1468            "write under the execution cwd (outside the project) must be rejected"
1469        );
1470
1471        pop_execution_policy();
1472        crate::stdlib::process::set_thread_execution_context(None);
1473        std::env::remove_var("HARN_PROJECT_ROOT");
1474    }
1475
1476    #[test]
1477    fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
1478        let dir = tempfile::tempdir().unwrap();
1479        crate::stdlib::process::set_thread_execution_context(Some(
1480            crate::orchestration::RunExecutionRecord {
1481                cwd: Some(dir.path().to_string_lossy().into_owned()),
1482                source_dir: None,
1483                env: Default::default(),
1484                adapter: None,
1485                repo_path: None,
1486                worktree_path: None,
1487                branch: None,
1488                base_ref: None,
1489                cleanup: None,
1490            },
1491        ));
1492        push_execution_policy(CapabilityPolicy {
1493            sandbox_profile: SandboxProfile::Worktree,
1494            ..CapabilityPolicy::default()
1495        });
1496
1497        assert!(enforce_process_cwd(dir.path()).is_ok());
1498        let outside = tempfile::tempdir().unwrap();
1499        assert!(enforce_process_cwd(outside.path()).is_err());
1500
1501        pop_execution_policy();
1502        crate::stdlib::process::set_thread_execution_context(None);
1503    }
1504
1505    #[test]
1506    fn sandboxed_process_config_defaults_cwd_to_current_when_allowed() {
1507        let cwd = std::env::current_dir().unwrap();
1508        let policy = CapabilityPolicy {
1509            sandbox_profile: SandboxProfile::Worktree,
1510            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1511            ..CapabilityPolicy::default()
1512        };
1513
1514        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1515
1516        assert_eq!(resolved.cwd.unwrap(), normalize_for_policy(&cwd));
1517    }
1518
1519    #[test]
1520    fn sandboxed_process_config_defaults_cwd_to_workspace_when_current_is_outside() {
1521        let workspace = tempfile::tempdir().unwrap();
1522        let policy = CapabilityPolicy {
1523            sandbox_profile: SandboxProfile::Worktree,
1524            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1525            ..CapabilityPolicy::default()
1526        };
1527
1528        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1529
1530        assert_eq!(
1531            resolved.cwd.unwrap(),
1532            normalize_for_policy(workspace.path())
1533        );
1534    }
1535
1536    #[test]
1537    fn sandboxed_process_config_rejects_explicit_cwd_outside_workspace() {
1538        let workspace = tempfile::tempdir().unwrap();
1539        let outside = tempfile::tempdir().unwrap();
1540        let policy = CapabilityPolicy {
1541            sandbox_profile: SandboxProfile::Worktree,
1542            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1543            ..CapabilityPolicy::default()
1544        };
1545        let config = ProcessCommandConfig {
1546            cwd: Some(outside.path().to_path_buf()),
1547            ..ProcessCommandConfig::default()
1548        };
1549
1550        assert!(sandboxed_process_config(&config, &policy).is_err());
1551    }
1552
1553    #[test]
1554    fn sandboxed_process_config_neutralizes_rustc_wrapper() {
1555        let cwd = std::env::current_dir().unwrap();
1556        let policy = CapabilityPolicy {
1557            sandbox_profile: SandboxProfile::Worktree,
1558            workspace_roots: vec![cwd.to_string_lossy().into_owned()],
1559            ..CapabilityPolicy::default()
1560        };
1561
1562        // A sandboxed spawn must bypass sccache so it can never spawn (and
1563        // thereby permanently confine) the shared daemon.
1564        let resolved = sandboxed_process_config(&ProcessCommandConfig::default(), &policy).unwrap();
1565        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1566        assert_eq!(env.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1567        assert_eq!(
1568            env.get("CARGO_BUILD_RUSTC_WRAPPER").map(String::as_str),
1569            Some("")
1570        );
1571    }
1572
1573    #[test]
1574    fn neutralize_rustc_wrapper_overrides_caller_supplied_wrapper() {
1575        // Even if a caller (or inherited env) asked for sccache, the sandboxed
1576        // config forces it off rather than appending a duplicate entry.
1577        let mut env = vec![
1578            ("RUSTC_WRAPPER".to_string(), "sccache".to_string()),
1579            ("PATH".to_string(), "/usr/bin".to_string()),
1580        ];
1581        neutralize_rustc_wrapper(&mut env);
1582        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1583        assert_eq!(collected.get("RUSTC_WRAPPER").map(String::as_str), Some(""));
1584        assert_eq!(
1585            collected
1586                .get("CARGO_BUILD_RUSTC_WRAPPER")
1587                .map(String::as_str),
1588            Some("")
1589        );
1590        assert_eq!(collected.get("PATH").map(String::as_str), Some("/usr/bin"));
1591        // No duplicate RUSTC_WRAPPER entries.
1592        assert_eq!(env.iter().filter(|(k, _)| k == "RUSTC_WRAPPER").count(), 1);
1593    }
1594
1595    #[test]
1596    fn workspace_local_tmpdir_lands_inside_the_first_writable_root() {
1597        let workspace = tempfile::tempdir().unwrap();
1598        let policy = CapabilityPolicy {
1599            sandbox_profile: SandboxProfile::Worktree,
1600            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1601            ..CapabilityPolicy::default()
1602        };
1603
1604        let tmpdir = workspace_local_tmpdir(&policy).expect("a writable root yields a temp dir");
1605
1606        // The temp dir is created, lives under the writable workspace root, and
1607        // is named by the documented convention.
1608        assert!(tmpdir.is_dir(), "temp dir must be created: {tmpdir:?}");
1609        assert!(
1610            path_is_within(&tmpdir, &normalize_for_policy(workspace.path())),
1611            "temp dir {tmpdir:?} must be inside the writable workspace root"
1612        );
1613        assert!(tmpdir.ends_with(WORKSPACE_TMPDIR_NAME));
1614        // It self-ignores so its churn never shows in a git diff.
1615        let ignore = std::fs::read_to_string(tmpdir.join(".gitignore")).unwrap_or_default();
1616        assert!(
1617            ignore.lines().any(|line| line.trim() == "*"),
1618            "temp dir must carry a self-ignoring .gitignore, got {ignore:?}"
1619        );
1620        // It is within the sandbox's writable scope: a write under it passes the
1621        // same path-scope check the OS sandbox enforces.
1622        push_execution_policy(policy);
1623        assert!(
1624            check_fs_path_scope(&tmpdir.join("rustcXXXX/intermediate.o"), FsAccess::Write).is_ok(),
1625            "writes under the workspace-local temp dir must be in sandbox scope"
1626        );
1627        pop_execution_policy();
1628    }
1629
1630    #[test]
1631    fn inject_workspace_tmpdir_is_a_noop_under_unrestricted_profile() {
1632        // The unrestricted profile short-circuits the injection helper: an
1633        // unsandboxed child keeps whatever TMPDIR it would otherwise inherit.
1634        let policy = CapabilityPolicy {
1635            sandbox_profile: SandboxProfile::Unrestricted,
1636            workspace_roots: vec!["/definitely/not/writable/xyzzy".to_string()],
1637            ..CapabilityPolicy::default()
1638        };
1639        let mut env = Vec::new();
1640        inject_workspace_tmpdir(&mut env, &policy);
1641        assert!(
1642            env.is_empty(),
1643            "unrestricted profile must not inject a TMPDIR override, got {env:?}"
1644        );
1645    }
1646
1647    #[test]
1648    fn inject_workspace_tmpdir_sets_all_three_keys_inside_workspace() {
1649        let workspace = tempfile::tempdir().unwrap();
1650        let policy = CapabilityPolicy {
1651            sandbox_profile: SandboxProfile::Worktree,
1652            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1653            ..CapabilityPolicy::default()
1654        };
1655        let mut env = Vec::new();
1656        inject_workspace_tmpdir(&mut env, &policy);
1657
1658        let collected: std::collections::BTreeMap<_, _> = env.into_iter().collect();
1659        let expected = workspace_local_tmpdir(&policy)
1660            .unwrap()
1661            .display()
1662            .to_string();
1663        for key in TMPDIR_ENV_KEYS {
1664            assert_eq!(
1665                collected.get(key).map(String::as_str),
1666                Some(expected.as_str()),
1667                "{key} must point at the workspace-local temp dir"
1668            );
1669        }
1670    }
1671
1672    #[test]
1673    fn deterministic_message_locale_env_forces_english_utf8_safe_messages() {
1674        let env: std::collections::BTreeMap<_, _> =
1675            deterministic_message_locale_env().into_iter().collect();
1676        // gettext tools (gcc/clang, git-l10n, coreutils, gradle) honor
1677        // LC_MESSAGES; `C` yields untranslated English.
1678        assert_eq!(env.get("LC_MESSAGES").map(String::as_str), Some("C"));
1679        // .NET ignores LC_* and localizes from its own variable.
1680        assert_eq!(
1681            env.get("DOTNET_CLI_UI_LANGUAGE").map(String::as_str),
1682            Some("en")
1683        );
1684        // Deliberately NOT setting LC_ALL/LC_CTYPE/LANG so UTF-8 handling of
1685        // non-ASCII source and identifiers is preserved (unlike `LC_ALL=C`).
1686        assert!(
1687            !env.contains_key("LC_ALL"),
1688            "must not force LC_ALL (would clobber UTF-8 ctype)"
1689        );
1690        assert!(!env.contains_key("LC_CTYPE"));
1691        assert!(!env.contains_key("LANG"));
1692        // The override-strip constant names the one variable that would defeat
1693        // LC_MESSAGES if inherited.
1694        assert_eq!(MESSAGE_LOCALE_OVERRIDE_ENV, "LC_ALL");
1695    }
1696
1697    #[test]
1698    fn inject_workspace_tmpdir_respects_a_caller_pinned_tmpdir() {
1699        let workspace = tempfile::tempdir().unwrap();
1700        let policy = CapabilityPolicy {
1701            sandbox_profile: SandboxProfile::Worktree,
1702            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1703            ..CapabilityPolicy::default()
1704        };
1705        // Caller already pinned TMPDIR; only the untouched siblings get filled.
1706        let mut env = vec![("TMPDIR".to_string(), "/caller/explicit/tmp".to_string())];
1707        inject_workspace_tmpdir(&mut env, &policy);
1708
1709        let collected: std::collections::BTreeMap<_, _> = env.iter().cloned().collect();
1710        assert_eq!(
1711            collected.get("TMPDIR").map(String::as_str),
1712            Some("/caller/explicit/tmp"),
1713            "an explicit caller TMPDIR must be preserved untouched"
1714        );
1715        let expected = workspace_local_tmpdir(&policy)
1716            .unwrap()
1717            .display()
1718            .to_string();
1719        assert_eq!(
1720            collected.get("TMP").map(String::as_str),
1721            Some(expected.as_str())
1722        );
1723        assert_eq!(
1724            collected.get("TEMP").map(String::as_str),
1725            Some(expected.as_str())
1726        );
1727        // And no duplicate TMPDIR entry was appended.
1728        assert_eq!(env.iter().filter(|(k, _)| k == "TMPDIR").count(), 1);
1729    }
1730
1731    #[test]
1732    fn sandboxed_process_config_injects_workspace_tmpdir() {
1733        let workspace = tempfile::tempdir().unwrap();
1734        let policy = CapabilityPolicy {
1735            sandbox_profile: SandboxProfile::Worktree,
1736            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1737            ..CapabilityPolicy::default()
1738        };
1739        let config = ProcessCommandConfig {
1740            cwd: Some(workspace.path().to_path_buf()),
1741            ..ProcessCommandConfig::default()
1742        };
1743        let resolved = sandboxed_process_config(&config, &policy).unwrap();
1744        let env: std::collections::BTreeMap<_, _> = resolved.env.into_iter().collect();
1745        let expected = workspace_local_tmpdir(&policy)
1746            .unwrap()
1747            .display()
1748            .to_string();
1749        assert_eq!(
1750            env.get("TMPDIR").map(String::as_str),
1751            Some(expected.as_str()),
1752            "the command_output path must inject a workspace-local TMPDIR"
1753        );
1754    }
1755
1756    #[test]
1757    fn read_only_root_outside_workspace_allows_read_denies_write() {
1758        // Models an embedder (burin's in-process TUI) that grants a
1759        // read-only root R holding bundled pipelines/partials outside the
1760        // user's writable workspace. A read under R passes; a write under R
1761        // is denied; a read outside both R and the workspace is denied.
1762        let workspace = tempfile::tempdir().unwrap();
1763        let read_only = tempfile::tempdir().unwrap();
1764        push_execution_policy(CapabilityPolicy {
1765            sandbox_profile: SandboxProfile::Worktree,
1766            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1767            read_only_roots: vec![read_only.path().to_string_lossy().into_owned()],
1768            ..CapabilityPolicy::default()
1769        });
1770
1771        let asset = read_only
1772            .path()
1773            .join("partials/agent-web-tools.harn.prompt");
1774        // READ under the read-only root is allowed.
1775        assert!(
1776            check_fs_path_scope(&asset, FsAccess::Read).is_ok(),
1777            "read under a configured read-only root must be allowed"
1778        );
1779
1780        // WRITE under the read-only root is denied, flagged read_only.
1781        let write_err = check_fs_path_scope(&asset, FsAccess::Write)
1782            .expect_err("write under a read-only root must be denied");
1783        assert!(write_err.read_only, "write rejection must set read_only");
1784
1785        // DELETE under the read-only root is likewise denied.
1786        assert!(
1787            check_fs_path_scope(&asset, FsAccess::Delete).is_err(),
1788            "delete under a read-only root must be denied"
1789        );
1790
1791        // A read inside the writable workspace still passes.
1792        assert!(check_fs_path_scope(&workspace.path().join("src/main.rs"), FsAccess::Read).is_ok());
1793
1794        // A read outside BOTH the workspace and the read-only root is denied
1795        // and is NOT flagged read_only (it fell outside every root).
1796        let stranger = tempfile::tempdir().unwrap();
1797        let outside_err = check_fs_path_scope(&stranger.path().join("secret.txt"), FsAccess::Read)
1798            .expect_err("read outside all roots must be denied");
1799        assert!(
1800            !outside_err.read_only,
1801            "out-of-scope rejection must not be flagged read_only"
1802        );
1803
1804        pop_execution_policy();
1805    }
1806
1807    #[cfg(unix)]
1808    #[test]
1809    fn standard_io_device_files_allowed_under_restricted_profile() {
1810        // Writing to the standard process I/O streams is not a workspace
1811        // mutation, so a restricted profile with a workspace root that does
1812        // not contain /dev must still allow them — while a genuine
1813        // out-of-root write is still rejected.
1814        let workspace = tempfile::tempdir().unwrap();
1815        push_execution_policy(CapabilityPolicy {
1816            sandbox_profile: SandboxProfile::Worktree,
1817            workspace_roots: vec![workspace.path().to_string_lossy().into_owned()],
1818            ..CapabilityPolicy::default()
1819        });
1820
1821        for device in ["/dev/stdout", "/dev/stderr", "/dev/null"] {
1822            assert!(
1823                check_fs_path_scope(Path::new(device), FsAccess::Write).is_ok(),
1824                "write to standard device {device} must be allowed"
1825            );
1826            // Reads of the same devices are likewise allowed.
1827            assert!(
1828                check_fs_path_scope(Path::new(device), FsAccess::Read).is_ok(),
1829                "read of standard device {device} must be allowed"
1830            );
1831        }
1832        assert!(
1833            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Read).is_ok(),
1834            "read of standard device /dev/stdin must be allowed"
1835        );
1836        assert!(
1837            check_fs_path_scope(Path::new("/dev/stdin"), FsAccess::Write).is_err(),
1838            "write to /dev/stdin is not a standard output stream"
1839        );
1840        assert!(
1841            check_fs_path_scope(Path::new("/dev/null"), FsAccess::Delete).is_err(),
1842            "standard devices must not bypass delete scoping"
1843        );
1844        // Numeric /dev/fd/<N> descriptors are allowed.
1845        assert!(check_fs_path_scope(Path::new("/dev/fd/1"), FsAccess::Write).is_ok());
1846        assert!(check_fs_path_scope(Path::new("/dev/fd/2"), FsAccess::Write).is_ok());
1847
1848        // A non-device path outside the workspace is still rejected.
1849        let stranger = tempfile::tempdir().unwrap();
1850        assert!(
1851            check_fs_path_scope(&stranger.path().join("escape.txt"), FsAccess::Write).is_err(),
1852            "a real out-of-root write must still be rejected"
1853        );
1854        // Other /dev entries are NOT broadly allowed — the allowlist is narrow.
1855        assert!(
1856            check_fs_path_scope(Path::new("/dev/sda"), FsAccess::Write).is_err(),
1857            "/dev/sda must not be allowed by the standard-device allowlist"
1858        );
1859        assert!(
1860            check_fs_path_scope(Path::new("/dev/fd/notanumber"), FsAccess::Write).is_err(),
1861            "non-numeric /dev/fd/<x> must not be allowed"
1862        );
1863
1864        pop_execution_policy();
1865    }
1866
1867    #[test]
1868    fn is_standard_io_device_matches_only_known_streams() {
1869        assert!(is_standard_io_device_for_access(
1870            Path::new("/dev/stdin"),
1871            FsAccess::Read
1872        ));
1873        assert!(!is_standard_io_device_for_access(
1874            Path::new("/dev/stdin"),
1875            FsAccess::Write
1876        ));
1877        assert!(is_standard_io_device_for_access(
1878            Path::new("/dev/stdout"),
1879            FsAccess::Write
1880        ));
1881        assert!(is_standard_io_device_for_access(
1882            Path::new("/dev/stderr"),
1883            FsAccess::Write
1884        ));
1885        assert!(is_standard_io_device_for_access(
1886            Path::new("/dev/null"),
1887            FsAccess::Write
1888        ));
1889        assert!(is_standard_io_device_for_access(
1890            Path::new("/dev/fd/0"),
1891            FsAccess::Read
1892        ));
1893        assert!(is_standard_io_device_for_access(
1894            Path::new("/dev/fd/12"),
1895            FsAccess::Write
1896        ));
1897        assert!(!is_standard_io_device_for_access(
1898            Path::new("/dev/null"),
1899            FsAccess::Delete
1900        ));
1901        assert!(!is_standard_io_device_for_access(
1902            Path::new("/dev/fd/"),
1903            FsAccess::Write
1904        ));
1905        assert!(!is_standard_io_device_for_access(
1906            Path::new("/dev/fd/1a"),
1907            FsAccess::Write
1908        ));
1909        assert!(!is_standard_io_device_for_access(
1910            Path::new("/dev/stdoutx"),
1911            FsAccess::Write
1912        ));
1913        assert!(!is_standard_io_device_for_access(
1914            Path::new("/dev/random"),
1915            FsAccess::Read
1916        ));
1917        assert!(!is_standard_io_device_for_access(
1918            Path::new("/tmp/dev/null"),
1919            FsAccess::Write
1920        ));
1921    }
1922
1923    #[test]
1924    fn path_within_root_accepts_root_and_children() {
1925        let root = Path::new("/tmp/harn-root");
1926        assert!(path_is_within(root, root));
1927        assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
1928        assert!(!path_is_within(
1929            Path::new("/tmp/harn-root-other/file"),
1930            root
1931        ));
1932    }
1933
1934    #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
1935    #[test]
1936    fn developer_toolchain_roots_cover_common_home_managed_runtimes() {
1937        let temp_home = tempfile::tempdir().expect("temp home");
1938        let roots = developer_toolchain_read_roots_for_home(temp_home.path());
1939        let normalized_home = normalize_for_policy(temp_home.path());
1940
1941        for suffix in [
1942            Path::new(".cargo"),
1943            Path::new(".rustup"),
1944            Path::new(".pyenv"),
1945            Path::new(".nvm"),
1946            Path::new(".volta"),
1947            Path::new(".local/share/uv"),
1948            Path::new("go"),
1949        ] {
1950            assert!(
1951                roots.iter().any(|path| path.ends_with(suffix)),
1952                "expected a developer-toolchain grant for {}",
1953                suffix.display()
1954            );
1955        }
1956        assert!(
1957            roots.iter().all(|path| path.starts_with(&normalized_home)),
1958            "developer-toolchain roots must stay under HOME"
1959        );
1960    }
1961
1962    #[cfg(any(target_os = "linux", target_os = "macos"))]
1963    #[test]
1964    fn developer_toolchain_cache_roots_cover_jvm_and_ios_toolchains() {
1965        let temp_home = tempfile::tempdir().expect("temp home");
1966        let roots = developer_toolchain_cache_write_roots_for_home(temp_home.path());
1967        let normalized_home = normalize_for_policy(temp_home.path());
1968
1969        for suffix in [
1970            Path::new(".gradle"),
1971            Path::new(".m2"),
1972            Path::new(".konan"),
1973            Path::new("Library/Caches/CocoaPods"),
1974            Path::new("Library/Developer/Xcode/DerivedData"),
1975        ] {
1976            assert!(
1977                roots.iter().any(|path| path.ends_with(suffix)),
1978                "expected a JVM/iOS toolchain cache grant for {}",
1979                suffix.display()
1980            );
1981        }
1982        assert!(
1983            roots.iter().all(|path| path.starts_with(&normalized_home)),
1984            "toolchain cache roots must stay under HOME"
1985        );
1986    }
1987
1988    #[cfg(any(target_os = "linux", target_os = "macos"))]
1989    #[test]
1990    fn developer_toolchain_cache_roots_require_developer_toolchains_preset() {
1991        let mut policy = CapabilityPolicy {
1992            workspace_roots: vec!["/tmp/harn-workspace".to_string()],
1993            ..CapabilityPolicy::default()
1994        };
1995        // Default presets include DeveloperToolchains -> cache roots present
1996        // (only when an absolute HOME is resolvable on this host).
1997        if sandbox_user_home_dir().is_some() {
1998            assert!(
1999                !process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2000                "default presets should render JVM/iOS cache roots"
2001            );
2002        }
2003        // Explicitly dropping DeveloperToolchains removes them.
2004        policy.process_sandbox.presets = Some(vec![ProcessSandboxPreset::SystemRuntime]);
2005        assert!(
2006            process_sandbox_developer_toolchain_cache_roots(&policy).is_empty(),
2007            "cache roots must be gated on the DeveloperToolchains preset"
2008        );
2009    }
2010
2011    #[test]
2012    fn os_hardened_profile_overrides_fallback_env() {
2013        // `OsHardened` ignores `HARN_HANDLER_SANDBOX=off` — the whole
2014        // point of the profile is that the OS sandbox is required.
2015        // We cannot mutate the env here without races, so just check
2016        // the pure resolution function.
2017        assert_eq!(
2018            effective_fallback(SandboxProfile::OsHardened),
2019            SandboxFallback::Enforce
2020        );
2021    }
2022
2023    #[test]
2024    fn unrestricted_profile_skips_active_sandbox() {
2025        let policy = CapabilityPolicy {
2026            sandbox_profile: SandboxProfile::Unrestricted,
2027            workspace_roots: vec!["/tmp".to_string()],
2028            ..Default::default()
2029        };
2030        crate::orchestration::push_execution_policy(policy);
2031        let result = active_sandbox_policy();
2032        crate::orchestration::pop_execution_policy();
2033        assert!(
2034            result.is_none(),
2035            "Unrestricted profile must short-circuit sandbox dispatch"
2036        );
2037    }
2038
2039    #[test]
2040    fn worktree_profile_engages_active_sandbox() {
2041        let policy = CapabilityPolicy {
2042            sandbox_profile: SandboxProfile::Worktree,
2043            workspace_roots: vec!["/tmp".to_string()],
2044            ..Default::default()
2045        };
2046        crate::orchestration::push_execution_policy(policy);
2047        let result = active_sandbox_policy();
2048        crate::orchestration::pop_execution_policy();
2049        assert!(
2050            result.is_some(),
2051            "Worktree profile must keep sandbox dispatch active"
2052        );
2053    }
2054}