Skip to main content

speccoder_runtime/
sandbox.rs

1//! The OS sandbox for commands speccoder starts.
2//!
3//! Checks, the model's `run_command`, the formatter, acceptance tests and MCP
4//! servers run code the model may have written. The policy decides *which*
5//! commands run; the sandbox confines *what they do*:
6//!
7//! - writes only to the workspace, a private temporary directory and the
8//!   package caches builds need (never the rest of the state directory —
9//!   approvals live there — and never `~/.cargo/bin`);
10//! - credentials unreadable (`~/.ssh`, `~/.aws`, `~/.cargo/credentials*`, …);
11//! - no network beyond loopback, unless the command is allowed it
12//!   (exactly, `[sandbox] network_allow`, or by a prefix in
13//!   `[sandbox] network_allow_prefix`) or a person approved it.
14//!
15//! The [procage](https://github.com/hupe1980/procage) library confines
16//! every command (module `sandbox::procage`): Seatbelt on macOS, Landlock,
17//! seccomp and, where the kernel allows user namespaces, mounts on Linux.
18//! Commands that run to completion are spawned in this process; long-running
19//! servers start through the `speccoder` binary as a helper (`__sandbox`),
20//! which confines itself and becomes the server. Where the host cannot
21//! enforce part of the profile, that part is left out by name and the
22//! runner says so.
23//!
24//! When a sandboxed command fails, its outcome carries what the sandbox
25//! refused it (module `denials`): shown under a failed check, in the next
26//! attempt's digest and in the model's `run_command` result. Linux lists
27//! refused system calls only; macOS lists every denial it logged, which
28//! under heavy load is not every denial.
29
30use std::path::{Path, PathBuf};
31use std::sync::OnceLock;
32
33use serde::{Deserialize, Serialize};
34
35use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
36
37#[cfg(any(target_os = "macos", target_os = "linux"))]
38mod denials;
39#[cfg(any(target_os = "macos", target_os = "linux"))]
40pub mod procage;
41
42/// What a sandboxed command may reach on the network.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Network {
46    /// Nothing (on Linux also not loopback).
47    Off,
48    /// Loopback only: local test servers work, the outside does not. On
49    /// Linux this is [`Network::Off`] (seccomp cannot tell addresses apart).
50    Loopback,
51    /// Everything.
52    On,
53}
54
55/// Whether to sandbox.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Mode {
58    /// Sandbox where the OS supports it; otherwise run unsandboxed and say so.
59    Auto,
60    /// Refuse to run commands unsandboxed.
61    Required,
62    /// Do not sandbox.
63    Off,
64}
65
66/// `[sandbox]` in `speccoder.toml`.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Config {
69    /// Whether to sandbox.
70    pub mode: Mode,
71    /// The network for every command.
72    pub network: Network,
73    /// Exact commands that get the network (`[["cargo", "fetch"]]`).
74    pub network_allow: Vec<Vec<String>>,
75    /// Command prefixes (two elements or more) that get the network.
76    pub network_allow_prefix: Vec<Vec<String>>,
77    /// More writable paths (`~` is the home directory).
78    pub writable: Vec<String>,
79}
80
81impl Default for Config {
82    fn default() -> Self {
83        Self {
84            mode: Mode::Auto,
85            network: Network::Loopback,
86            network_allow: Vec::new(),
87            network_allow_prefix: Vec::new(),
88            writable: Vec::new(),
89        }
90    }
91}
92
93#[derive(Debug, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95struct Raw {
96    mode: Option<String>,
97    network: Option<String>,
98    #[serde(default)]
99    network_allow: Vec<Vec<String>>,
100    #[serde(default)]
101    network_allow_prefix: Vec<Vec<String>>,
102    #[serde(default)]
103    writable: Vec<String>,
104}
105
106/// Reads `[sandbox]`.
107pub fn parse(text: &str) -> Result<Config, String> {
108    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
109    let Some(v) = table.get("sandbox") else {
110        return Ok(Config::default());
111    };
112    let raw: Raw = v
113        .clone()
114        .try_into()
115        .map_err(|e| format!("[sandbox]: {e}"))?;
116    let mode = match raw.mode.as_deref() {
117        None | Some("auto") => Mode::Auto,
118        Some("required") => Mode::Required,
119        Some("off") => Mode::Off,
120        Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
121    };
122    let network = match raw.network.as_deref() {
123        None | Some("loopback") => Network::Loopback,
124        Some("off") => Network::Off,
125        Some("on") => Network::On,
126        Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
127    };
128    if let Some(why) = raw
129        .network_allow_prefix
130        .iter()
131        .find_map(|p| crate::tools::policy::prefix_refusal(p))
132    {
133        return Err(format!("[sandbox] network_allow_prefix: {why}"));
134    }
135    Ok(Config {
136        mode,
137        network,
138        network_allow: raw
139            .network_allow
140            .into_iter()
141            .filter(|p| !p.is_empty())
142            .collect(),
143        network_allow_prefix: raw.network_allow_prefix,
144        writable: raw.writable,
145    })
146}
147
148/// Credentials a command never reads, relative to the home directory.
149pub const HIDDEN: &[&str] = &[
150    ".ssh",
151    ".aws",
152    ".azure",
153    ".gnupg",
154    ".netrc",
155    ".docker",
156    ".kube",
157    ".npmrc",
158    ".pypirc",
159    ".git-credentials",
160    ".password-store",
161    ".config/gh",
162    ".config/gcloud",
163    ".config/hub",
164    ".cargo/credentials",
165    ".cargo/credentials.toml",
166    ".local/share/keyrings",
167    "Library/Keychains",
168];
169
170/// Package caches builds write to, relative to the home directory. Not
171/// `~/.cargo` as a whole: `~/.cargo/bin` would let a test replace `cargo`.
172pub const CACHES: &[&str] = &[
173    ".cargo/registry",
174    ".cargo/git",
175    ".cargo/.package-cache",
176    ".cargo/.package-cache-mutate",
177    ".cargo/.global-cache",
178    ".cache",
179    ".npm",
180    ".pnpm-store",
181    ".yarn/berry/cache",
182    "go/pkg/mod",
183    "Library/Caches",
184];
185
186/// One command's confinement.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Profile {
190    /// Writable trees (the workspace first).
191    pub writable: Vec<PathBuf>,
192    /// Unreadable trees.
193    pub hidden: Vec<PathBuf>,
194    /// Trees inside the writable ones that stay read-only (in place: the
195    /// checkout's `.git`).
196    pub read_only: Vec<PathBuf>,
197    /// The network.
198    pub network: Network,
199    /// The private temporary directory (`TMPDIR`), writable.
200    pub tmp: PathBuf,
201    /// What this host cannot enforce and the profile leaves out
202    /// (`sandbox::procage::fit`).
203    #[serde(default)]
204    pub unenforced: Unenforced,
205}
206
207/// Parts of a profile left out: because the host cannot enforce them (Linux
208/// without user namespaces or with an older kernel; the runner's notice
209/// names each), or on purpose
210/// (the person's own commands may write git hooks and agent configuration).
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Unenforced {
214    /// Git hooks and shell startup files in writable trees stay writable.
215    pub trust_handoff: bool,
216    /// Unix sockets in writable trees are not reachable either.
217    pub workspace_sockets: bool,
218    /// Commands can signal every process of the user, not only their own
219    /// (Linux older than 6.12 cannot keep signals within the run).
220    pub signal_scope: bool,
221    /// In place: `.git` and ignored files could not be carved out, so
222    /// commands can write the one and read the other.
223    pub carve: bool,
224}
225
226fn canonical(p: &Path) -> PathBuf {
227    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
228}
229
230impl Profile {
231    /// The profile for commands working in `root`, for the home directory `home`.
232    #[must_use]
233    pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
234        let root = canonical(root);
235        let expand = |p: &str| -> Option<PathBuf> {
236            match (p.strip_prefix("~/"), home) {
237                (Some(rest), Some(h)) => Some(h.join(rest)),
238                (Some(_), None) => None,
239                (None, _) => Some(PathBuf::from(p)),
240            }
241        };
242        let home = home.map(canonical);
243        let home = home.as_deref();
244        let mut writable = vec![root.clone()];
245        writable.extend(
246            home.into_iter()
247                .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
248        );
249        writable.extend(
250            config
251                .writable
252                .iter()
253                .filter_map(|p| expand(p))
254                .map(|p| canonical(&p)),
255        );
256        let hidden = home
257            .into_iter()
258            .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
259            .collect();
260        let digest = {
261            use sha2::{Digest as _, Sha256};
262            let d = Sha256::digest(root.to_string_lossy().as_bytes());
263            d.iter()
264                .take(6)
265                .map(|b| format!("{b:02x}"))
266                .collect::<String>()
267        };
268        // Short (Unix socket paths are limited to ~104 bytes) and per workspace.
269        let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
270        Self {
271            writable,
272            hidden,
273            read_only: Vec::new(),
274            network: config.network,
275            tmp,
276            unenforced: Unenforced::default(),
277        }
278    }
279
280    /// The same profile with `read_only` trees carved out of the writable
281    /// ones, and `hidden` added to the unreadable ones.
282    #[must_use]
283    pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
284        self.read_only
285            .extend(read_only.iter().map(|p| canonical(p)));
286        self.hidden.extend(hidden.iter().map(|p| canonical(p)));
287        self
288    }
289
290    /// The same profile with another network.
291    #[must_use]
292    pub fn with_network(mut self, network: Network) -> Self {
293        self.network = network;
294        self
295    }
296}
297
298/// This machine's sandbox: procage, which confines with Seatbelt on macOS
299/// and Landlock and seccomp on Linux. Long-running servers start through the
300/// `speccoder` binary at `helper`, when there is one (commands that run to
301/// completion need none).
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct Backend {
304    /// The `speccoder` binary that serves as the helper.
305    pub helper: Option<PathBuf>,
306}
307
308impl Backend {
309    /// The OS mechanism, for people and run records: `Seatbelt` or
310    /// `Landlock+seccomp`.
311    #[must_use]
312    pub fn mechanism(&self) -> &'static str {
313        if cfg!(target_os = "macos") {
314            "Seatbelt"
315        } else {
316            "Landlock+seccomp"
317        }
318    }
319}
320
321/// The helper subcommand (`speccoder __sandbox <profile> -- argv…`).
322pub const HELPER_ARG: &str = "__sandbox";
323
324/// The sandbox this machine offers, or why there is none.
325pub fn backend() -> Result<Backend, String> {
326    static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
327    CACHED.get_or_init(detect).clone()
328}
329
330fn detect() -> Result<Backend, String> {
331    available()?;
332    Ok(Backend {
333        helper: helper_binary(),
334    })
335}
336
337/// Whether this machine can sandbox commands at all (whatever process asks).
338///
339/// # Errors
340///
341/// Why not.
342pub fn available() -> Result<(), String> {
343    #[cfg(any(target_os = "macos", target_os = "linux"))]
344    return procage::available();
345    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
346    Err(format!("no sandbox for {}", std::env::consts::OS))
347}
348
349/// The `speccoder` binary that serves as the helper: `SPECCODER_SANDBOX_HELPER`,
350/// or this executable when it is `speccoder`.
351fn helper_binary() -> Option<PathBuf> {
352    std::env::var_os("SPECCODER_SANDBOX_HELPER")
353        .map(PathBuf::from)
354        .or_else(|| {
355            std::env::current_exe()
356                .ok()
357                .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
358        })
359}
360
361/// The profile this machine enforces for `profile`, and a notice for what it
362/// leaves out.
363///
364/// # Errors
365///
366/// Why this machine cannot confine the profile at all.
367pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
368    #[cfg(any(target_os = "macos", target_os = "linux"))]
369    return procage::fit(profile);
370    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
371    {
372        let _ = profile;
373        Err(format!("no sandbox for {}", std::env::consts::OS))
374    }
375}
376
377/// `argv` confined by `p` through the helper on `backend`.
378pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
379    // Without the helper a server cannot be confined: never start it plain.
380    let helper = backend.helper.as_ref().ok_or_else(|| {
381        "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
382            .to_owned()
383    })?;
384    let mut out = vec![
385        helper.to_string_lossy().into_owned(),
386        HELPER_ARG.to_owned(),
387        serde_json::to_string(p).map_err(|e| e.to_string())?,
388        "--".to_owned(),
389    ];
390    out.extend_from_slice(argv);
391    Ok(out)
392}
393
394/// A runner that confines every command.
395#[derive(Debug, Clone)]
396pub struct Sandboxed {
397    /// The confinement.
398    pub profile: Profile,
399    /// The OS mechanism.
400    pub backend: Backend,
401    /// Exact commands that get the network.
402    pub network_allow: Vec<Vec<String>>,
403    /// Prefixes (two elements or more) that get it.
404    pub network_allow_prefix: Vec<Vec<String>>,
405}
406
407impl Sandboxed {
408    /// The network `argv` gets: its profile's, or all of it for an allowed
409    /// command (exactly, or extending an allowed prefix).
410    #[must_use]
411    pub fn network_for(&self, argv: &[String]) -> Network {
412        if self.network_allow.iter().any(|p| p.as_slice() == argv)
413            || self
414                .network_allow_prefix
415                .iter()
416                .any(|p| crate::tools::policy::prefix_matches(p, argv))
417        {
418            Network::On
419        } else {
420            self.profile.network
421        }
422    }
423
424    /// Runs `inv` with `network` instead of the profile's.
425    #[must_use]
426    pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
427        let profile = self.profile.clone().with_network(network);
428        let failed = |e: String| Outcome {
429            exit_code: None,
430            timed_out: false,
431            spawn_error: Some(e),
432            started: std::time::SystemTime::now(),
433            duration: std::time::Duration::ZERO,
434            stdout: crate::verify::process::Captured::default(),
435            stderr: crate::verify::process::Captured::default(),
436            sandbox: Default::default(),
437        };
438        // Through the wrapper a missing program would be the wrapper's
439        // failure; report it as the plain runner does.
440        if let Some(program) = inv.argv.first()
441            && !resolvable(program, &inv.cwd, &inv.env)
442        {
443            return failed(format!(
444                "could not start `{program}`: No such file or directory"
445            ));
446        }
447        if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
448            return failed(format!("the sandbox's temporary directory: {e}"));
449        }
450        let tmp = profile.tmp.to_string_lossy().into_owned();
451        let mut env: Vec<(String, String)> = inv
452            .env
453            .iter()
454            .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
455            .cloned()
456            .collect();
457        env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
458        #[cfg(any(target_os = "macos", target_os = "linux"))]
459        return procage::run(&profile, &Invocation { env, ..inv.clone() });
460        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
461        {
462            let _ = env;
463            failed(format!("no sandbox for {}", std::env::consts::OS))
464        }
465    }
466}
467
468/// The executable `program` runs as in `cwd` with `env`: a program with a
469/// `/` against `cwd`, else the first file of that name on `PATH` (relative
470/// `PATH` entries against `cwd` too) — canonicalised; `None` when there is
471/// none. A file without an execute bit is skipped, as `execvp` skips it:
472/// the executable named is the one that runs. What a command's approval
473/// names, and what it is checked against again when it runs.
474#[must_use]
475pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
476    let candidate = |p: PathBuf| {
477        executable_file(&p)
478            .then(|| std::fs::canonicalize(&p).ok())
479            .flatten()
480    };
481    if program.is_empty() {
482        return None;
483    }
484    if program.contains('/') {
485        return candidate(cwd.join(program));
486    }
487    let path = env
488        .iter()
489        .find(|(k, _)| k == "PATH")
490        .map(|(_, v)| v.as_str());
491    path.unwrap_or("/usr/bin:/bin")
492        .split(':')
493        .filter(|d| !d.is_empty())
494        .find_map(|d| candidate(cwd.join(d).join(program)))
495}
496
497/// A regular file (through symlinks) that someone may execute.
498fn executable_file(p: &Path) -> bool {
499    #[cfg(unix)]
500    {
501        use std::os::unix::fs::PermissionsExt as _;
502        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
503    }
504    #[cfg(not(unix))]
505    {
506        p.is_file()
507    }
508}
509
510/// Whether `program` names an executable file, directly or on `PATH`.
511fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
512    resolve_executable(program, cwd, env).is_some()
513}
514
515impl CommandRunner for Sandboxed {
516    fn run(&self, inv: &Invocation) -> Outcome {
517        self.run_with_network(inv, self.network_for(&inv.argv))
518    }
519}
520
521/// The runner for commands working in `root`: sandboxed when configured and
522/// possible; otherwise the plain runner and a notice (or, with `required`,
523/// an error).
524pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
525    if config.mode == Mode::Off {
526        return Ok((
527            Runner::Plain,
528            Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
529        ));
530    }
531    let home = std::env::var_os("HOME").map(PathBuf::from);
532    let profile = Profile::new(root, config, home.as_deref());
533    // What this machine cannot enforce is left out by name; anything else it
534    // cannot confine means no sandbox, and `mode` decides.
535    match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
536        Ok((backend, (profile, notice))) => Ok((
537            Runner::Sandboxed(Sandboxed {
538                profile,
539                backend,
540                network_allow: config.network_allow.clone(),
541                network_allow_prefix: config.network_allow_prefix.clone(),
542            }),
543            notice,
544        )),
545        Err(e) if config.mode == Mode::Required => Err(format!(
546            "[sandbox] mode = \"required\", but there is no sandbox: {e}"
547        )),
548        Err(e) => Ok((
549            Runner::Plain,
550            Some(format!("commands run unsandboxed: {e}")),
551        )),
552    }
553}
554
555/// What `[approvals] contained` comes to at `root`, in one line: the
556/// commands the sandbox confines run without asking, or why every command
557/// that is not a check asks — what `init`, `new` and `doctor` say.
558#[must_use]
559pub fn contained_at(root: &Path) -> (bool, String) {
560    let text = crate::verify::find_config(root)
561        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
562        .unwrap_or_default();
563    let allowed = crate::substrate::run_config::parse(&text).is_ok_and(|c| c.contained);
564    if !allowed {
565        return (
566            true,
567            "every command that is not a check asks ([approvals] contained = \"ask\")".to_owned(),
568        );
569    }
570    match runner_at(root).map(|(r, _)| r.confines()) {
571        Ok(Some(m)) => (
572            true,
573            format!(
574                "commands the sandbox confines run without asking ({m}) — [approvals] contained = \"ask\" to be asked"
575            ),
576        ),
577        Ok(None) | Err(_) => (
578            false,
579            "every command that is not a check asks: no sandbox confines them here".to_owned(),
580        ),
581    }
582}
583
584/// [`runner`] with `[sandbox]` read from the `speccoder.toml` that governs `root`.
585pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
586    let text = crate::verify::find_config(root)
587        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
588        .unwrap_or_default();
589    let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
590    runner(root, &config)
591}
592
593impl Runner {
594    /// In place: `read_only` and `hidden` carved out of the profile, and
595    /// what the person must know when this machine cannot. A plain runner
596    /// confines nothing, so there is nothing to carve.
597    #[must_use]
598    pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
599        match self {
600            Self::Sandboxed(mut s) => {
601                let carved = s.profile.clone().carve(read_only, hidden);
602                match fit(&carved) {
603                    Ok((fitted, notice)) => {
604                        s.profile = fitted;
605                        (Self::Sandboxed(s), notice)
606                    }
607                    Err(e) => (
608                        Self::Sandboxed({
609                            s.profile.unenforced.carve = true;
610                            s
611                        }),
612                        Some(format!(
613                            "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
614                        )),
615                    ),
616                }
617            }
618            Self::Plain => (Self::Plain, None),
619        }
620    }
621}
622
623/// A sandboxed or a plain runner.
624#[derive(Debug, Clone)]
625pub enum Runner {
626    /// Confined.
627    Sandboxed(Sandboxed),
628    /// Not confined.
629    Plain,
630}
631
632impl Runner {
633    /// Whether commands are confined (the network rule then matters).
634    #[must_use]
635    pub fn is_sandboxed(&self) -> bool {
636        matches!(self, Self::Sandboxed(_))
637    }
638
639    /// The mechanism that confines a command to the worktree — writes
640    /// there, no network beyond loopback, no signal to other processes,
641    /// `.git` carved out in place — when this runner does: a call it
642    /// confines needs nobody's approval (`[approvals] contained`).
643    #[must_use]
644    pub fn confines(&self) -> Option<&'static str> {
645        match self {
646            Self::Sandboxed(s)
647                if s.profile.network != Network::On
648                    && !s.profile.unenforced.signal_scope
649                    && !s.profile.unenforced.carve =>
650            {
651                Some(s.backend.mechanism())
652            }
653            _ => None,
654        }
655    }
656
657    /// The same runner, with git hooks, git config and agent configuration
658    /// (the trust-handoff names) writable in its trees: for the tools that
659    /// create a project (`git init`, a format's init), never for the model's
660    /// commands.
661    #[must_use]
662    pub fn with_trust_handoff_writable(self) -> Self {
663        match self {
664            Self::Sandboxed(mut s) => {
665                s.profile.unenforced.trust_handoff = true;
666                Self::Sandboxed(s)
667            }
668            Self::Plain => Self::Plain,
669        }
670    }
671
672    /// The same runner, `dir` writable too.
673    #[must_use]
674    pub fn with_writable(self, dir: &Path) -> Self {
675        match self {
676            Self::Sandboxed(mut s) => {
677                s.profile.writable.push(canonical(dir));
678                Self::Sandboxed(s)
679            }
680            Self::Plain => Self::Plain,
681        }
682    }
683
684    /// Whether `argv` gets the network by `[sandbox] network_allow` (or a
685    /// prefix): never for a plain runner, which asks nobody.
686    #[must_use]
687    pub fn allows_network(&self, argv: &[String]) -> bool {
688        matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
689    }
690
691    /// Runs `inv`; `network` (a person's approval) widens the network.
692    #[must_use]
693    pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
694        match self {
695            Self::Plain => LocalRunner.run(inv),
696            Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
697            Self::Sandboxed(s) => s.run(inv),
698        }
699    }
700
701    /// Where commands may write scratch files: the sandbox's private
702    /// temporary directory (created), or `None` unsandboxed.
703    #[must_use]
704    pub fn scratch(&self) -> Option<PathBuf> {
705        match self {
706            Self::Plain => None,
707            Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
708                .ok()
709                .map(|()| s.profile.tmp.clone()),
710        }
711    }
712
713    /// `argv` as it will be started (for long-running servers started elsewhere).
714    pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
715        match self {
716            Self::Plain => Ok((argv.to_vec(), None)),
717            Self::Sandboxed(s) => {
718                let profile = s.profile.clone().with_network(s.network_for(argv));
719                std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
720                Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
721            }
722        }
723    }
724}
725
726impl CommandRunner for Runner {
727    fn run(&self, inv: &Invocation) -> Outcome {
728        self.run_with(inv, false)
729    }
730}
731
732/// The helper's entry (`speccoder __sandbox <profile> -- argv…`): confines
733/// this process and becomes the command; returns only on failure.
734pub fn helper_main(args: &[String]) -> String {
735    let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
736        return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
737    };
738    let profile: Profile = match serde_json::from_str(profile) {
739        Ok(p) => p,
740        Err(e) => return format!("the sandbox profile: {e}"),
741    };
742    let argv = &args[2..];
743    if argv.is_empty() {
744        return "nothing to run".to_owned();
745    }
746    #[cfg(any(target_os = "macos", target_os = "linux"))]
747    return procage::exec(&profile, argv);
748    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
749    format!("no sandbox for {}", std::env::consts::OS)
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn config_parses_and_defaults_to_loopback() {
758        assert_eq!(parse("").unwrap_or_default(), Config::default());
759        let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
760            .unwrap_or_default();
761        assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
762        assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
763    }
764
765    #[test]
766    fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
767        let home = Path::new("/home/u");
768        let p = Profile::new(
769            Path::new("/s/proj/worktrees/001"),
770            &Config::default(),
771            Some(home),
772        );
773        assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
774        assert!(
775            !p.writable
776                .iter()
777                .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
778        );
779        assert!(p.hidden.contains(&home.join(".ssh")));
780        assert!(p.writable.contains(&home.join(".cargo/registry")));
781        assert_eq!(p.unenforced, Unenforced::default());
782    }
783
784    /// A profile written before the signal scope could be left out
785    /// enforces it; a fitted one keeps the choice through the helper.
786    #[test]
787    fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
788        let p = Profile::new(Path::new("/w"), &Config::default(), None);
789        let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
790        json["unenforced"] =
791            serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
792        let old: Profile = serde_json::from_value(json).unwrap();
793        assert!(!old.unenforced.signal_scope);
794        let mut fitted = p.clone();
795        fitted.unenforced.signal_scope = true;
796        let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
797        assert!(back.unenforced.signal_scope);
798    }
799
800    #[test]
801    fn the_executable_is_the_first_on_the_path_canonicalised() {
802        use std::os::unix::fs::PermissionsExt;
803        let dir = tempfile::tempdir().unwrap();
804        let root = std::fs::canonicalize(dir.path()).unwrap();
805        for d in ["bin", "usr"] {
806            std::fs::create_dir_all(root.join(d)).unwrap();
807            let f = root.join(d).join("make");
808            std::fs::write(&f, "#!/bin/sh\n").unwrap();
809            std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
810        }
811        let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
812        let usr = format!("{}", root.join("usr").display());
813        assert_eq!(
814            resolve_executable("make", &root, &env(&usr)),
815            Some(root.join("usr/make"))
816        );
817        // A worktree directory earlier on the path shadows it.
818        assert_eq!(
819            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
820            Some(root.join("bin/make"))
821        );
822        assert_eq!(
823            resolve_executable("./bin/make", &root, &env("")),
824            Some(root.join("bin/make"))
825        );
826        assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
827        assert!(!resolvable("nope", &root, &env(&usr)));
828        // A file without an execute bit is skipped, as execvp skips it.
829        std::fs::set_permissions(
830            root.join("bin/make"),
831            std::fs::Permissions::from_mode(0o644),
832        )
833        .unwrap();
834        assert_eq!(
835            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
836            Some(root.join("usr/make"))
837        );
838        assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
839    }
840
841    /// The experimental `engine` key is gone: procage is the only engine.
842    #[test]
843    fn the_engine_key_is_refused_by_name() {
844        let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
845        assert!(e.contains("engine"), "{e}");
846    }
847
848    /// A profile this machine cannot confine at all (here: a writable path
849    /// with a control character, which Seatbelt cannot express) means no
850    /// sandbox: `auto` runs commands plain and says why, `required` refuses.
851    #[cfg(target_os = "macos")]
852    #[test]
853    fn an_unconfinable_profile_follows_the_mode() {
854        if backend().is_err() {
855            return; // no sandbox on this machine at all
856        }
857        let dir = tempfile::tempdir().unwrap();
858        let odd = dir.path().join("procage\u{1}odd");
859        std::fs::create_dir_all(&odd).unwrap();
860        let mut config = Config {
861            writable: vec![odd.to_string_lossy().into_owned()],
862            ..Config::default()
863        };
864        let (plain, notice) = runner(dir.path(), &config).unwrap();
865        assert!(!plain.is_sandboxed());
866        assert!(
867            notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
868            "{plain:?}"
869        );
870        config.mode = Mode::Required;
871        let e = runner(dir.path(), &config).unwrap_err();
872        assert!(e.contains("mode = \"required\""), "{e}");
873    }
874
875    #[test]
876    fn a_confinable_profile_is_sandboxed() {
877        let dir = tempfile::tempdir().unwrap();
878        let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
879            assert!(
880                backend().is_err(),
881                "a sandbox exists but the runner is plain"
882            );
883            return;
884        };
885        assert_eq!(s.backend, backend().unwrap());
886    }
887
888    #[test]
889    fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
890        let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
891        let s = Sandboxed {
892            profile: Profile::new(Path::new("/w"), &c, None),
893            backend: Backend { helper: None },
894            network_allow: c.network_allow.clone(),
895            network_allow_prefix: c.network_allow_prefix.clone(),
896        };
897        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
898        assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
899        assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
900        assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
901        assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
902    }
903}