Skip to main content

io_harness/
sandbox.rs

1//! The execution sandbox: model-produced code runs isolated, per run.
2//!
3//! Since 0.2.0 the verification gate has compiled and run model-produced code
4//! directly on the host — the "compiles locally, no isolation" limitation, made
5//! sharper by 0.5.0's many concurrent agents. 0.6.0 routes every such execution
6//! through a [`Sandbox`]: an ephemeral working directory, resource caps that
7//! *kill* rather than throttle, network denied by default, and guaranteed
8//! teardown so nothing the run wrote or spawned outlives it.
9//!
10//! The sandbox is both **OS-native** and **OS-neutral**. One trait,
11//! [`Sandbox`], has a native backend per platform — macOS `sandbox-exec` and
12//! Linux namespaces; Windows is still the floor (its Job Object is
13//! unimplemented) — over a [portable floor](FloorSandbox) (fresh subprocess,
14//! ephemeral tempdir, resource caps, network env stripped) that compiles and runs
15//! on all three, so isolation is never *absent* on any OS the crate builds for.
16//! [`select`] picks the strongest backend this host can actually deliver — the
17//! candidate by cfg, degraded to the floor if its primitive turns out to be
18//! unavailable — and the one that ran is recorded.
19//!
20//! ## Backend isolation strength (documented, not hidden)
21//!
22//! - **macOS `sandbox-exec`** — a generated profile confines filesystem writes
23//!   to the workdir and denies network; `setrlimit` caps CPU time and open file
24//!   descriptors; memory is capped by an RSS monitor (macOS does not enforce
25//!   `RLIMIT_AS`/`RLIMIT_DATA`). It does **not** cap the process count — see
26//!   [`SandboxLimits::max_processes`], which no backend enforces today.
27//! - **Linux namespaces** — user + mount + pid + net namespaces give a hard
28//!   network boundary and a private tmpfs; rlimits on top. The crate installs
29//!   **no seccomp filter of its own**; what syscall filtering there is comes
30//!   from whatever the kernel applies by default inside an unprivileged user
31//!   namespace. Probed at runtime: a kernel that restricts unprivileged user
32//!   namespaces gets the portable floor, reported as such. *(cfg-gated, not
33//!   live-run on the macOS build host.)*
34//! - **Windows** — *no native backend yet.* The Job Object was designed but
35//!   never implemented (no Win32 call is made), so a Windows run gets the
36//!   portable floor and reports it as such. On Windows that floor enforces the
37//!   **wall clock only**: no CPU cap, no memory cap, and no process cap — all
38//!   three are unix `rlimit`/`ps` mechanisms with no Windows equivalent until the
39//!   Job Object lands — and no kernel network boundary either, only the
40//!   best-effort proxy-env strip. What it does have is an ephemeral workdir and a
41//!   wall-clock kill that reaches the whole process tree. A cap that is not
42//!   applied is never reported: [`Cap::Cpu`] and [`Cap::Memory`] are never
43//!   claimed on Windows. See [`windows`].
44//! - **Portable floor** — the weakest backend: filesystem-scoped (a fresh
45//!   ephemeral workdir) and resource-capped, **not a full syscall jail**. Network
46//!   deny is best-effort (proxy env stripped), *not* a kernel boundary. It exists
47//!   so no OS ever runs code with no sandbox at all.
48//!
49//! A configurable network egress *allow-list* is out of scope for 0.6.0 (network
50//! is deny-by-default only); it lands in 0.8.0 with MCP/plugins.
51
52use std::path::{Path, PathBuf};
53
54use serde::{Deserialize, Serialize};
55
56use crate::error::Result;
57
58/// Which backend actually ran a sandboxed command. Recorded in the trace so an
59/// operator can audit not just *what* ran but *how* it was isolated.
60///
61/// The value is a *report*, never a promise: a native backend whose primitive
62/// turns out to be unavailable degrades to the floor and says `PortableFloor`
63/// here rather than naming an isolation it did not apply. So an application
64/// deciding how much to trust a run reads this, instead of inferring isolation
65/// from the OS it happens to be running on.
66///
67/// ```
68/// use io_harness::sandbox::{select, Backend, Sandbox, SandboxConfig};
69///
70/// let backend = select(&SandboxConfig::new()).backend();
71/// if backend == Backend::PortableFloor {
72///     // Filesystem-scoped and resource-capped, but not a syscall jail, and
73///     // network deny is only a proxy-env strip. Refuse genuinely untrusted
74///     // work here rather than running it believing it is confined.
75///     eprintln!("no kernel isolation on this host: {}", backend.as_str());
76/// }
77/// ```
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79pub enum Backend {
80    /// macOS `sandbox-exec` profile + rlimits + RSS monitor.
81    MacosSandboxExec,
82    /// Linux user/mount/pid/net namespaces + rlimits. The crate installs no
83    /// seccomp filter; only the kernel's own defaults for an unprivileged user
84    /// namespace apply on top.
85    LinuxNamespaces,
86    /// Windows Job Object + restricted token. **Reserved, never reported** —
87    /// the Job Object is not implemented, so Windows runs report
88    /// [`Backend::PortableFloor`]. Kept so the variant is here when it is.
89    WindowsJobObject,
90    /// The portable floor: subprocess + ephemeral workdir + caps + env strip.
91    PortableFloor,
92}
93
94impl Backend {
95    /// A stable label for the trace and logs.
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            Backend::MacosSandboxExec => "macos-sandbox-exec",
99            Backend::LinuxNamespaces => "linux-namespaces",
100            Backend::WindowsJobObject => "windows-job-object",
101            Backend::PortableFloor => "portable-floor",
102        }
103    }
104}
105
106/// A resource cap that was breached, killing the sandboxed process. Returned in
107/// [`SandboxOutcome::cap_hit`] so a cap hit is a *typed* result, never a hang.
108///
109/// Worth matching on rather than folding into "it failed": a process killed by
110/// a cap has no exit code at all, so a caller reading only
111/// [`exit_code`](SandboxOutcome::exit_code) sees `None` and loses the one fact
112/// that says whether to raise a limit or fix the code.
113///
114/// ```
115/// use io_harness::sandbox::{Cap, SandboxOutcome};
116///
117/// fn why(outcome: &SandboxOutcome) -> String {
118///     match outcome.cap_hit {
119///         Some(Cap::Wall) => "hung: outlived max_wall_secs".into(),
120///         Some(Cap::Cpu) => "spun: burned max_cpu_secs of CPU and took SIGXCPU".into(),
121///         Some(Cap::Memory) => "grew past max_memory_bytes and the RSS monitor killed it".into(),
122///         // No cap fired, so the exit code is the whole story.
123///         None => format!("exited {:?}", outcome.exit_code),
124///     }
125/// }
126///
127/// // A cap is also what goes in the trace, by this stable label.
128/// assert_eq!(Cap::Wall.as_str(), "wall");
129/// # let _ = why;
130/// ```
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132pub enum Cap {
133    /// CPU time (`RLIMIT_CPU` on unix; the process took SIGXCPU).
134    Cpu,
135    /// Resident memory (an RSS monitor killed it).
136    Memory,
137    /// Wall-clock time (the run outlived `max_wall_secs`).
138    Wall,
139}
140
141impl Cap {
142    /// A stable label for the trace and error messages.
143    pub fn as_str(&self) -> &'static str {
144        match self {
145            Cap::Cpu => "cpu",
146            Cap::Memory => "memory",
147            Cap::Wall => "wall",
148        }
149    }
150}
151
152/// Resource caps applied to a sandboxed run. Serde-serializable like
153/// [`crate::Policy`] and [`crate::Containment`] so io-cli and io-studio load it
154/// from config rather than hand-building it.
155///
156/// Defaults are sized so an ordinary `rustc`/`cargo` verification passes out of
157/// the box — a default that failed real compiles would push callers to disable
158/// the sandbox entirely. Tighten via the fields for untrusted work.
159///
160/// These caps **kill**; they do not throttle. A breach terminates the process
161/// and comes back as [`SandboxOutcome::cap_hit`], so a runaway is a typed
162/// result the gate can report rather than a verification that never returns.
163///
164/// ```
165/// use io_harness::sandbox::{SandboxConfig, SandboxLimits};
166///
167/// // Tighter than the defaults, for code you did not write. Thirty wall-
168/// // seconds is enough for a small `rustc` invocation and not enough for an
169/// // infinite loop to be interesting; the CPU cap catches a spin that the
170/// // wall clock would let idle-wait past.
171/// let config = SandboxConfig {
172///     limits: SandboxLimits {
173///         max_cpu_secs: Some(5),
174///         max_wall_secs: Some(30),
175///         max_memory_bytes: Some(256 * 1024 * 1024),
176///         max_open_files: Some(64),
177///         // Left as-is: no backend enforces it yet, so setting it would
178///         // buy a false sense of a process-count bound.
179///         ..SandboxLimits::default()
180///     },
181///     ..SandboxConfig::new()
182/// };
183///
184/// // Only the wall cap is enforced on every platform. On Windows it is the
185/// // *only* one, so leaving it `None` there means the run is bounded by
186/// // nothing at all.
187/// assert!(config.limits.max_wall_secs.is_some());
188/// ```
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct SandboxLimits {
191    /// Max CPU seconds before SIGXCPU. `None` = no CPU cap. **Unix only** —
192    /// `RLIMIT_CPU` has no Windows equivalent, so this is not applied there and
193    /// [`Cap::Cpu`] is never reported.
194    pub max_cpu_secs: Option<u64>,
195    /// Max wall-clock seconds before the run is killed. `None` = no wall cap.
196    /// The one cap enforced on **every** platform, and on Windows the only one —
197    /// leaving it `None` there means the run is bounded by nothing.
198    pub max_wall_secs: Option<u64>,
199    /// Max resident bytes before the RSS monitor kills the run. `None` = no cap.
200    /// **Unix only** — the monitor reads the process table with `ps`, which does
201    /// not exist on Windows, so this is not applied there and [`Cap::Memory`] is
202    /// never reported.
203    pub max_memory_bytes: Option<u64>,
204    /// Max concurrent processes in the sandbox. **Enforced by no backend
205    /// today, on any platform** — setting it changes nothing.
206    ///
207    /// The portable floor and the unix native backends deliberately do not map
208    /// it to `RLIMIT_NPROC`: that limit is per-real-uid, not per-sandbox, so
209    /// capping it there would throttle the operator's whole login session
210    /// rather than the sandboxed run. The two mechanisms that *can* scope it —
211    /// the Linux pid namespace's process limit and the Windows Job Object's
212    /// active-process limit — are not wired up (the Job Object is not
213    /// implemented at all; see [`windows`]).
214    ///
215    /// The field is kept because it is the shape the native implementations
216    /// will use, and because removing it would break every serialized config
217    /// carrying it. Treat it as reserved, not as a bound you have. `None` = no
218    /// cap, which is also what any other value means right now.
219    pub max_processes: Option<u64>,
220    /// Max open file descriptors (`RLIMIT_NOFILE`, unix). `None` = no cap.
221    pub max_open_files: Option<u64>,
222}
223
224impl Default for SandboxLimits {
225    fn default() -> Self {
226        Self {
227            max_cpu_secs: Some(60),
228            max_wall_secs: Some(120),
229            max_memory_bytes: Some(2 * 1024 * 1024 * 1024), // 2 GiB
230            // Not enforced by the floor (RLIMIT_NPROC is per-uid, not per-sandbox);
231            // the native pid-namespace / Job-Object backends scope it properly.
232            max_processes: None,
233            max_open_files: Some(512),
234        }
235    }
236}
237
238/// How the sandbox is configured for a run.
239///
240/// The *absence* of a `SandboxConfig` on the exec path means opt out: the
241/// verification gate runs on the host exactly as it did in 0.5.0. Its presence
242/// turns isolation on. This is what makes 0.6.0 additive and reversible.
243///
244/// ```
245/// use io_harness::sandbox::{select, Backend, Sandbox, SandboxConfig};
246///
247/// // The recommended default: caps that kill, egress denied, and the
248/// // strongest backend this host can actually deliver.
249/// let config = SandboxConfig::new();
250/// assert!(!config.allow_network, "network is denied by default, not allowed");
251///
252/// // `floor_only` pins every platform to the same weakest backend. Useful for
253/// // reproducing a report from a host whose native primitive was unavailable,
254/// // and for exercising the floor on a machine that would otherwise never
255/// // take it.
256/// let floor = SandboxConfig::new().floor_only();
257/// assert_eq!(select(&floor).backend(), Backend::PortableFloor);
258/// ```
259///
260/// It derives `Serialize`/`Deserialize` for the same reason [`crate::Policy`]
261/// does: io-cli and io-studio load one from a config file rather than
262/// hand-building it. Each of its own three fields is `#[serde(default)]`, so a
263/// config file may name only what it changes — note that `limits`, if given at
264/// all, is a whole [`SandboxLimits`] and every cap in it must be spelled out.
265///
266/// ```
267/// use io_harness::sandbox::{SandboxConfig, SandboxLimits};
268///
269/// let config: SandboxConfig = serde_json::from_str(r#"{"allow_network": true}"#).unwrap();
270/// assert!(config.allow_network);
271/// assert_eq!(config.limits, SandboxLimits::default(), "the caps fall back whole");
272/// ```
273#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
274pub struct SandboxConfig {
275    /// Resource caps for the run.
276    #[serde(default)]
277    pub limits: SandboxLimits,
278    /// Allow outbound network. Default `false` — network is denied by default.
279    #[serde(default)]
280    pub allow_network: bool,
281    /// Disable the native backend and force the portable floor. Off by default;
282    /// used to prove the selection ladder and to run the floor everywhere.
283    #[serde(default)]
284    pub force_floor: bool,
285}
286
287impl SandboxConfig {
288    /// A config with default caps and network denied — the recommended default.
289    pub fn new() -> Self {
290        Self::default()
291    }
292
293    /// Force the portable floor backend (disable the native one).
294    pub fn floor_only(mut self) -> Self {
295        self.force_floor = true;
296        self
297    }
298}
299
300/// One command to run in the sandbox. OS-neutral by construction — no
301/// OS-specific type appears here, so the [`Sandbox`] trait signature is portable.
302pub struct RunSpec<'a> {
303    /// The command and its arguments. `argv[0]` is the program.
304    pub argv: &'a [String],
305    /// The isolated working directory the command runs in.
306    pub workdir: &'a Path,
307    /// Resource caps for this run.
308    pub limits: &'a SandboxLimits,
309    /// Whether outbound network is permitted (default-deny lives in the caller).
310    pub allow_network: bool,
311}
312
313/// The result of a sandboxed run — enough to make a verification pass/fail
314/// decision identical to the un-sandboxed path, plus the isolation metadata.
315///
316/// ```
317/// use io_harness::sandbox::SandboxOutcome;
318///
319/// /// Turn one sandboxed command into something a person can act on.
320/// fn report(outcome: &SandboxOutcome) -> String {
321///     // `success()` is the gate's whole question: a zero exit *and* no cap.
322///     // Testing `exit_code == Some(0)` alone reads a capped run — which has
323///     // no exit code — as merely "not zero", losing the reason.
324///     if outcome.success() {
325///         return format!("passed, isolated by {}", outcome.backend.as_str());
326///     }
327///     match outcome.cap_hit {
328///         Some(cap) => format!("killed by the {} cap", cap.as_str()),
329///         // Compiler and test failures land in stderr; it is what the model
330///         // is shown so it can fix the code on the next step.
331///         None => format!("exited {:?}:\n{}", outcome.exit_code, outcome.stderr),
332///     }
333/// }
334/// # let _ = report;
335/// ```
336///
337/// `argv` and `backend` are recorded in the trace, so an audit answers both
338/// what ran and how it was confined — including when the backend that answered
339/// was weaker than the one this platform advertises.
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct SandboxOutcome {
342    /// Which backend ran the command.
343    pub backend: Backend,
344    /// The exact argv that ran (recorded in the trace).
345    pub argv: Vec<String>,
346    /// The process exit code, or `None` when killed by a signal or a cap.
347    pub exit_code: Option<i32>,
348    /// The cap that killed the run, if any.
349    pub cap_hit: Option<Cap>,
350    /// Captured stdout.
351    pub stdout: String,
352    /// Captured stderr.
353    pub stderr: String,
354}
355
356impl SandboxOutcome {
357    /// The command ran to completion with a zero exit code and hit no cap.
358    pub fn success(&self) -> bool {
359        self.cap_hit.is_none() && self.exit_code == Some(0)
360    }
361}
362
363/// The one execution abstraction. Every external command the harness runs —
364/// the execution-based verification gate, and any command an agent runs as a
365/// tool — goes through a `Sandbox`, so there is exactly one place model-produced
366/// code leaves the harness.
367///
368/// The signature is OS-neutral: no OS-specific type appears, so the same trait
369/// is the public surface on mac, linux, and windows. Implemented by
370/// [`FloorSandbox`] and each native backend. Mirrors [`crate::Provider`]'s
371/// async style (RPITIT, no `async-trait` dependency).
372///
373/// Reach for it directly when the embedding program wants to run something
374/// under the same isolation the verification gate gets — a build, a linter, a
375/// script the agent produced — rather than shelling out beside the harness.
376///
377/// ```
378/// use io_harness::sandbox::{select, workdir, RunSpec, Sandbox, SandboxConfig};
379///
380/// # async fn demo() -> io_harness::Result<()> {
381/// let config = SandboxConfig::new();
382/// let sandbox = select(&config);
383///
384/// // An ephemeral workdir whose teardown is its drop: the directory and
385/// // everything the command wrote in it are gone when `dir` goes out of
386/// // scope, on every exit path including a panic or an early `?`.
387/// let dir = workdir()?;
388/// std::fs::write(dir.path().join("main.rs"), "fn main() {}")?;
389///
390/// let argv = vec!["rustc".to_string(), "main.rs".to_string()];
391/// let outcome = sandbox
392///     .run(RunSpec {
393///         argv: &argv,
394///         workdir: dir.path(),
395///         limits: &config.limits,
396///         allow_network: config.allow_network,
397///     })
398///     .await?;
399///
400/// // Anything worth keeping is copied out deliberately, through
401/// // `copy_back`, so the write policy still decides. Nothing leaks by
402/// // default.
403/// println!("{} under {}", outcome.success(), outcome.backend.as_str());
404/// # Ok(())
405/// # }
406/// ```
407///
408/// The trait is RPITIT and therefore not object-safe — there is no
409/// `Box<dyn Sandbox>`. [`select`] returns the concrete [`Selected`] enum
410/// instead, which implements this trait.
411pub trait Sandbox {
412    /// Run one command under isolation, returning its captured outcome.
413    fn run(
414        &self,
415        spec: RunSpec<'_>,
416    ) -> impl std::future::Future<Output = Result<SandboxOutcome>> + Send;
417
418    /// Which backend this is — recorded so an audit shows how a run was isolated.
419    fn backend(&self) -> Backend;
420}
421
422/// The selected backend for a run. An internal enum so the crate can dispatch to
423/// one concrete backend without `dyn` (the trait is RPITIT and not
424/// object-safe). Callers see the [`Sandbox`] trait; [`select`] returns this.
425///
426/// Its variants are cfg-gated to the OS whose primitives they use, so a `match`
427/// over them does not port. Use it through [`Sandbox`] and ask
428/// [`backend`](Sandbox::backend) what it turned out to be — a native variant
429/// whose primitive failed its probe still reports [`Backend::PortableFloor`],
430/// so the variant you hold and the isolation you got are not the same question.
431///
432/// ```
433/// use io_harness::sandbox::{select, Backend, Sandbox, SandboxConfig};
434///
435/// let selected = select(&SandboxConfig::new());
436/// let confined = selected.backend() != Backend::PortableFloor;
437/// println!("kernel-level isolation: {confined}");
438/// ```
439pub enum Selected {
440    /// The portable floor, always available.
441    Floor(FloorSandbox),
442    /// The macOS native backend.
443    #[cfg(target_os = "macos")]
444    Macos(macos::MacosSandbox),
445    /// The Linux native backend.
446    #[cfg(target_os = "linux")]
447    Linux(linux::LinuxSandbox),
448    /// The Windows native backend.
449    #[cfg(target_os = "windows")]
450    Windows(windows::WindowsSandbox),
451}
452
453impl Sandbox for Selected {
454    async fn run(&self, spec: RunSpec<'_>) -> Result<SandboxOutcome> {
455        match self {
456            Selected::Floor(s) => s.run(spec).await,
457            #[cfg(target_os = "macos")]
458            Selected::Macos(s) => s.run(spec).await,
459            #[cfg(target_os = "linux")]
460            Selected::Linux(s) => s.run(spec).await,
461            #[cfg(target_os = "windows")]
462            Selected::Windows(s) => s.run(spec).await,
463        }
464    }
465
466    fn backend(&self) -> Backend {
467        match self {
468            Selected::Floor(s) => s.backend(),
469            #[cfg(target_os = "macos")]
470            Selected::Macos(s) => s.backend(),
471            #[cfg(target_os = "linux")]
472            Selected::Linux(s) => s.backend(),
473            #[cfg(target_os = "windows")]
474            Selected::Windows(s) => s.backend(),
475        }
476    }
477}
478
479/// Pick the strongest backend this host can actually deliver: the native rung
480/// for this target, or the portable floor when `force_floor` skips it (so the
481/// floor can be exercised everywhere).
482///
483/// The *candidate* is chosen at compile time by cfg, but a native backend whose
484/// primitive is unavailable degrades to the floor and reports
485/// [`Backend::PortableFloor`] rather than naming an isolation it did not apply —
486/// [`linux`] probes its `unshare` wrapper (0.9.1: Ubuntu 24.04 restricts
487/// unprivileged user namespaces, and every wrapped spawn failed there), and
488/// [`windows`] has no native backend to offer at all. Since the backend is
489/// recorded in the trace, a degraded run is auditable, not silent. Use
490/// [`Sandbox::backend`] on the result to see what will really run.
491///
492/// Which is the point of the example: ask, never assume. Compiling for Linux
493/// does not mean you got namespaces. Ubuntu 24.04 ships
494/// `kernel.apparmor_restrict_unprivileged_userns=1`, every `unshare`-wrapped
495/// spawn fails there, and before 0.9.1 that surfaced to the caller as its code
496/// having failed verification. Now the wrapper is probed once per process and
497/// this returns a backend that reports the floor.
498///
499/// ```
500/// use io_harness::sandbox::{select, Backend, Sandbox, SandboxConfig};
501///
502/// let sandbox = select(&SandboxConfig::new());
503/// match sandbox.backend() {
504///     Backend::PortableFloor => {
505///         // Ephemeral workdir and caps that kill, but no syscall jail and no
506///         // kernel network boundary — egress denial here is only a proxy-env
507///         // strip, which a payload not reading those variables ignores. This
508///         // is the branch where an application handling genuinely untrusted
509///         // code decides to refuse rather than proceed.
510///         eprintln!("degraded to the portable floor on this host");
511///     }
512///     native => eprintln!("native isolation: {}", native.as_str()),
513/// }
514/// ```
515pub fn select(config: &SandboxConfig) -> Selected {
516    if !config.force_floor {
517        #[cfg(target_os = "macos")]
518        return Selected::Macos(macos::MacosSandbox);
519        #[cfg(target_os = "linux")]
520        return Selected::Linux(linux::LinuxSandbox);
521        #[cfg(target_os = "windows")]
522        return Selected::Windows(windows::WindowsSandbox);
523    }
524    Selected::Floor(FloorSandbox)
525}
526
527/// The portable floor backend: a fresh subprocess in an ephemeral working
528/// directory, with resource caps and network env stripped. The guaranteed-present
529/// isolation floor on every OS. Deliberately the weakest backend — filesystem-
530/// scoped and resource-capped, not a syscall jail.
531pub struct FloorSandbox;
532
533impl Sandbox for FloorSandbox {
534    async fn run(&self, spec: RunSpec<'_>) -> Result<SandboxOutcome> {
535        run_capped(Backend::PortableFloor, spec, |_cmd| {}).await
536    }
537
538    fn backend(&self) -> Backend {
539        Backend::PortableFloor
540    }
541}
542
543/// Run `argv` in `workdir` under `limits`, capturing output and enforcing caps
544/// that *kill*. `configure` is a backend hook to further restrict the command
545/// (e.g. wrap it in `sandbox-exec`) before it is spawned; the floor passes a
546/// no-op. Shared by the floor and the native unix backends so caps and teardown
547/// live in one place.
548///
549/// Caps:
550/// - **CPU** via `RLIMIT_CPU` (unix `pre_exec`) → SIGXCPU → [`Cap::Cpu`]. *Unix
551///   only* — Windows has no equivalent and applies no CPU cap.
552/// - **Memory** via an RSS poll-and-kill monitor → [`Cap::Memory`] (macOS does
553///   not enforce address-space rlimits, so a monitor is the portable mechanism).
554///   *Unix only* — the monitor reads the process table with `ps`, which does not
555///   exist on Windows, so no memory cap is applied there.
556/// - **Wall** via a tokio timeout → [`Cap::Wall`]. The one cap that applies on
557///   **every** platform, and therefore the only thing standing between a Windows
558///   run and running forever.
559///
560/// A cap the platform cannot apply is never *claimed*: [`SandboxOutcome::cap_hit`]
561/// only ever names a cap that really fired, and a run on a platform missing the
562/// CPU/memory mechanisms warns once rather than letting a caller believe the
563/// limits it configured are in force.
564async fn run_capped(
565    backend: Backend,
566    spec: RunSpec<'_>,
567    configure: impl FnOnce(&mut tokio::process::Command),
568) -> Result<SandboxOutcome> {
569    use std::process::Stdio;
570    use std::sync::atomic::{AtomicU8, Ordering};
571    use std::sync::Arc;
572
573    let argv: Vec<String> = spec.argv.to_vec();
574    let mut cmd = tokio::process::Command::new(&argv[0]);
575    cmd.args(&argv[1..])
576        .current_dir(spec.workdir)
577        .stdin(Stdio::null())
578        .stdout(Stdio::piped())
579        .stderr(Stdio::piped())
580        .kill_on_drop(true);
581
582    // Deny network on the floor best-effort by stripping proxy configuration.
583    // A real kernel boundary comes from the native backends; documented as such.
584    if !spec.allow_network {
585        for k in [
586            "HTTP_PROXY",
587            "HTTPS_PROXY",
588            "ALL_PROXY",
589            "http_proxy",
590            "https_proxy",
591            "all_proxy",
592        ] {
593            cmd.env_remove(k);
594        }
595    }
596
597    // Unix: apply rlimits in the child before exec. CPU is the reliable kill.
598    #[cfg(unix)]
599    {
600        let cpu = spec.limits.max_cpu_secs;
601        let nofile = spec.limits.max_open_files;
602        // Note: max_processes is deliberately NOT mapped to RLIMIT_NPROC here —
603        // that limit is per-real-uid, so it would throttle the whole login
604        // session, not the sandbox. The native backends scope it per-sandbox.
605        unsafe {
606            cmd.pre_exec(move || {
607                // The cast is load-bearing on macOS, where the RLIMIT_* constants
608                // are c_int, and a no-op on Linux, where they are already u32 —
609                // so clippy's unnecessary_cast fires on Linux only. Keep the cast
610                // and silence it rather than cfg-splitting two lines.
611                // A cap that could not be applied fails the spawn: running the
612                // payload uncapped is worse than not running it.
613                #[allow(clippy::unnecessary_cast)]
614                {
615                    set_rlimit(libc::RLIMIT_CPU as u32, cpu)?;
616                    set_rlimit(libc::RLIMIT_NOFILE as u32, nofile)?;
617                }
618                Ok(())
619            });
620        }
621    }
622
623    configure(&mut cmd);
624
625    let child = cmd.spawn().map_err(|e| crate::error::Error::Sandbox {
626        reason: format!("could not spawn {}: {e}", argv[0]),
627    })?;
628    let pid = child.id();
629
630    // Say once, out loud, what this platform cannot enforce. The CPU cap is
631    // `RLIMIT_CPU` and the memory cap is an RSS monitor over `ps` — both unix
632    // mechanisms — so a Windows run gets the wall clock and nothing else. A cap
633    // silently not applied is worse than no cap: the caller thinks it has one.
634    #[cfg(not(unix))]
635    if spec.limits.max_cpu_secs.is_some() || spec.limits.max_memory_bytes.is_some() {
636        static SAID: std::sync::Once = std::sync::Once::new();
637        SAID.call_once(|| {
638            tracing::warn!(
639                "sandbox: the CPU and memory caps are unix-only mechanisms and are NOT applied \
640                 on this platform; only the wall-clock cap is enforced"
641            )
642        });
643    }
644
645    // A flag set by whichever killer fired, so the outcome can name the cap.
646    const NONE: u8 = 0;
647    const MEM: u8 = 1;
648    const WALL: u8 = 2;
649    let flag = Arc::new(AtomicU8::new(NONE));
650
651    // Memory monitor: poll the process *tree*'s RSS and kill it on breach.
652    // Unix-only (uses `ps`); the build host is macOS where address-space rlimits
653    // do not enforce. The tree rather than the pid because a payload that forks
654    // — which is what Linux `/bin/sh` does — otherwise evades the cap entirely.
655    #[cfg(unix)]
656    let mem_monitor = {
657        let max = spec.limits.max_memory_bytes;
658        let flag = Arc::clone(&flag);
659        match (pid, max) {
660            (Some(pid), Some(max)) => Some(tokio::spawn(async move {
661                loop {
662                    tokio::time::sleep(std::time::Duration::from_millis(40)).await;
663                    let Some(tree) = process_tree(pid) else {
664                        // The process table could not be read this time. That is
665                        // not "the process is gone" — keep polling rather than
666                        // switching the cap off for the rest of the run.
667                        continue;
668                    };
669                    if tree.is_empty() {
670                        return; // process gone
671                    }
672                    if tree.iter().map(|(_, rss)| rss).sum::<u64>() > max {
673                        flag.store(MEM, Ordering::SeqCst);
674                        // Descendants first: killing the root alone would only
675                        // reparent the hog and leave it running.
676                        for (p, _) in tree.iter().rev() {
677                            unsafe { libc::kill(*p as libc::pid_t, libc::SIGKILL) };
678                        }
679                        return;
680                    }
681                }
682            })),
683            _ => None,
684        }
685    };
686    // No monitor where it cannot measure: `process_tree` is unix-only, so on
687    // Windows there is no RSS poller at all rather than one that reads nothing
688    // and quietly never fires.
689    #[cfg(not(unix))]
690    let mem_monitor: Option<tokio::task::JoinHandle<()>> = None;
691
692    // Wall-clock cap: the OS-neutral backstop that always kills.
693    //
694    // The wait runs as its own task so the *timeout does not own the child*.
695    // Letting the timeout own it (the shape until 0.9.1) means expiry drops the
696    // child first, and the only kill left is `kill_on_drop` — which terminates
697    // just the process the harness spawned. Its descendants survive: on unix
698    // they reparent, and on Windows they also keep the stdout/stderr pipes open,
699    // which strands the blocking pipe reads tokio uses there and hangs the
700    // caller's runtime long after the cap "fired". Holding the child alive past
701    // expiry lets [`kill_tree`] reach the whole tree by pid instead.
702    let waiter = tokio::spawn(async move { child.wait_with_output().await });
703    let wall = spec.limits.max_wall_secs;
704    let waited = match wall {
705        Some(secs) => {
706            match tokio::time::timeout(std::time::Duration::from_secs(secs), waiter).await {
707                Ok(joined) => joined,
708                Err(_elapsed) => {
709                    flag.store(WALL, Ordering::SeqCst);
710                    // Dropping the JoinHandle detaches the wait, it does not
711                    // cancel it — the child is still running and still killable.
712                    kill_tree(pid);
713                    if let Some(m) = mem_monitor {
714                        m.abort();
715                    }
716                    // Output is lost on a wall kill; the detached wait reaps.
717                    return Ok(SandboxOutcome {
718                        backend,
719                        argv,
720                        exit_code: None,
721                        cap_hit: Some(Cap::Wall),
722                        stdout: String::new(),
723                        stderr: String::new(),
724                    });
725                }
726            }
727        }
728        None => waiter.await,
729    };
730    let output = waited.map_err(|e| crate::error::Error::Sandbox {
731        reason: format!("the sandbox wait task did not finish: {e}"),
732    })??;
733
734    if let Some(m) = mem_monitor {
735        m.abort();
736    }
737
738    let cap_hit = match flag.load(Ordering::SeqCst) {
739        MEM => Some(Cap::Memory),
740        WALL => Some(Cap::Wall),
741        _ => cpu_capped(&output.status).then_some(Cap::Cpu),
742    };
743
744    Ok(SandboxOutcome {
745        backend,
746        argv,
747        exit_code: output.status.code(),
748        cap_hit,
749        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
750        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
751    })
752}
753
754/// Did the process die of SIGXCPU (the `RLIMIT_CPU` kill)?
755#[cfg(unix)]
756fn cpu_capped(status: &std::process::ExitStatus) -> bool {
757    use std::os::unix::process::ExitStatusExt;
758    status.signal() == Some(libc::SIGXCPU)
759}
760#[cfg(not(unix))]
761fn cpu_capped(_status: &std::process::ExitStatus) -> bool {
762    false
763}
764
765/// Set an rlimit's soft value to `value`, keeping the hard limit *above* it; a
766/// `None` value leaves the limit alone.
767///
768/// The soft/hard split is load-bearing on Linux: `check_process_timers` tests the
769/// hard limit first and `SIGKILL`s there, so a `RLIMIT_CPU` with soft == hard
770/// never sends `SIGXCPU` and [`cpu_capped`] never sees the cap it set. macOS
771/// sends `SIGXCPU` either way, which is why this only ever showed up on Linux.
772/// The hard limit is clamped to what `getrlimit` reports — lowering it is
773/// irreversible for the child and raising it is not permitted to an unprivileged
774/// process, so it is only ever lowered, never raised.
775///
776/// Runs in the forked child before exec, so it must be async-signal-safe: only
777/// `getrlimit`/`setrlimit`, no allocation (`last_os_error` just wraps `errno`).
778/// A cap that could not be applied is an error, not a shrug — the caller fails
779/// the spawn rather than running the payload uncapped.
780#[cfg(unix)]
781fn set_rlimit(resource: u32, value: Option<u64>) -> std::io::Result<()> {
782    let Some(v) = value else { return Ok(()) };
783    let v = v as libc::rlim_t;
784    let mut lim = libc::rlimit {
785        rlim_cur: 0,
786        rlim_max: 0,
787    };
788    unsafe {
789        if libc::getrlimit(resource as _, &mut lim) != 0 {
790            return Err(std::io::Error::last_os_error());
791        }
792        // Never raise the hard limit, and never ask for a soft limit above it.
793        lim.rlim_cur = v.min(lim.rlim_max);
794        lim.rlim_max = lim.rlim_max.min(v.saturating_add(1));
795        if libc::setrlimit(resource as _, &lim) != 0 {
796            return Err(std::io::Error::last_os_error());
797        }
798    }
799    Ok(())
800}
801
802/// Kill `pid` and everything it spawned, on whatever OS this is.
803///
804/// Killing only `pid` is not enough anywhere: a payload run through a shell puts
805/// the real work in a *child*, so the single kill takes the shell and reparents
806/// the work. Unix walks [`process_tree`] and signals descendants first (killing
807/// the root first would orphan them before they can be found). Windows has
808/// neither signals nor `ps`, so it uses the tree kill the OS itself ships,
809/// `taskkill /T` — a system utility, not a new dependency. Best-effort by
810/// design: a process that is already gone is a success, not an error.
811fn kill_tree(pid: Option<u32>) {
812    let Some(pid) = pid else { return };
813    #[cfg(unix)]
814    {
815        for (p, _) in process_tree(pid).unwrap_or_default().iter().rev() {
816            unsafe { libc::kill(*p as libc::pid_t, libc::SIGKILL) };
817        }
818        // Always signal the root, even when the process table could not be read.
819        unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
820    }
821    #[cfg(windows)]
822    {
823        use std::process::Stdio;
824        let _ = std::process::Command::new("taskkill")
825            .args(["/F", "/T", "/PID", &pid.to_string()])
826            .stdin(Stdio::null())
827            .stdout(Stdio::null())
828            .stderr(Stdio::null())
829            .status();
830    }
831}
832
833/// Every process in `pid`'s tree — the process and its descendants — with each
834/// one's RSS in bytes. macOS/BSD and Linux `ps` both report RSS in kibibytes.
835///
836/// The tree, not the pid, is what the memory cap has to measure: a shell that
837/// *forks* its payload (Linux `/bin/sh` does) leaves the monitor watching a
838/// 2 MiB shell while its child takes 400 MiB, and the cap silently never fires.
839///
840/// Two return shapes, deliberately distinct: `Some(empty)` means the pid is no
841/// longer in the process table (it is gone, stop polling); `None` means the
842/// table could not be read *this time* — a fork failure, an unexpected `ps` —
843/// which is not evidence of anything and must not switch the cap off.
844///
845// ponytail: one `ps` fork per poll and an O(tree × table) scan. Fine for a
846// handful of processes at 25 Hz; read /proc directly if a run ever spawns
847// hundreds.
848#[cfg(unix)]
849fn process_tree(pid: u32) -> Option<Vec<(u32, u64)>> {
850    let out = std::process::Command::new("ps")
851        .args(["-eo", "pid=,ppid=,rss="])
852        .output()
853        .ok()?;
854    if !out.status.success() {
855        return None;
856    }
857    let text = String::from_utf8_lossy(&out.stdout);
858    let rows: Vec<(u32, u32, u64)> = text
859        .lines()
860        .filter_map(|l| {
861            let mut f = l.split_whitespace();
862            let p = f.next()?.parse().ok()?;
863            let pp = f.next()?.parse().ok()?;
864            let kb = f.next()?.parse::<u64>().ok()?;
865            Some((p, pp, kb * 1024))
866        })
867        .collect();
868    if rows.is_empty() {
869        return None; // the table itself is unreadable — not "the process is gone"
870    }
871    let mut tree: Vec<(u32, u64)> = rows
872        .iter()
873        .filter(|(p, _, _)| *p == pid)
874        .map(|(p, _, rss)| (*p, *rss))
875        .collect();
876    let mut i = 0;
877    while i < tree.len() {
878        let parent = tree[i].0;
879        for (p, pp, rss) in &rows {
880            if *pp == parent && *p != parent && !tree.iter().any(|(t, _)| t == p) {
881                tree.push((*p, *rss));
882            }
883        }
884        i += 1;
885    }
886    Some(tree)
887}
888
889/// Create an ephemeral working directory for one sandboxed run, seeding it with
890/// the files the command needs. Returned as a [`tempfile::TempDir`] so teardown
891/// is a guaranteed drop — the directory is removed when it goes out of scope, on
892/// every exit path including a panic or an early return.
893pub fn workdir() -> Result<tempfile::TempDir> {
894    Ok(tempfile::tempdir()?)
895}
896
897/// Copy files produced in the sandbox `workdir` back to `dest_root`, keeping only
898/// those `allowed` accepts (the 0.4.0 write policy). Returns the relative paths
899/// copied. So sandbox capture composes with the permission layer rather than
900/// bypassing it: a file the policy would deny writing is not copied back.
901///
902/// The `allowed` predicate is the whole reason this is not a directory copy.
903/// Everything a sandboxed command produces is otherwise dropped with the
904/// workdir, so capture is the one path back out — and if it did not consult
905/// the policy, it would be the hole in it: a file the agent may not write
906/// directly would arrive in the workspace merely by having been produced
907/// somewhere the write check does not run.
908///
909/// ```
910/// use std::path::PathBuf;
911///
912/// use io_harness::sandbox::copy_back;
913/// use io_harness::{Act, Effect, Policy};
914///
915/// # async fn demo(sandbox_dir: &std::path::Path, repo: &std::path::Path)
916/// #     -> io_harness::Result<()> {
917/// let policy = Policy::default()
918///     .layer("app")
919///     .allow_write("*")
920///     .deny_write("secrets/*");
921///
922/// let produced = vec![
923///     PathBuf::from("src/lib.rs"),
924///     PathBuf::from("secrets/leaked.pem"),
925/// ];
926/// let copied = copy_back(sandbox_dir, repo, &produced, |rel| {
927///     policy.check(Act::Write, &rel.to_string_lossy()).effect == Effect::Allow
928/// })
929/// .await?;
930///
931/// // The denied path is simply not there. It stays in the workdir and dies
932/// // with it; the return value is what actually landed, so a caller can
933/// // report the difference rather than guess at it.
934/// assert!(!copied.contains(&PathBuf::from("secrets/leaked.pem")));
935/// # Ok(())
936/// # }
937/// ```
938///
939/// A listed file that the sandbox never produced is skipped rather than an
940/// error: a command that failed part way leaves a partial set, and the caller
941/// already has the failure from [`SandboxOutcome`].
942pub async fn copy_back(
943    workdir: &Path,
944    dest_root: &Path,
945    files: &[PathBuf],
946    allowed: impl Fn(&Path) -> bool,
947) -> Result<Vec<PathBuf>> {
948    let mut copied = Vec::new();
949    for rel in files {
950        if !allowed(rel) {
951            continue;
952        }
953        let src = workdir.join(rel);
954        if !src.exists() {
955            continue;
956        }
957        let dest = dest_root.join(rel);
958        if let Some(parent) = dest.parent() {
959            tokio::fs::create_dir_all(parent).await?;
960        }
961        tokio::fs::copy(&src, &dest).await?;
962        copied.push(rel.clone());
963    }
964    Ok(copied)
965}
966
967// All three backend modules are always compiled — their logic (profile/argv
968// construction, Job-Object limit mapping) is portable and unit-tested on the
969// build host. Only the wiring into `select`/`Selected` is `cfg`-gated to the OS
970// whose native primitives actually run. This is how the Linux and Windows
971// backends "compile under their cfg and pass their backend unit tests" on a
972// macOS host without a cross toolchain (rusqlite's bundled C blocks a full
973// cross-check, which is an environment limit, not a limit of this code).
974pub mod linux;
975pub mod macos;
976pub mod windows;
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981
982    fn spec<'a>(argv: &'a [String], dir: &'a Path, limits: &'a SandboxLimits) -> RunSpec<'a> {
983        RunSpec {
984            argv,
985            workdir: dir,
986            limits,
987            allow_network: false,
988        }
989    }
990
991    #[tokio::test]
992    async fn floor_runs_a_command_and_captures_output() {
993        let dir = tempfile::tempdir().unwrap();
994        let argv = vec!["sh".into(), "-c".into(), "echo hello; exit 0".into()];
995        let out = FloorSandbox
996            .run(spec(&argv, dir.path(), &SandboxLimits::default()))
997            .await
998            .unwrap();
999        assert!(out.success());
1000        assert_eq!(out.backend, Backend::PortableFloor);
1001        assert!(out.stdout.contains("hello"));
1002        assert_eq!(out.exit_code, Some(0));
1003    }
1004
1005    #[tokio::test]
1006    async fn floor_reports_a_nonzero_exit() {
1007        let dir = tempfile::tempdir().unwrap();
1008        let argv = vec!["sh".into(), "-c".into(), "exit 3".into()];
1009        let out = FloorSandbox
1010            .run(spec(&argv, dir.path(), &SandboxLimits::default()))
1011            .await
1012            .unwrap();
1013        assert!(!out.success());
1014        assert_eq!(out.exit_code, Some(3));
1015        assert_eq!(out.cap_hit, None);
1016    }
1017
1018    #[tokio::test]
1019    async fn force_floor_selects_the_portable_backend() {
1020        let sb = select(&SandboxConfig::new().floor_only());
1021        assert_eq!(sb.backend(), Backend::PortableFloor);
1022    }
1023
1024    #[test]
1025    fn config_and_limits_round_trip_through_serde() {
1026        let cfg = SandboxConfig::new();
1027        let json = serde_json::to_string(&cfg).unwrap();
1028        let back: SandboxConfig = serde_json::from_str(&json).unwrap();
1029        assert_eq!(cfg, back);
1030    }
1031
1032    // The CPU cap is `RLIMIT_CPU`, a unix mechanism with no Windows equivalent;
1033    // asserting it there would assert a cap the floor deliberately never applies.
1034    #[cfg(unix)]
1035    #[tokio::test]
1036    async fn cpu_cap_kills_a_busy_loop_and_names_the_cpu_cap() {
1037        let dir = tempfile::tempdir().unwrap();
1038        // A pure busy loop that would never finish; RLIMIT_CPU must kill it.
1039        let argv = vec!["sh".into(), "-c".into(), "while :; do :; done".into()];
1040        let limits = SandboxLimits {
1041            max_cpu_secs: Some(1),
1042            max_wall_secs: Some(30), // wall is the backstop; CPU should fire first
1043            ..SandboxLimits::default()
1044        };
1045        let out = FloorSandbox
1046            .run(spec(&argv, dir.path(), &limits))
1047            .await
1048            .unwrap();
1049        assert_eq!(out.cap_hit, Some(Cap::Cpu), "expected CPU cap, got {out:?}");
1050        assert!(!out.success());
1051    }
1052
1053    // The memory cap is an RSS monitor over `ps`; both the monitor and `ps` are
1054    // unix-only, so this is a unix mechanism asserted on unix.
1055    #[cfg(unix)]
1056    #[tokio::test]
1057    async fn memory_cap_kills_a_heap_hog_and_names_the_memory_cap() {
1058        let dir = tempfile::tempdir().unwrap();
1059        // Grow RSS well past the cap; the monitor must kill it.
1060        let argv = vec![
1061            "sh".into(),
1062            "-c".into(),
1063            // perl builds a large string in RSS; portable enough on macOS.
1064            "perl -e '$x=\"a\"x(400*1024*1024); sleep 5'".into(),
1065        ];
1066        let limits = SandboxLimits {
1067            max_memory_bytes: Some(64 * 1024 * 1024), // 64 MiB
1068            max_wall_secs: Some(30),
1069            ..SandboxLimits::default()
1070        };
1071        let out = FloorSandbox
1072            .run(spec(&argv, dir.path(), &limits))
1073            .await
1074            .unwrap();
1075        assert_eq!(
1076            out.cap_hit,
1077            Some(Cap::Memory),
1078            "expected memory cap, got {out:?}"
1079        );
1080        assert!(!out.success());
1081    }
1082
1083    // Same unix-only mechanism, exercised through a fork. See above.
1084    #[cfg(unix)]
1085    #[tokio::test]
1086    async fn memory_cap_kills_a_hog_the_shell_forked() {
1087        let dir = tempfile::tempdir().unwrap();
1088        // The shell *forks* the hog instead of exec'ing it — which is what
1089        // Linux /bin/sh (dash) does even without the explicit `&`. The monitor
1090        // must sum the process tree, not the single pid it spawned, or the cap
1091        // watches a 2 MiB shell while its child takes 400 MiB.
1092        let argv = vec![
1093            "sh".into(),
1094            "-c".into(),
1095            "perl -e '$x=\"a\"x(400*1024*1024); sleep 5' & wait".into(),
1096        ];
1097        let limits = SandboxLimits {
1098            max_memory_bytes: Some(64 * 1024 * 1024), // 64 MiB
1099            max_wall_secs: Some(30),
1100            ..SandboxLimits::default()
1101        };
1102        let out = FloorSandbox
1103            .run(spec(&argv, dir.path(), &limits))
1104            .await
1105            .unwrap();
1106        assert_eq!(
1107            out.cap_hit,
1108            Some(Cap::Memory),
1109            "a forked hog must still hit the memory cap, got {out:?}"
1110        );
1111        assert!(!out.success());
1112    }
1113
1114    #[cfg(unix)]
1115    #[tokio::test]
1116    async fn the_wall_clock_kill_reaches_the_children_the_run_forked() {
1117        let dir = tempfile::tempdir().unwrap();
1118        let marker = dir.path().join("survived");
1119        // The payload forks a child that outlives the wall clock and leaves a
1120        // file behind if it is still alive. Killing only the pid the harness
1121        // spawned reparents that child and it goes on to write the file.
1122        let argv = vec![
1123            "sh".into(),
1124            "-c".into(),
1125            "(sleep 4; touch survived) & wait".into(),
1126        ];
1127        let limits = SandboxLimits {
1128            max_wall_secs: Some(1),
1129            max_cpu_secs: None,
1130            ..SandboxLimits::default()
1131        };
1132        let out = FloorSandbox
1133            .run(spec(&argv, dir.path(), &limits))
1134            .await
1135            .unwrap();
1136        assert_eq!(
1137            out.cap_hit,
1138            Some(Cap::Wall),
1139            "wall must kill it, got {out:?}"
1140        );
1141        assert!(!out.success());
1142        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1143        assert!(
1144            !marker.exists(),
1145            "a forked child must not outlive the wall-clock kill"
1146        );
1147    }
1148
1149    // What is actually true on Windows: no CPU cap and no memory cap are applied
1150    // there (both are unix mechanisms), so the wall clock is the only thing that
1151    // can stop an endless run — and the outcome must name the cap that really
1152    // fired rather than one of the two nobody enforced.
1153    #[cfg(windows)]
1154    #[tokio::test]
1155    async fn windows_enforces_the_wall_clock_and_claims_no_cap_it_did_not_apply() {
1156        let dir = tempfile::tempdir().unwrap();
1157        // `for /L` with a step of 0 never terminates. Passed as separate argv
1158        // entries, none containing a space, so nothing depends on how `cmd.exe`
1159        // re-parses a quoted command line.
1160        let argv: Vec<String> = [
1161            "cmd", "/C", "for", "/L", "%i", "in", "(1,0,2)", "do", "@rem",
1162        ]
1163        .iter()
1164        .map(|s| s.to_string())
1165        .collect();
1166        let limits = SandboxLimits {
1167            max_cpu_secs: Some(1),
1168            max_memory_bytes: Some(1024 * 1024),
1169            max_wall_secs: Some(5),
1170            ..SandboxLimits::default()
1171        };
1172        let out = FloorSandbox
1173            .run(spec(&argv, dir.path(), &limits))
1174            .await
1175            .unwrap();
1176        assert_eq!(
1177            out.cap_hit,
1178            Some(Cap::Wall),
1179            "the wall clock is the only cap Windows applies, got {out:?}"
1180        );
1181        assert!(!out.success());
1182    }
1183
1184    #[cfg(unix)]
1185    #[tokio::test]
1186    async fn a_capped_run_keeps_its_hard_limit_above_the_soft_one() {
1187        let dir = tempfile::tempdir().unwrap();
1188        // Linux's CPU timer tests the HARD limit first and SIGKILLs there, so a
1189        // cap set with soft == hard never sends SIGXCPU and `cpu_capped` never
1190        // sees it. The child's own view of its limits is the portable oracle.
1191        let argv = vec![
1192            "sh".into(),
1193            "-c".into(),
1194            "ulimit -S -n; ulimit -H -n".into(),
1195        ];
1196        let limits = SandboxLimits {
1197            max_open_files: Some(64),
1198            ..SandboxLimits::default()
1199        };
1200        let out = FloorSandbox
1201            .run(spec(&argv, dir.path(), &limits))
1202            .await
1203            .unwrap();
1204        let seen: Vec<u64> = out
1205            .stdout
1206            .split_whitespace()
1207            .filter_map(|s| s.parse().ok())
1208            .collect();
1209        assert_eq!(seen.len(), 2, "expected soft and hard, got {out:?}");
1210        assert_eq!(seen[0], 64, "soft limit must be what was asked for");
1211        assert!(
1212            seen[1] > seen[0],
1213            "hard limit must stay above the soft one, got {out:?}"
1214        );
1215    }
1216
1217    #[tokio::test]
1218    async fn workdir_is_removed_on_drop() {
1219        let path = {
1220            let wd = workdir().unwrap();
1221            let p = wd.path().to_path_buf();
1222            assert!(p.exists());
1223            p
1224            // wd dropped here
1225        };
1226        assert!(!path.exists(), "sandbox workdir must be gone after drop");
1227    }
1228
1229    #[tokio::test]
1230    async fn copy_back_honours_the_write_policy() {
1231        let src = tempfile::tempdir().unwrap();
1232        let dst = tempfile::tempdir().unwrap();
1233        tokio::fs::write(src.path().join("keep.txt"), "y")
1234            .await
1235            .unwrap();
1236        tokio::fs::write(src.path().join("secret.txt"), "n")
1237            .await
1238            .unwrap();
1239
1240        let files = vec![PathBuf::from("keep.txt"), PathBuf::from("secret.txt")];
1241        let copied = copy_back(src.path(), dst.path(), &files, |p| {
1242            p != Path::new("secret.txt")
1243        })
1244        .await
1245        .unwrap();
1246
1247        assert_eq!(copied, vec![PathBuf::from("keep.txt")]);
1248        assert!(dst.path().join("keep.txt").exists());
1249        assert!(
1250            !dst.path().join("secret.txt").exists(),
1251            "denied file must not be copied back"
1252        );
1253    }
1254}