Skip to main content

harness_loop/
sandbox.rs

1//! Sandbox trait + concrete backends.
2//!
3//! Principle (DESIGN.md §11): permissions are decided when the execution
4//! environment is *spawned*, not re-prompted per tool call.
5//!
6//! **Be honest about what each backend enforces** — see [`Isolation`]:
7//!
8//! - [`WorktreeSandbox`] — a git worktree on a feature branch. Isolates
9//!   *changes* (they land on a branch, not `main`), **not capability**: a shell
10//!   command inside it can still `cd /` and touch anything. `Isolation::Changes`.
11//! - [`SeatbeltSandbox`] (macOS) / [`BubblewrapSandbox`] (Linux) — OS-native,
12//!   kernel-enforced, no daemon. Each runs a command as a *separate* sandboxed
13//!   process (`sandbox-exec` / `bwrap`), so the harness process is never
14//!   restricted — the per-command helper model Codex CLI uses, and the reason
15//!   the in-process `birdcage` crate was *not* used (its `spawn` leaks the
16//!   sandbox to the caller). Denies network by default. `Isolation::Process`.
17//!   Confining *writes* is the default posture and stops a command damaging the
18//!   host; it does **not** stop one reading it. On a multi-tenant host, where
19//!   the threat is one tenant reading another's workspace rather than breaking
20//!   the machine, ask for `with_confine_reads(true)` — then and only then does
21//!   `fs_policy()` report [`FsPolicy::Confined`].
22//! - [`ContainerSandbox`] — routes `runner.exec` through `docker exec` into a
23//!   container (`--network none` for real net isolation). The container/kernel
24//!   does the isolating; this crate is only the orchestration. `Isolation::Process`.
25//! - [`NullSandbox`] — no isolation (tests / opt-out). `Isolation::None`.
26//!
27//! **Scope (all backends):** a sandbox here wraps `world.runner.exec` — i.e.
28//! *shell subprocesses*. The agent's in-process filesystem tools are confined
29//! *separately*: `harness-tools-fs` runs every op through a capability
30//! directory (`cap-std`), so their workspace jail is OS-enforced (`openat`),
31//! not a string check. So the two side-effect channels are each confined —
32//! shell by this sandbox, files by the cap-std jail — though not yet behind a
33//! single unified `World` capability.
34
35use async_trait::async_trait;
36use harness_core::{RepoView, World};
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use thiserror::Error;
40
41#[derive(Debug, Error)]
42pub enum SandboxError {
43    #[error("git not available: {0}")]
44    GitMissing(String),
45    #[error("git error: {0}")]
46    Git(String),
47    #[error("io error: {0}")]
48    Io(String),
49}
50
51/// Filesystem access policy a sandbox can advertise to the framework.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum FsPolicy {
54    /// Sandbox can read/write only inside its own root.
55    Confined,
56    /// Sandbox can read anywhere but only write inside its root.
57    HostReadConfinedWrite,
58    /// No restriction (debugging only).
59    Unrestricted,
60}
61
62/// Network access policy.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum NetPolicy {
65    /// No outbound network at all.
66    None,
67    /// Outbound network allowed.
68    Allowed,
69}
70
71/// What a backend **actually enforces** — as distinct from the `FsPolicy` /
72/// `NetPolicy` it *requests*. A policy the kernel doesn't enforce is not
73/// isolation, so callers should branch on this, not on the requested policy.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Isolation {
76    /// Nothing is enforced; the agent has host access.
77    None,
78    /// Only *changes* are isolated (e.g. a git worktree/branch). Capability is
79    /// **not** restricted — commands can still read/write/network the host.
80    Changes,
81    /// Shell subprocesses are confined by the kernel or a container (Seatbelt /
82    /// Landlock / Docker). In-process fs tools are jailed separately.
83    Process,
84}
85
86#[async_trait]
87pub trait Sandbox: Send + Sync {
88    async fn spawn(&self) -> Result<SandboxHandle, SandboxError>;
89    fn fs_policy(&self) -> FsPolicy;
90    fn net_policy(&self) -> NetPolicy;
91    /// What this backend actually enforces (be honest — see [`Isolation`]).
92    fn isolation(&self) -> Isolation;
93}
94
95/// What a sandbox hands back to the caller — a `World` rooted at the sandbox
96/// path, plus introspection / cleanup helpers.
97pub struct SandboxHandle {
98    pub world: World,
99    pub root: PathBuf,
100    cleanup: Option<Box<dyn FnOnce() + Send>>,
101    keep: bool,
102    label: String,
103}
104
105impl SandboxHandle {
106    pub fn label(&self) -> &str {
107        &self.label
108    }
109    pub fn root(&self) -> &Path {
110        &self.root
111    }
112
113    /// Skip the cleanup callback on drop. Useful when you want to inspect the
114    /// sandbox afterwards.
115    pub fn keep(&mut self) {
116        self.keep = true;
117    }
118
119    /// Take ownership of the sandbox `World`, keeping the environment alive
120    /// (the cleanup callback is dropped, like [`keep`](Self::keep)). Intended
121    /// for backends with no cleanup resource — Seatbelt / bubblewrap / null —
122    /// where nothing leaks.
123    pub fn into_world(self) -> World {
124        let md = std::mem::ManuallyDrop::new(self);
125        // SAFETY: `md` is a `ManuallyDrop`, so `Drop` never runs and `world` is
126        // moved out exactly once (no double-free). The skipped `cleanup` is
127        // `None` for the OS backends this is used with.
128        unsafe { std::ptr::read(&md.world) }
129    }
130}
131
132impl Drop for SandboxHandle {
133    fn drop(&mut self) {
134        if self.keep {
135            return;
136        }
137        if let Some(f) = self.cleanup.take() {
138            f();
139        }
140    }
141}
142
143// ============================================================
144// WorktreeSandbox
145// ============================================================
146
147/// Spawn a fresh git worktree at a sibling path on a new branch.
148///
149/// Requires `git` on `PATH`. The host repo must be a git repository.
150pub struct WorktreeSandbox {
151    /// Path to the *source* git repo.
152    pub source: PathBuf,
153    /// Branch name to create (must not already exist).
154    pub branch: String,
155    /// Optional starting ref; defaults to the current HEAD.
156    pub start_ref: Option<String>,
157    /// Optional explicit worktree path (default: tempdir).
158    pub target: Option<PathBuf>,
159}
160
161impl WorktreeSandbox {
162    pub fn new(source: impl Into<PathBuf>, branch: impl Into<String>) -> Self {
163        Self {
164            source: source.into(),
165            branch: branch.into(),
166            start_ref: None,
167            target: None,
168        }
169    }
170
171    pub fn with_target(mut self, p: impl Into<PathBuf>) -> Self {
172        self.target = Some(p.into());
173        self
174    }
175
176    pub fn with_start_ref(mut self, r: impl Into<String>) -> Self {
177        self.start_ref = Some(r.into());
178        self
179    }
180}
181
182#[async_trait]
183impl Sandbox for WorktreeSandbox {
184    fn fs_policy(&self) -> FsPolicy {
185        // Requested, not enforced — a worktree does not stop writes elsewhere.
186        FsPolicy::Confined
187    }
188    fn net_policy(&self) -> NetPolicy {
189        NetPolicy::Allowed
190    }
191    fn isolation(&self) -> Isolation {
192        Isolation::Changes
193    }
194
195    async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
196        // Choose target path
197        let target = match &self.target {
198            Some(p) => p.clone(),
199            None => {
200                let stamp = std::time::SystemTime::now()
201                    .duration_since(std::time::UNIX_EPOCH)
202                    .map(|d| d.as_nanos())
203                    .unwrap_or(0);
204                std::env::temp_dir().join(format!("harness-worktree-{stamp}"))
205            }
206        };
207
208        // Verify git available
209        let probe = tokio::process::Command::new("git")
210            .arg("--version")
211            .output()
212            .await
213            .map_err(|e| SandboxError::GitMissing(format!("`git --version` failed: {e}")))?;
214        if !probe.status.success() {
215            return Err(SandboxError::GitMissing(
216                String::from_utf8_lossy(&probe.stderr).into_owned(),
217            ));
218        }
219
220        // `git worktree add -b <branch> <target> [<start_ref>]`
221        let start = self.start_ref.clone().unwrap_or_else(|| "HEAD".to_string());
222        let target_str = target.to_string_lossy().to_string();
223        let args: Vec<&str> = vec!["worktree", "add", "-b", &self.branch, &target_str, &start];
224        tracing::info!(branch=%self.branch, target=%target.display(), "spawning worktree");
225        let out = tokio::process::Command::new("git")
226            .current_dir(&self.source)
227            .args(&args)
228            .output()
229            .await
230            .map_err(|e| SandboxError::Io(e.to_string()))?;
231        if !out.status.success() {
232            return Err(SandboxError::Git(format!(
233                "git worktree add failed: {}",
234                String::from_utf8_lossy(&out.stderr)
235            )));
236        }
237
238        // Build a World pointing at the worktree.
239        let world = World {
240            repo: RepoView {
241                root: target.clone(),
242            },
243            runner: Arc::new(harness_context::TokioRunner),
244            clock: Arc::new(harness_context::SystemClock),
245            kv: Arc::new(harness_context::InMemoryKv::new()),
246            profile: harness_core::UserProfile::default(),
247            session: None,
248        };
249
250        let cleanup_source = self.source.clone();
251        let cleanup_target = target.clone();
252        let cleanup_branch = self.branch.clone();
253        let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
254            let target_str = cleanup_target.to_string_lossy().to_string();
255            let args: Vec<&str> = vec!["worktree", "remove", "--force", &target_str];
256            let res = std::process::Command::new("git")
257                .current_dir(&cleanup_source)
258                .args(&args)
259                .output();
260            if let Ok(o) = res
261                && !o.status.success()
262            {
263                tracing::warn!(
264                    "git worktree remove failed for {}: {}",
265                    cleanup_target.display(),
266                    String::from_utf8_lossy(&o.stderr)
267                );
268            }
269            // Best-effort branch delete (will fail if checked out elsewhere).
270            let _ = std::process::Command::new("git")
271                .current_dir(&cleanup_source)
272                .args(["branch", "-D", &cleanup_branch])
273                .output();
274        });
275
276        Ok(SandboxHandle {
277            world,
278            root: target,
279            cleanup: Some(cleanup),
280            keep: false,
281            label: format!("worktree:{}", self.branch),
282        })
283    }
284}
285
286// ============================================================
287// ContainerSandbox
288// ============================================================
289
290/// Run the agent against a Docker container. The agent itself runs on the
291/// host, but every `world.runner.exec(...)` call routes through `docker exec`
292/// into a long-lived container whose workspace is bind-mounted from the host.
293///
294/// Requires `docker` on PATH; the container image must include the tools the
295/// agent intends to use (cargo, git, etc.).
296pub struct ContainerSandbox {
297    /// OCI image to spawn (e.g. `rust:1.92-slim`).
298    pub image: String,
299    /// Host directory to mount inside the container at `/workspace`.
300    pub source: PathBuf,
301    /// Container name; auto-generated if `None`.
302    pub name: Option<String>,
303    /// Pass `--network none` to the container.
304    pub no_net: bool,
305}
306
307impl ContainerSandbox {
308    pub fn new(image: impl Into<String>, source: impl Into<PathBuf>) -> Self {
309        Self {
310            image: image.into(),
311            source: source.into(),
312            name: None,
313            no_net: true,
314        }
315    }
316
317    pub fn with_network(mut self, allow: bool) -> Self {
318        self.no_net = !allow;
319        self
320    }
321
322    pub fn with_name(mut self, name: impl Into<String>) -> Self {
323        self.name = Some(name.into());
324        self
325    }
326}
327
328#[async_trait]
329impl Sandbox for ContainerSandbox {
330    fn fs_policy(&self) -> FsPolicy {
331        FsPolicy::Confined
332    }
333    fn net_policy(&self) -> NetPolicy {
334        if self.no_net {
335            NetPolicy::None
336        } else {
337            NetPolicy::Allowed
338        }
339    }
340    fn isolation(&self) -> Isolation {
341        Isolation::Process
342    }
343
344    async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
345        // probe docker
346        let probe = tokio::process::Command::new("docker")
347            .arg("--version")
348            .output()
349            .await
350            .map_err(|e| SandboxError::GitMissing(format!("docker missing: {e}")))?;
351        if !probe.status.success() {
352            return Err(SandboxError::GitMissing(
353                String::from_utf8_lossy(&probe.stderr).to_string(),
354            ));
355        }
356
357        let name = self.name.clone().unwrap_or_else(|| {
358            format!(
359                "harness-sb-{}-{}",
360                std::process::id(),
361                std::time::SystemTime::now()
362                    .duration_since(std::time::UNIX_EPOCH)
363                    .map(|d| d.as_nanos())
364                    .unwrap_or(0)
365            )
366        });
367        let mount = format!("{}:/workspace", self.source.display());
368        let mut args = vec![
369            "run",
370            "-d",
371            "--rm",
372            "--name",
373            &name,
374            "-v",
375            &mount,
376            "-w",
377            "/workspace",
378        ];
379        if self.no_net {
380            args.push("--network");
381            args.push("none");
382        }
383        args.push(&self.image);
384        args.push("sleep");
385        args.push("infinity");
386
387        tracing::info!(image=%self.image, name=%name, "spawning container sandbox");
388        let out = tokio::process::Command::new("docker")
389            .args(&args)
390            .output()
391            .await
392            .map_err(|e| SandboxError::Io(e.to_string()))?;
393        if !out.status.success() {
394            return Err(SandboxError::Git(format!(
395                "docker run failed: {}",
396                String::from_utf8_lossy(&out.stderr)
397            )));
398        }
399
400        // Wrap the world's runner so every exec routes through `docker exec`.
401        let runner: Arc<dyn harness_core::ProcessRunner> = Arc::new(DockerExecRunner {
402            container: name.clone(),
403        });
404        let world = World {
405            repo: RepoView {
406                root: self.source.clone(),
407            },
408            runner,
409            clock: Arc::new(harness_context::SystemClock),
410            kv: Arc::new(harness_context::InMemoryKv::new()),
411            profile: harness_core::UserProfile::default(),
412            session: None,
413        };
414
415        let kill_name = name.clone();
416        let cleanup: Box<dyn FnOnce() + Send> = Box::new(move || {
417            let _ = std::process::Command::new("docker")
418                .args(["kill", &kill_name])
419                .output();
420        });
421
422        Ok(SandboxHandle {
423            world,
424            root: self.source.clone(),
425            cleanup: Some(cleanup),
426            keep: false,
427            label: format!("container:{name}"),
428        })
429    }
430}
431
432struct DockerExecRunner {
433    container: String,
434}
435
436#[async_trait]
437impl harness_core::ProcessRunner for DockerExecRunner {
438    async fn exec(
439        &self,
440        program: &str,
441        args: &[&str],
442        cwd: Option<&std::path::Path>,
443    ) -> std::io::Result<harness_core::ProcessOutput> {
444        let mut docker_args: Vec<String> = vec!["exec".into()];
445        if let Some(c) = cwd {
446            docker_args.push("-w".into());
447            // Re-anchor relative cwd inside the container's /workspace mount.
448            let inside = if c.is_absolute() {
449                c.display().to_string()
450            } else {
451                format!("/workspace/{}", c.display())
452            };
453            docker_args.push(inside);
454        }
455        docker_args.push(self.container.clone());
456        docker_args.push(program.into());
457        for a in args {
458            docker_args.push((*a).into());
459        }
460        let out = tokio::process::Command::new("docker")
461            .args(&docker_args)
462            .output()
463            .await?;
464        Ok(harness_core::ProcessOutput {
465            status: out.status.code().unwrap_or(-1),
466            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
467            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
468        })
469    }
470}
471
472// ============================================================
473// SeatbeltSandbox (macOS, OS-native — no Docker, per-command)
474// ============================================================
475
476/// **macOS only.** Runs each shell subprocess under Apple's Seatbelt via
477/// `sandbox-exec` — a *separate* sandboxed process per command, so the harness
478/// process itself is never restricted (unlike an in-process crate such as
479/// birdcage, whose `spawn` leaks the sandbox to the caller). Kernel-enforced,
480/// no daemon; denies outbound network by default. The same per-command helper
481/// approach OpenAI's Codex CLI uses.
482///
483/// Only wraps `world.runner.exec` (shell); in-process fs tools are jailed
484/// separately — see the module docs.
485pub struct SeatbeltSandbox {
486    pub root: PathBuf,
487    pub allow_net: bool,
488    pub confine_writes: bool,
489    pub confine_reads: bool,
490}
491
492impl SeatbeltSandbox {
493    pub fn new(root: impl Into<PathBuf>) -> Self {
494        Self {
495            root: root.into(),
496            allow_net: false,
497            confine_writes: false,
498            confine_reads: false,
499        }
500    }
501    pub fn with_network(mut self, allow: bool) -> Self {
502        self.allow_net = allow;
503        self
504    }
505    pub fn with_confine_writes(mut self, confine: bool) -> Self {
506        self.confine_writes = confine;
507        self
508    }
509    /// Also deny *reads* outside the root (plus the system paths a process
510    /// needs to start at all).
511    ///
512    /// Write confinement alone stops a command corrupting the host, but it
513    /// leaves every readable file on the box in reach — which on a multi-tenant
514    /// server means tenant A's shell can `cat` tenant B's workspace. Isolating
515    /// changes is not the same as isolating capability; if the threat is
516    /// exfiltration rather than damage, this is the switch that matters.
517    ///
518    /// Implies [`with_confine_writes`](Self::with_confine_writes): a filesystem
519    /// you may write but not read back is not a coherent policy, and every
520    /// caller that wants read confinement wants both.
521    pub fn with_confine_reads(mut self, confine: bool) -> Self {
522        self.confine_reads = confine;
523        if confine {
524            self.confine_writes = true;
525        }
526        self
527    }
528}
529
530/// System paths a process must be able to read before it can execute at all —
531/// the dynamic loader, the shared cache, the binaries themselves, and the CA
532/// bundle any networked tool will look for. Denying these does not harden the
533/// sandbox, it just makes every command fail with a linker error.
534///
535/// `/opt` covers Homebrew (`/opt/homebrew` on Apple silicon); `/nix` covers a
536/// Nix-installed toolchain. Both are absent on a stock machine, and a Seatbelt
537/// `subpath` allow for a path that does not exist is inert, so listing them
538/// costs nothing.
539/// Deliberately absent: `/private/tmp` and `/private/var/folders`. They stay
540/// *writable* (the OS and half the toolchain write scratch there unconditionally
541/// and cannot be talked out of it) but not *readable*, because a shared temp
542/// directory that every tenant can read is exactly the exfiltration path read
543/// confinement exists to close. A read-confined sandbox gets its own `TMPDIR`
544/// inside its root instead — see [`SeatbeltRunner`].
545const MACOS_READ_ALLOW: &[&str] = &[
546    "/usr",
547    "/bin",
548    "/sbin",
549    "/System",
550    "/Library",
551    "/opt",
552    "/nix",
553    "/etc",
554    "/private/etc",
555    "/var/select",
556    "/dev",
557    "/private/var/db/dyld",
558];
559
560/// Build a Seatbelt profile (SBPL): `allow default`, then subtract capabilities.
561pub fn seatbelt_profile(
562    root: &Path,
563    allow_net: bool,
564    confine_writes: bool,
565    confine_reads: bool,
566) -> String {
567    let mut p = String::from("(version 1)\n(allow default)\n");
568    if !allow_net {
569        p.push_str("(deny network*)\n");
570    }
571    if confine_writes {
572        p.push_str("(deny file-write*)\n");
573        p.push_str(&format!(
574            "(allow file-write* (subpath \"{}\"))\n",
575            root.display()
576        ));
577        p.push_str(
578            "(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))\n",
579        );
580        p.push_str("(allow file-write* (literal \"/dev/null\") (literal \"/dev/stdout\") (literal \"/dev/stderr\") (literal \"/dev/dtracehelper\"))\n");
581    }
582    if confine_reads {
583        p.push_str("(deny file-read*)\n");
584        // Metadata stays readable everywhere: resolving `<root>/x` walks every
585        // parent directory, so denying stat on `/Users` makes the workspace
586        // itself unreachable — a denial that blocks nothing an attacker wants
587        // (the bytes) while breaking everything the tenant does.
588        p.push_str("(allow file-read-metadata)\n");
589        // `/` itself, and it is not optional: a `subpath` allow for `/usr`
590        // does not cover reading the root *directory entry*, and without it
591        // every process — even `/bin/echo` — dies with SIGABRT before `main`,
592        // silently (no stderr, exit 134). Allowing every top-level directory
593        // individually does not substitute for it. Found the hard way.
594        p.push_str("(allow file-read* (literal \"/\"))\n");
595        p.push_str(&format!(
596            "(allow file-read* (subpath \"{}\"))\n",
597            root.display()
598        ));
599        for path in MACOS_READ_ALLOW {
600            p.push_str(&format!("(allow file-read* (subpath \"{path}\"))\n"));
601        }
602    }
603    p
604}
605
606struct SeatbeltRunner {
607    profile: String,
608    /// Private scratch directory handed to the child as `TMPDIR`, set only when
609    /// reads are confined.
610    ///
611    /// The shared temp dirs stay writable but unreadable under read
612    /// confinement, so a command that writes a scratch file and reads it back —
613    /// which is most of them — would break. Pointing `TMPDIR` inside the
614    /// sandbox root fixes that without reopening the cross-tenant read.
615    private_tmp: Option<PathBuf>,
616}
617
618#[async_trait]
619impl harness_core::ProcessRunner for SeatbeltRunner {
620    async fn exec(
621        &self,
622        program: &str,
623        args: &[&str],
624        cwd: Option<&std::path::Path>,
625    ) -> std::io::Result<harness_core::ProcessOutput> {
626        // sandbox-exec -p <profile> <program> <args...>
627        let mut sb: Vec<String> = vec!["-p".into(), self.profile.clone(), program.into()];
628        sb.extend(args.iter().map(|a| (*a).to_string()));
629        let mut cmd = tokio::process::Command::new("sandbox-exec");
630        cmd.args(&sb);
631        if let Some(t) = &self.private_tmp {
632            std::fs::create_dir_all(t)?;
633            // TMPDIR is what libc and every sane toolchain honour; TMP/TEMP are
634            // there for the ported ones that only look at those.
635            cmd.env("TMPDIR", t).env("TMP", t).env("TEMP", t);
636        }
637        if let Some(c) = cwd {
638            cmd.current_dir(c);
639        }
640        let out = cmd.output().await?;
641        Ok(harness_core::ProcessOutput {
642            status: out.status.code().unwrap_or(-1),
643            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
644            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
645        })
646    }
647}
648
649#[async_trait]
650impl Sandbox for SeatbeltSandbox {
651    fn fs_policy(&self) -> FsPolicy {
652        match (self.confine_reads, self.confine_writes) {
653            (true, _) => FsPolicy::Confined,
654            (false, true) => FsPolicy::HostReadConfinedWrite,
655            (false, false) => FsPolicy::Unrestricted,
656        }
657    }
658    fn net_policy(&self) -> NetPolicy {
659        if self.allow_net {
660            NetPolicy::Allowed
661        } else {
662            NetPolicy::None
663        }
664    }
665    fn isolation(&self) -> Isolation {
666        Isolation::Process
667    }
668
669    async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
670        let probe = tokio::process::Command::new("sandbox-exec")
671            .arg("-p")
672            .arg("(version 1)(allow default)")
673            .arg("/usr/bin/true")
674            .output()
675            .await
676            .map_err(|e| SandboxError::GitMissing(format!("sandbox-exec unavailable: {e}")))?;
677        if !probe.status.success() {
678            return Err(SandboxError::GitMissing(
679                "sandbox-exec probe failed (macOS only)".into(),
680            ));
681        }
682        let world = World {
683            repo: RepoView {
684                root: self.root.clone(),
685            },
686            runner: Arc::new(SeatbeltRunner {
687                profile: seatbelt_profile(
688                    &self.root,
689                    self.allow_net,
690                    self.confine_writes,
691                    self.confine_reads,
692                ),
693                private_tmp: self.confine_reads.then(|| self.root.join(".tmp")),
694            }),
695            clock: Arc::new(harness_context::SystemClock),
696            kv: Arc::new(harness_context::InMemoryKv::new()),
697            profile: harness_core::UserProfile::default(),
698            session: None,
699        };
700        Ok(SandboxHandle {
701            world,
702            root: self.root.clone(),
703            cleanup: None,
704            keep: false,
705            label: "seatbelt".into(),
706        })
707    }
708}
709
710// ============================================================
711// BubblewrapSandbox (Linux, OS-native — no Docker/daemon, per-command)
712// ============================================================
713
714/// **Linux only.** Runs each shell subprocess under
715/// [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) — a separate
716/// sandboxed process per command (parent never restricted). The Linux
717/// counterpart to [`SeatbeltSandbox`]. Network denied by default; with
718/// `confine_writes`, `--ro-bind / /` + `--bind <root> <root>` gives real
719/// read-host / write-workspace enforcement.
720pub struct BubblewrapSandbox {
721    pub root: PathBuf,
722    pub allow_net: bool,
723    pub confine_writes: bool,
724    pub confine_reads: bool,
725}
726
727impl BubblewrapSandbox {
728    pub fn new(root: impl Into<PathBuf>) -> Self {
729        Self {
730            root: root.into(),
731            allow_net: false,
732            confine_writes: true,
733            confine_reads: false,
734        }
735    }
736    pub fn with_network(mut self, allow: bool) -> Self {
737        self.allow_net = allow;
738        self
739    }
740    pub fn with_confine_writes(mut self, confine: bool) -> Self {
741        self.confine_writes = confine;
742        self
743    }
744    /// Also deny *reads* outside the root: bind in the system paths a process
745    /// needs and nothing else, instead of `--ro-bind / /`.
746    ///
747    /// See [`SeatbeltSandbox::with_confine_reads`] for why write confinement on
748    /// its own is not enough on a multi-tenant host. Implies write confinement.
749    pub fn with_confine_reads(mut self, confine: bool) -> Self {
750        self.confine_reads = confine;
751        if confine {
752            self.confine_writes = true;
753        }
754        self
755    }
756}
757
758/// The only host paths bound into a read-confined bubblewrap sandbox — enough
759/// for a dynamically-linked binary to load and resolve TLS, and no more.
760///
761/// Bound with `--ro-bind-try` rather than `--ro-bind`: `/lib64` is absent on
762/// aarch64, `/nix` on anything but NixOS, and `bwrap` treats a missing bind
763/// source as a fatal error. `-try` turns "not on this distro" into a no-op,
764/// which is what it is.
765const LINUX_READ_BIND: &[&str] = &[
766    "/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc", "/opt", "/nix",
767];
768
769/// Build the `bwrap` args before the user's program. Pure → unit-testable, and
770/// verified live in a Linux container (`--unshare-net` blocks network;
771/// `--ro-bind / /` + `--bind <root>` confines writes to the workspace).
772pub fn bwrap_args(
773    root: &Path,
774    allow_net: bool,
775    confine_writes: bool,
776    confine_reads: bool,
777) -> Vec<String> {
778    let r = root.display().to_string();
779    let mut a: Vec<String> = Vec::new();
780    if confine_reads {
781        // Nothing is visible by default; bind in the system tree read-only and
782        // the workspace read-write. The host's home directories — every other
783        // tenant — are simply not in the mount namespace.
784        for path in LINUX_READ_BIND {
785            a.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]);
786        }
787        a.extend(["--bind".into(), r.clone(), r]);
788        // A private /tmp, so scratch files are neither visible to nor from the
789        // host — a shared /tmp is a side channel between tenants.
790        a.extend(["--tmpfs".into(), "/tmp".into()]);
791    } else if confine_writes {
792        a.extend(["--ro-bind".into(), "/".into(), "/".into()]);
793        a.extend(["--bind".into(), r.clone(), r]);
794    } else {
795        a.extend(["--bind".into(), "/".into(), "/".into()]);
796    }
797    a.extend(["--proc".into(), "/proc".into()]);
798    a.extend(["--dev".into(), "/dev".into()]);
799    if !allow_net {
800        a.push("--unshare-net".into());
801    }
802    a
803}
804
805struct BwrapRunner {
806    args: Vec<String>,
807}
808
809#[async_trait]
810impl harness_core::ProcessRunner for BwrapRunner {
811    async fn exec(
812        &self,
813        program: &str,
814        args: &[&str],
815        cwd: Option<&std::path::Path>,
816    ) -> std::io::Result<harness_core::ProcessOutput> {
817        let mut full = self.args.clone();
818        full.push(program.into());
819        full.extend(args.iter().map(|a| (*a).to_string()));
820        let mut cmd = tokio::process::Command::new("bwrap");
821        cmd.args(&full);
822        if let Some(c) = cwd {
823            cmd.current_dir(c);
824        }
825        let out = cmd.output().await?;
826        Ok(harness_core::ProcessOutput {
827            status: out.status.code().unwrap_or(-1),
828            stdout: String::from_utf8_lossy(&out.stdout).to_string(),
829            stderr: String::from_utf8_lossy(&out.stderr).to_string(),
830        })
831    }
832}
833
834#[async_trait]
835impl Sandbox for BubblewrapSandbox {
836    fn fs_policy(&self) -> FsPolicy {
837        match (self.confine_reads, self.confine_writes) {
838            (true, _) => FsPolicy::Confined,
839            (false, true) => FsPolicy::HostReadConfinedWrite,
840            (false, false) => FsPolicy::Unrestricted,
841        }
842    }
843    fn net_policy(&self) -> NetPolicy {
844        if self.allow_net {
845            NetPolicy::Allowed
846        } else {
847            NetPolicy::None
848        }
849    }
850    fn isolation(&self) -> Isolation {
851        Isolation::Process
852    }
853
854    async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
855        let probe = tokio::process::Command::new("bwrap")
856            .arg("--version")
857            .output()
858            .await
859            .map_err(|e| SandboxError::GitMissing(format!("bwrap unavailable: {e}")))?;
860        if !probe.status.success() {
861            return Err(SandboxError::GitMissing(
862                "bwrap probe failed (Linux only)".into(),
863            ));
864        }
865        let runner = Arc::new(BwrapRunner {
866            args: bwrap_args(
867                &self.root,
868                self.allow_net,
869                self.confine_writes,
870                self.confine_reads,
871            ),
872        });
873        let world = World {
874            repo: RepoView {
875                root: self.root.clone(),
876            },
877            runner,
878            clock: Arc::new(harness_context::SystemClock),
879            kv: Arc::new(harness_context::InMemoryKv::new()),
880            profile: harness_core::UserProfile::default(),
881            session: None,
882        };
883        Ok(SandboxHandle {
884            world,
885            root: self.root.clone(),
886            cleanup: None,
887            keep: false,
888            label: "bubblewrap".into(),
889        })
890    }
891}
892
893// ============================================================
894// NullSandbox (for tests & for "I don't actually need isolation")
895// ============================================================
896
897/// A no-op sandbox: returns a World rooted at the host repo, no isolation.
898pub struct NullSandbox {
899    pub root: PathBuf,
900}
901
902impl NullSandbox {
903    pub fn new(root: impl Into<PathBuf>) -> Self {
904        Self { root: root.into() }
905    }
906}
907
908#[async_trait]
909impl Sandbox for NullSandbox {
910    fn fs_policy(&self) -> FsPolicy {
911        FsPolicy::Unrestricted
912    }
913    fn net_policy(&self) -> NetPolicy {
914        NetPolicy::Allowed
915    }
916    fn isolation(&self) -> Isolation {
917        Isolation::None
918    }
919
920    async fn spawn(&self) -> Result<SandboxHandle, SandboxError> {
921        let world = World {
922            repo: RepoView {
923                root: self.root.clone(),
924            },
925            runner: Arc::new(harness_context::TokioRunner),
926            clock: Arc::new(harness_context::SystemClock),
927            kv: Arc::new(harness_context::InMemoryKv::new()),
928            profile: harness_core::UserProfile::default(),
929            session: None,
930        };
931        Ok(SandboxHandle {
932            world,
933            root: self.root.clone(),
934            cleanup: None,
935            keep: false,
936            label: "null".into(),
937        })
938    }
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    #[tokio::test]
946    async fn container_sandbox_fails_cleanly_without_docker() {
947        let s = ContainerSandbox::new("harness-nonexistent-image-xyzzy:latest", ".");
948        // Three ways this can go, all of them "no container was started":
949        // docker is absent, the image pull fails, or — the case that used to
950        // hang this test forever — the CLI is installed but the daemon is down,
951        // and `docker run` waits on it indefinitely. Bound the wait so a stopped
952        // Docker Desktop can't wedge the whole suite.
953        let outcome = tokio::time::timeout(std::time::Duration::from_secs(20), s.spawn()).await;
954        match outcome {
955            Ok(spawned) => assert!(spawned.is_err(), "expected error spawning bogus container"),
956            Err(_elapsed) => { /* daemon never answered — nothing was spawned */ }
957        }
958    }
959
960    #[tokio::test]
961    async fn null_sandbox_returns_world() {
962        let s = NullSandbox::new(".");
963        let h = s.spawn().await.unwrap();
964        assert_eq!(h.label(), "null");
965    }
966
967    #[test]
968    fn seatbelt_profile_denies_network_by_default() {
969        let p = seatbelt_profile(std::path::Path::new("/tmp/x"), false, false, false);
970        assert!(p.contains("(deny network*)"));
971        let allowed = seatbelt_profile(std::path::Path::new("/tmp/x"), true, false, false);
972        assert!(!allowed.contains("(deny network*)"));
973    }
974
975    #[test]
976    fn seatbelt_read_confinement_is_off_unless_asked() {
977        let p = seatbelt_profile(std::path::Path::new("/work"), false, true, false);
978        assert!(
979            !p.contains("(deny file-read*)"),
980            "write confinement must not silently imply read confinement"
981        );
982    }
983
984    #[test]
985    fn seatbelt_read_confinement_allows_root_and_system_paths() {
986        let p = seatbelt_profile(std::path::Path::new("/work/tenant-a"), false, true, true);
987        assert!(p.contains("(deny file-read*)"));
988        assert!(p.contains("(allow file-read* (subpath \"/work/tenant-a\"))"));
989        // Without these a dynamically-linked binary cannot even start.
990        for required in ["/usr", "/System", "/private/var/db/dyld"] {
991            assert!(
992                p.contains(&format!("(allow file-read* (subpath \"{required}\"))")),
993                "missing read allow for {required}"
994            );
995        }
996        // Path resolution walks parents of the root, so metadata must stay open.
997        assert!(p.contains("(allow file-read-metadata)"));
998        // Without the root directory entry itself nothing executes at all.
999        assert!(p.contains("(allow file-read* (literal \"/\"))"));
1000        // The point of the whole exercise: another tenant's tree is not listed.
1001        assert!(!p.contains("/work/tenant-b"));
1002        // Shared scratch is writable but NOT readable — otherwise one tenant's
1003        // temp file is every tenant's temp file.
1004        assert!(p.contains(
1005            "(allow file-write* (subpath \"/private/var/folders\") (subpath \"/private/tmp\"))"
1006        ));
1007        assert!(!p.contains("(allow file-read* (subpath \"/private/tmp\"))"));
1008        assert!(!p.contains("(allow file-read* (subpath \"/private/var/folders\"))"));
1009    }
1010
1011    #[test]
1012    fn seatbelt_confine_reads_implies_confine_writes() {
1013        let s = SeatbeltSandbox::new("/work").with_confine_reads(true);
1014        assert!(s.confine_writes);
1015        assert_eq!(s.fs_policy(), FsPolicy::Confined);
1016        assert_eq!(
1017            SeatbeltSandbox::new("/work")
1018                .with_confine_writes(true)
1019                .fs_policy(),
1020            FsPolicy::HostReadConfinedWrite
1021        );
1022    }
1023
1024    #[test]
1025    fn bwrap_args_deny_net_and_confine_writes_by_default() {
1026        let a = bwrap_args(std::path::Path::new("/work"), false, true, false);
1027        assert!(a.iter().any(|x| x == "--unshare-net"));
1028        assert!(a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]));
1029        assert!(a.windows(3).any(|w| w == ["--bind", "/work", "/work"]));
1030        let net = bwrap_args(std::path::Path::new("/work"), true, true, false);
1031        assert!(!net.iter().any(|x| x == "--unshare-net"));
1032    }
1033
1034    #[test]
1035    fn bwrap_read_confinement_binds_system_paths_only() {
1036        let a = bwrap_args(std::path::Path::new("/work/tenant-a"), false, true, true);
1037        // The whole-root read bind is exactly what read confinement removes.
1038        assert!(
1039            !a.windows(3).any(|w| w == ["--ro-bind", "/", "/"]),
1040            "read-confined sandbox must not bind the whole filesystem"
1041        );
1042        assert!(a.windows(3).any(|w| w == ["--ro-bind-try", "/usr", "/usr"]));
1043        assert!(
1044            a.windows(3)
1045                .any(|w| w == ["--bind", "/work/tenant-a", "/work/tenant-a"])
1046        );
1047        // /home is never bound, so no other tenant exists in the namespace.
1048        assert!(!a.iter().any(|x| x == "/home"));
1049        // Shared /tmp is a cross-tenant side channel; it gets its own tmpfs.
1050        assert!(a.windows(2).any(|w| w == ["--tmpfs", "/tmp"]));
1051    }
1052
1053    #[test]
1054    fn bwrap_confine_reads_implies_confine_writes() {
1055        let s = BubblewrapSandbox::new("/work").with_confine_reads(true);
1056        assert!(s.confine_writes);
1057        assert_eq!(s.fs_policy(), FsPolicy::Confined);
1058    }
1059
1060    #[tokio::test]
1061    async fn bubblewrap_fails_cleanly_off_linux() {
1062        // On macOS `bwrap` is absent → spawn errors, doesn't panic.
1063        let absent = std::process::Command::new("bwrap")
1064            .arg("--version")
1065            .output()
1066            .map(|o| !o.status.success())
1067            .unwrap_or(true);
1068        if absent {
1069            assert!(BubblewrapSandbox::new(".").spawn().await.is_err());
1070        }
1071    }
1072
1073    /// macOS: prove read confinement is enforced by the *kernel*, not just
1074    /// present in the profile string.
1075    ///
1076    /// The scenario is the one that motivated the flag: two tenant directories
1077    /// side by side, a sandbox rooted at one of them, and a command that tries
1078    /// to read the other. Asserting on the SBPL text would pass even if the
1079    /// profile were subtly malformed and Seatbelt ignored it, which is exactly
1080    /// the failure this crate refuses to ship — a policy the kernel does not
1081    /// enforce is not isolation.
1082    #[cfg(target_os = "macos")]
1083    #[tokio::test]
1084    async fn seatbelt_confined_reads_block_a_sibling_tenant() {
1085        let base =
1086            std::env::temp_dir().join(format!("harness-sandbox-reads-{}", std::process::id()));
1087        let mine = base.join("tenant-a");
1088        let theirs = base.join("tenant-b");
1089        std::fs::create_dir_all(&mine).unwrap();
1090        std::fs::create_dir_all(&theirs).unwrap();
1091        let my_file = mine.join("ok.txt");
1092        let their_secret = theirs.join("secret.txt");
1093        std::fs::write(&my_file, "mine\n").unwrap();
1094        std::fs::write(&their_secret, "SECRET\n").unwrap();
1095
1096        // Resolve through symlinks: on macOS the temp dir is /var/... which is a
1097        // symlink to /private/var, and Seatbelt matches on the real path. A
1098        // profile written with the unresolved path denies everything, including
1099        // the workspace — which would make this test pass for the wrong reason.
1100        let real_root = std::fs::canonicalize(&mine).unwrap();
1101        let real_secret = std::fs::canonicalize(&their_secret).unwrap();
1102
1103        let h = SeatbeltSandbox::new(&real_root)
1104            .with_confine_reads(true)
1105            .spawn()
1106            .await
1107            .expect("seatbelt spawns");
1108
1109        // A binary still runs — /usr/bin and the dyld cache stayed readable.
1110        let own = h
1111            .world
1112            .runner
1113            .exec(
1114                "/bin/cat",
1115                &[real_root.join("ok.txt").to_str().unwrap()],
1116                None,
1117            )
1118            .await
1119            .unwrap();
1120        assert_eq!(own.status, 0, "own workspace must stay readable: {own:?}");
1121        assert!(own.stdout.contains("mine"));
1122
1123        // The neighbour's file is not.
1124        let stolen = h
1125            .world
1126            .runner
1127            .exec("/bin/cat", &[real_secret.to_str().unwrap()], None)
1128            .await
1129            .unwrap();
1130        assert_ne!(stolen.status, 0, "sibling tenant read must be denied");
1131        assert!(
1132            !stolen.stdout.contains("SECRET"),
1133            "secret leaked out of the sandbox: {stolen:?}"
1134        );
1135
1136        let _ = std::fs::remove_dir_all(&base);
1137    }
1138
1139    /// macOS: prove Seatbelt enforces net-deny at the kernel via the sandbox's
1140    /// own **per-command** runner — the networked child is blocked, a local child
1141    /// works, and (unlike birdcage) the parent process is never touched.
1142    #[cfg(target_os = "macos")]
1143    #[tokio::test]
1144    async fn seatbelt_denies_child_network_without_touching_parent() {
1145        let curl = "/usr/bin/curl";
1146        if !std::path::Path::new(curl).exists() {
1147            return;
1148        }
1149        let probe = ["-s", "-m", "6", "-o", "/dev/null", "https://example.com"];
1150        let parent_net = || {
1151            std::process::Command::new(curl)
1152                .args(probe)
1153                .output()
1154                .map(|o| o.status.code().unwrap_or(-1))
1155                .unwrap_or(-1)
1156        };
1157        if parent_net() != 0 {
1158            return; // offline — skip
1159        }
1160
1161        let h = SeatbeltSandbox::new(".")
1162            .spawn()
1163            .await
1164            .expect("seatbelt spawns");
1165        // local child ok
1166        let echo = h
1167            .world
1168            .runner
1169            .exec("/bin/echo", &["hi"], None)
1170            .await
1171            .unwrap();
1172        assert_eq!(echo.status, 0);
1173        // networked child blocked
1174        let blocked = h.world.runner.exec(curl, &probe, None).await.unwrap();
1175        assert_ne!(blocked.status, 0, "child network must be denied");
1176        // Parent still unrestricted — the whole point versus birdcage.
1177        //
1178        // This is a live network call, so a single failure has two possible
1179        // causes and they are not equally likely: if the sandbox had leaked into
1180        // the parent, the profile is applied to the process and *every* attempt
1181        // fails; a network hiccup does not repeat. Under a full parallel test
1182        // run this assertion failed on a blip and blamed the sandbox. Requiring
1183        // one success out of three separates the two.
1184        let unrestricted = (0..3).any(|_| parent_net() == 0);
1185        assert!(unrestricted, "parent must remain unsandboxed");
1186    }
1187
1188    #[tokio::test]
1189    async fn worktree_sandbox_spawns_and_cleans_up() {
1190        // Skip unless we're inside a git repo.
1191        let probe = std::process::Command::new("git")
1192            .args(["rev-parse", "--show-toplevel"])
1193            .output();
1194        let Ok(out) = probe else {
1195            return;
1196        };
1197        if !out.status.success() {
1198            return;
1199        }
1200        let src = String::from_utf8_lossy(&out.stdout).trim().to_string();
1201        let branch = format!("harness-test-{}", std::process::id());
1202        let s = WorktreeSandbox::new(&src, &branch);
1203        let handle = s.spawn().await.expect("worktree spawns");
1204        assert!(handle.root().exists(), "worktree directory exists");
1205        assert!(handle.label().starts_with("worktree:"));
1206        let root = handle.root().to_path_buf();
1207        drop(handle);
1208        // Cleanup should remove the directory.
1209        // (Best-effort: give git a moment on slow filesystems.)
1210        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), async {
1211            while root.exists() {
1212                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1213            }
1214        })
1215        .await;
1216    }
1217}