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}