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    /// The person's shared package caches ([`CACHES`] under home): read by
202    /// every command, written only by one that fetches ([`Profile::fetching`]).
203    #[serde(default, skip_serializing_if = "Vec::is_empty")]
204    pub caches: Vec<PathBuf>,
205    /// What this host cannot enforce and the profile leaves out
206    /// (`sandbox::procage::fit`).
207    #[serde(default)]
208    pub unenforced: Unenforced,
209}
210
211/// Parts of a profile left out: because the host cannot enforce them (Linux
212/// without user namespaces or with an older kernel; the runner's notice
213/// names each), or on purpose
214/// (the person's own commands may write git hooks and agent configuration).
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(deny_unknown_fields, default)]
217pub struct Unenforced {
218    /// Git hooks and shell startup files in writable trees stay writable.
219    pub trust_handoff: bool,
220    /// Unix sockets in writable trees are not reachable either.
221    pub workspace_sockets: bool,
222    /// Commands can signal every process of the user, not only their own
223    /// (Linux older than 6.12 cannot keep signals within the run).
224    pub signal_scope: bool,
225    /// In place: `.git` and ignored files could not be carved out, so
226    /// commands can write the one and read the other.
227    pub carve: bool,
228}
229
230fn canonical(p: &Path) -> PathBuf {
231    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
232}
233
234impl Profile {
235    /// The profile for commands working in `root`, for the home directory `home`.
236    #[must_use]
237    pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
238        let root = canonical(root);
239        let expand = |p: &str| -> Option<PathBuf> {
240            match (p.strip_prefix("~/"), home) {
241                (Some(rest), Some(h)) => Some(h.join(rest)),
242                (Some(_), None) => None,
243                (None, _) => Some(PathBuf::from(p)),
244            }
245        };
246        let home = home.map(canonical);
247        let home = home.as_deref();
248        let mut writable = vec![root.clone()];
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        let profile = Self {
271            writable,
272            hidden,
273            read_only: Vec::new(),
274            network: Network::Off,
275            tmp,
276            caches: home
277                .into_iter()
278                .flat_map(|h| CACHES.iter().map(move |c| h.join(c)))
279                .collect(),
280            unenforced: Unenforced::default(),
281        };
282        profile.with_network(config.network)
283    }
284
285    /// The same profile with `read_only` trees carved out of the writable
286    /// ones, and `hidden` added to the unreadable ones.
287    #[must_use]
288    pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
289        self.read_only
290            .extend(read_only.iter().map(|p| canonical(p)));
291        self.hidden.extend(hidden.iter().map(|p| canonical(p)));
292        self
293    }
294
295    /// The same profile with another network. A command that may reach
296    /// the network fetches: it may write the shared caches too.
297    #[must_use]
298    pub fn with_network(mut self, network: Network) -> Self {
299        self.network = network;
300        if network == Network::On {
301            self = self.fetching();
302        }
303        self
304    }
305
306    /// The same profile, the shared caches writable: for what fills them —
307    /// a command with the network, speccoder's `prepare`.
308    #[must_use]
309    pub fn fetching(mut self) -> Self {
310        for c in &self.caches {
311            if !self.writable.contains(c) {
312                self.writable.push(c.clone());
313            }
314        }
315        self
316    }
317
318    /// The environment a command in this profile starts with, beside its
319    /// own: the private `TMPDIR`, and — unless it fetches — a private cache
320    /// for what tools derive (`XDG_CACHE_HOME`, `UV_CACHE_DIR`, `GOCACHE`),
321    /// since the shared caches are read-only to it.
322    #[must_use]
323    pub fn private_env(&self) -> Vec<(String, String)> {
324        let tmp = self.tmp.to_string_lossy().into_owned();
325        let mut env: Vec<(String, String)> = ["TMPDIR", "TMP", "TEMP"]
326            .map(|k| (k.to_owned(), tmp.clone()))
327            .to_vec();
328        let fetching = self.caches.iter().all(|c| self.writable.contains(c));
329        if !fetching {
330            let cache = self.tmp.join("cache");
331            for (k, sub) in [
332                ("XDG_CACHE_HOME", ""),
333                ("UV_CACHE_DIR", "uv"),
334                ("GOCACHE", "go-build"),
335            ] {
336                let dir = if sub.is_empty() {
337                    cache.clone()
338                } else {
339                    cache.join(sub)
340                };
341                let _ = std::fs::create_dir_all(&dir);
342                env.push((k.to_owned(), dir.to_string_lossy().into_owned()));
343            }
344        }
345        env
346    }
347}
348
349/// This machine's sandbox: procage, which confines with Seatbelt on macOS
350/// and Landlock and seccomp on Linux. Long-running servers start through the
351/// `speccoder` binary at `helper`, when there is one (commands that run to
352/// completion need none).
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct Backend {
355    /// The `speccoder` binary that serves as the helper.
356    pub helper: Option<PathBuf>,
357}
358
359impl Backend {
360    /// The OS mechanism, for people and run records: `Seatbelt` or
361    /// `Landlock+seccomp`.
362    #[must_use]
363    pub fn mechanism(&self) -> &'static str {
364        if cfg!(target_os = "macos") {
365            "Seatbelt"
366        } else {
367            "Landlock+seccomp"
368        }
369    }
370}
371
372/// The helper subcommand (`speccoder __sandbox <profile> -- argv…`).
373pub const HELPER_ARG: &str = "__sandbox";
374
375/// The sandbox this machine offers, or why there is none.
376pub fn backend() -> Result<Backend, String> {
377    static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
378    CACHED.get_or_init(detect).clone()
379}
380
381fn detect() -> Result<Backend, String> {
382    available()?;
383    Ok(Backend {
384        helper: helper_binary(),
385    })
386}
387
388/// Whether this machine can sandbox commands at all (whatever process asks).
389///
390/// # Errors
391///
392/// Why not.
393pub fn available() -> Result<(), String> {
394    #[cfg(any(target_os = "macos", target_os = "linux"))]
395    return procage::available();
396    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
397    Err(format!("no sandbox for {}", std::env::consts::OS))
398}
399
400/// The `speccoder` binary that serves as the helper: `SPECCODER_SANDBOX_HELPER`,
401/// or this executable when it is `speccoder`.
402fn helper_binary() -> Option<PathBuf> {
403    std::env::var_os("SPECCODER_SANDBOX_HELPER")
404        .map(PathBuf::from)
405        .or_else(|| {
406            std::env::current_exe()
407                .ok()
408                .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
409        })
410}
411
412/// The profile this machine enforces for `profile`, and a notice for what it
413/// leaves out.
414///
415/// # Errors
416///
417/// Why this machine cannot confine the profile at all.
418pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
419    #[cfg(any(target_os = "macos", target_os = "linux"))]
420    return procage::fit(profile);
421    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
422    {
423        let _ = profile;
424        Err(format!("no sandbox for {}", std::env::consts::OS))
425    }
426}
427
428/// `argv` confined by `p` through the helper on `backend`.
429pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
430    // Without the helper a server cannot be confined: never start it plain.
431    let helper = backend.helper.as_ref().ok_or_else(|| {
432        "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
433            .to_owned()
434    })?;
435    let mut out = vec![
436        helper.to_string_lossy().into_owned(),
437        HELPER_ARG.to_owned(),
438        serde_json::to_string(p).map_err(|e| e.to_string())?,
439        "--".to_owned(),
440    ];
441    out.extend_from_slice(argv);
442    Ok(out)
443}
444
445/// A runner that confines every command.
446#[derive(Debug, Clone)]
447pub struct Sandboxed {
448    /// The confinement.
449    pub profile: Profile,
450    /// The OS mechanism.
451    pub backend: Backend,
452    /// Exact commands that get the network.
453    pub network_allow: Vec<Vec<String>>,
454    /// Prefixes (two elements or more) that get it.
455    pub network_allow_prefix: Vec<Vec<String>>,
456}
457
458impl Sandboxed {
459    /// The network `argv` gets: its profile's, or all of it for an allowed
460    /// command (exactly, or extending an allowed prefix).
461    #[must_use]
462    pub fn network_for(&self, argv: &[String]) -> Network {
463        if self.network_allow.iter().any(|p| p.as_slice() == argv)
464            || self
465                .network_allow_prefix
466                .iter()
467                .any(|p| crate::tools::policy::prefix_matches(p, argv))
468        {
469            Network::On
470        } else {
471            self.profile.network
472        }
473    }
474
475    /// Runs `inv` with `network` instead of the profile's.
476    #[must_use]
477    pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
478        let profile = self.profile.clone().with_network(network);
479        let failed = |e: String| Outcome {
480            exit_code: None,
481            timed_out: false,
482            spawn_error: Some(e),
483            started: std::time::SystemTime::now(),
484            duration: std::time::Duration::ZERO,
485            stdout: crate::verify::process::Captured::default(),
486            stderr: crate::verify::process::Captured::default(),
487            sandbox: Default::default(),
488        };
489        // Through the wrapper a missing program would be the wrapper's
490        // failure; report it as the plain runner does.
491        if let Some(program) = inv.argv.first()
492            && !resolvable(program, &inv.cwd, &inv.env)
493        {
494            return failed(format!(
495                "could not start `{program}`: No such file or directory"
496            ));
497        }
498        if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
499            return failed(format!("the sandbox's temporary directory: {e}"));
500        }
501        let mut env: Vec<(String, String)> = inv
502            .env
503            .iter()
504            .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
505            .cloned()
506            .collect();
507        // The private ones, where the command does not name its own.
508        for (k, v) in profile.private_env() {
509            if !env.iter().any(|(e, _)| *e == k) {
510                env.push((k, v));
511            }
512        }
513        #[cfg(any(target_os = "macos", target_os = "linux"))]
514        return procage::run(&profile, &Invocation { env, ..inv.clone() });
515        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
516        {
517            let _ = env;
518            failed(format!("no sandbox for {}", std::env::consts::OS))
519        }
520    }
521}
522
523/// The executable `program` runs as in `cwd` with `env`: a program with a
524/// `/` against `cwd`, else the first file of that name on `PATH` (relative
525/// `PATH` entries against `cwd` too) — canonicalised; `None` when there is
526/// none. A file without an execute bit is skipped, as `execvp` skips it:
527/// the executable named is the one that runs. What a command's approval
528/// names, and what it is checked against again when it runs.
529#[must_use]
530pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
531    let candidate = |p: PathBuf| {
532        executable_file(&p)
533            .then(|| std::fs::canonicalize(&p).ok())
534            .flatten()
535    };
536    if program.is_empty() {
537        return None;
538    }
539    if program.contains('/') {
540        return candidate(cwd.join(program));
541    }
542    let path = env
543        .iter()
544        .find(|(k, _)| k == "PATH")
545        .map(|(_, v)| v.as_str());
546    path.unwrap_or("/usr/bin:/bin")
547        .split(':')
548        .filter(|d| !d.is_empty())
549        .find_map(|d| candidate(cwd.join(d).join(program)))
550}
551
552/// A regular file (through symlinks) that someone may execute.
553fn executable_file(p: &Path) -> bool {
554    #[cfg(unix)]
555    {
556        use std::os::unix::fs::PermissionsExt as _;
557        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
558    }
559    #[cfg(not(unix))]
560    {
561        p.is_file()
562    }
563}
564
565/// Whether `program` names an executable file, directly or on `PATH`.
566fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
567    resolve_executable(program, cwd, env).is_some()
568}
569
570impl CommandRunner for Sandboxed {
571    fn run(&self, inv: &Invocation) -> Outcome {
572        self.run_with_network(inv, self.network_for(&inv.argv))
573    }
574}
575
576/// The runner for commands working in `root`: sandboxed when configured and
577/// possible; otherwise the plain runner and a notice (or, with `required`,
578/// an error).
579pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
580    if config.mode == Mode::Off {
581        return Ok((
582            Runner::Plain,
583            Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
584        ));
585    }
586    let home = std::env::var_os("HOME").map(PathBuf::from);
587    let profile = Profile::new(root, config, home.as_deref());
588    // What this machine cannot enforce is left out by name; anything else it
589    // cannot confine means no sandbox, and `mode` decides.
590    match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
591        Ok((backend, (profile, notice))) => Ok((
592            Runner::Sandboxed(Sandboxed {
593                profile,
594                backend,
595                network_allow: config.network_allow.clone(),
596                network_allow_prefix: config.network_allow_prefix.clone(),
597            }),
598            notice,
599        )),
600        Err(e) if config.mode == Mode::Required => Err(format!(
601            "[sandbox] mode = \"required\", but there is no sandbox: {e}"
602        )),
603        Err(e) => Ok((
604            Runner::Plain,
605            Some(format!("commands run unsandboxed: {e}")),
606        )),
607    }
608}
609
610/// What `[approvals] contained` comes to at `root`, in one line: the
611/// commands the sandbox confines run without asking, or why every command
612/// that is not a check asks — what `init`, `new` and `doctor` say.
613#[must_use]
614pub fn contained_at(root: &Path) -> (bool, String) {
615    let text = crate::verify::find_config(root)
616        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
617        .unwrap_or_default();
618    let allowed = crate::substrate::run_config::parse(&text).is_ok_and(|c| c.contained);
619    if !allowed {
620        return (
621            true,
622            "every command that is not a check asks ([approvals] contained = \"ask\")".to_owned(),
623        );
624    }
625    match runner_at(root).map(|(r, _)| r.confines()) {
626        Ok(Some(m)) => (
627            true,
628            format!(
629                "commands the sandbox confines run without asking ({m}) — [approvals] contained = \"ask\" to be asked"
630            ),
631        ),
632        Ok(None) | Err(_) => (
633            false,
634            "every command that is not a check asks: no sandbox confines them here".to_owned(),
635        ),
636    }
637}
638
639/// [`runner`] with `[sandbox]` read from the `speccoder.toml` that governs `root`.
640pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
641    let text = crate::verify::find_config(root)
642        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
643        .unwrap_or_default();
644    let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
645    runner(root, &config)
646}
647
648impl Runner {
649    /// In place: `read_only` and `hidden` carved out of the profile, and
650    /// what the person must know when this machine cannot. A plain runner
651    /// confines nothing, so there is nothing to carve.
652    #[must_use]
653    pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
654        match self {
655            Self::Sandboxed(mut s) => {
656                let carved = s.profile.clone().carve(read_only, hidden);
657                match fit(&carved) {
658                    Ok((fitted, notice)) => {
659                        s.profile = fitted;
660                        (Self::Sandboxed(s), notice)
661                    }
662                    Err(e) => (
663                        Self::Sandboxed({
664                            s.profile.unenforced.carve = true;
665                            s
666                        }),
667                        Some(format!(
668                            "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
669                        )),
670                    ),
671                }
672            }
673            Self::Plain => (Self::Plain, None),
674        }
675    }
676}
677
678/// A command as it will be started: its argv, and the environment to start
679/// it with ([`Profile::private_env`]) when something confines it.
680pub type Wrapped = (Vec<String>, Option<Vec<(String, String)>>);
681
682/// A sandboxed or a plain runner.
683#[derive(Debug, Clone)]
684pub enum Runner {
685    /// Confined.
686    Sandboxed(Sandboxed),
687    /// Not confined.
688    Plain,
689}
690
691impl Runner {
692    /// Whether commands are confined (the network rule then matters).
693    #[must_use]
694    pub fn is_sandboxed(&self) -> bool {
695        matches!(self, Self::Sandboxed(_))
696    }
697
698    /// The mechanism that confines a command to the worktree — writes
699    /// there, no network beyond loopback, no signal to other processes,
700    /// `.git` carved out in place — when this runner does: a call it
701    /// confines needs nobody's approval (`[approvals] contained`).
702    #[must_use]
703    pub fn confines(&self) -> Option<&'static str> {
704        match self {
705            Self::Sandboxed(s)
706                if s.profile.network != Network::On
707                    && !s.profile.unenforced.signal_scope
708                    && !s.profile.unenforced.carve =>
709            {
710                Some(s.backend.mechanism())
711            }
712            _ => None,
713        }
714    }
715
716    /// The same runner, with git hooks, git config and agent configuration
717    /// (the trust-handoff names) writable in its trees: for the tools that
718    /// create a project (`git init`, a format's init), never for the model's
719    /// commands.
720    #[must_use]
721    pub fn with_trust_handoff_writable(self) -> Self {
722        match self {
723            Self::Sandboxed(mut s) => {
724                s.profile.unenforced.trust_handoff = true;
725                Self::Sandboxed(s)
726            }
727            Self::Plain => Self::Plain,
728        }
729    }
730
731    /// The same runner, `dir` writable too.
732    #[must_use]
733    pub fn with_writable(self, dir: &Path) -> Self {
734        match self {
735            Self::Sandboxed(mut s) => {
736                s.profile.writable.push(canonical(dir));
737                Self::Sandboxed(s)
738            }
739            Self::Plain => Self::Plain,
740        }
741    }
742
743    /// Whether `argv` gets the network by `[sandbox] network_allow` (or a
744    /// prefix): never for a plain runner, which asks nobody.
745    #[must_use]
746    pub fn allows_network(&self, argv: &[String]) -> bool {
747        matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
748    }
749
750    /// Runs `inv`; `network` (a person's approval) widens the network.
751    #[must_use]
752    pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
753        match self {
754            Self::Plain => LocalRunner.run(inv),
755            Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
756            Self::Sandboxed(s) => s.run(inv),
757        }
758    }
759
760    /// Where commands may write scratch files: the sandbox's private
761    /// temporary directory (created), or `None` unsandboxed.
762    #[must_use]
763    pub fn scratch(&self) -> Option<PathBuf> {
764        match self {
765            Self::Plain => None,
766            Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
767                .ok()
768                .map(|()| s.profile.tmp.clone()),
769        }
770    }
771
772    /// `argv` as it will be started (for long-running servers started
773    /// elsewhere), and the environment to start it with
774    /// ([`Profile::private_env`]); none when nothing confines it.
775    pub fn wrap(&self, argv: &[String]) -> Result<Wrapped, String> {
776        match self {
777            Self::Plain => Ok((argv.to_vec(), None)),
778            Self::Sandboxed(s) => {
779                let profile = s.profile.clone().with_network(s.network_for(argv));
780                std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
781                Ok((
782                    wrap(&profile, &s.backend, argv)?,
783                    Some(profile.private_env()),
784                ))
785            }
786        }
787    }
788
789    /// The same runner for what fills the shared caches (speccoder's
790    /// `prepare`): they are writable to it.
791    #[must_use]
792    pub fn fetching(self) -> Self {
793        match self {
794            Self::Sandboxed(mut s) => {
795                s.profile = s.profile.fetching();
796                Self::Sandboxed(s)
797            }
798            Self::Plain => Self::Plain,
799        }
800    }
801}
802
803impl CommandRunner for Runner {
804    fn run(&self, inv: &Invocation) -> Outcome {
805        self.run_with(inv, false)
806    }
807}
808
809/// The helper's entry (`speccoder __sandbox <profile> -- argv…`): confines
810/// this process and becomes the command; returns only on failure.
811pub fn helper_main(args: &[String]) -> String {
812    let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
813        return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
814    };
815    let profile: Profile = match serde_json::from_str(profile) {
816        Ok(p) => p,
817        Err(e) => return format!("the sandbox profile: {e}"),
818    };
819    let argv = &args[2..];
820    if argv.is_empty() {
821        return "nothing to run".to_owned();
822    }
823    #[cfg(any(target_os = "macos", target_os = "linux"))]
824    return procage::exec(&profile, argv);
825    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
826    format!("no sandbox for {}", std::env::consts::OS)
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn config_parses_and_defaults_to_loopback() {
835        assert_eq!(parse("").unwrap_or_default(), Config::default());
836        let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
837            .unwrap_or_default();
838        assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
839        assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
840    }
841
842    #[test]
843    fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
844        let home = Path::new("/home/u");
845        let p = Profile::new(
846            Path::new("/s/proj/worktrees/001"),
847            &Config::default(),
848            Some(home),
849        );
850        assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
851        assert!(
852            !p.writable
853                .iter()
854                .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
855        );
856        assert!(p.hidden.contains(&home.join(".ssh")));
857        assert_eq!(p.unenforced, Unenforced::default());
858    }
859
860    /// F-141: the person's package caches are read by every command and
861    /// written only by what fetches; the rest derives into a private cache.
862    #[test]
863    fn caches_are_written_only_by_what_fetches() {
864        let home = Path::new("/home/u");
865        let registry = home.join(".cargo/registry");
866        let p = Profile::new(Path::new("/w"), &Config::default(), Some(home));
867        assert!(p.caches.contains(&registry));
868        assert!(!p.writable.contains(&registry));
869        let env = p.private_env();
870        let var = |k: &str| env.iter().find(|(e, _)| e == k).map(|(_, v)| v.clone());
871        let cache = p.tmp.join("cache");
872        assert_eq!(
873            var("UV_CACHE_DIR"),
874            Some(cache.join("uv").to_string_lossy().into_owned())
875        );
876        assert_eq!(
877            var("GOCACHE"),
878            Some(cache.join("go-build").to_string_lossy().into_owned())
879        );
880        assert_eq!(
881            var("XDG_CACHE_HOME"),
882            Some(cache.to_string_lossy().into_owned())
883        );
884        for f in [p.clone().fetching(), p.clone().with_network(Network::On)] {
885            assert!(f.writable.contains(&registry));
886            assert!(f.private_env().iter().all(|(k, _)| k != "UV_CACHE_DIR"));
887        }
888        assert!(
889            !p.with_network(Network::Loopback)
890                .writable
891                .contains(&registry)
892        );
893    }
894
895    /// A profile written before the signal scope could be left out
896    /// enforces it; a fitted one keeps the choice through the helper.
897    #[test]
898    fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
899        let p = Profile::new(Path::new("/w"), &Config::default(), None);
900        let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
901        json["unenforced"] =
902            serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
903        let old: Profile = serde_json::from_value(json).unwrap();
904        assert!(!old.unenforced.signal_scope);
905        let mut fitted = p.clone();
906        fitted.unenforced.signal_scope = true;
907        let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
908        assert!(back.unenforced.signal_scope);
909    }
910
911    #[test]
912    fn the_executable_is_the_first_on_the_path_canonicalised() {
913        use std::os::unix::fs::PermissionsExt;
914        let dir = tempfile::tempdir().unwrap();
915        let root = std::fs::canonicalize(dir.path()).unwrap();
916        for d in ["bin", "usr"] {
917            std::fs::create_dir_all(root.join(d)).unwrap();
918            let f = root.join(d).join("make");
919            std::fs::write(&f, "#!/bin/sh\n").unwrap();
920            std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
921        }
922        let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
923        let usr = format!("{}", root.join("usr").display());
924        assert_eq!(
925            resolve_executable("make", &root, &env(&usr)),
926            Some(root.join("usr/make"))
927        );
928        // A worktree directory earlier on the path shadows it.
929        assert_eq!(
930            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
931            Some(root.join("bin/make"))
932        );
933        assert_eq!(
934            resolve_executable("./bin/make", &root, &env("")),
935            Some(root.join("bin/make"))
936        );
937        assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
938        assert!(!resolvable("nope", &root, &env(&usr)));
939        // A file without an execute bit is skipped, as execvp skips it.
940        std::fs::set_permissions(
941            root.join("bin/make"),
942            std::fs::Permissions::from_mode(0o644),
943        )
944        .unwrap();
945        assert_eq!(
946            resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
947            Some(root.join("usr/make"))
948        );
949        assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
950    }
951
952    /// The experimental `engine` key is gone: procage is the only engine.
953    #[test]
954    fn the_engine_key_is_refused_by_name() {
955        let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
956        assert!(e.contains("engine"), "{e}");
957    }
958
959    /// A profile this machine cannot confine at all (here: a writable path
960    /// with a control character, which Seatbelt cannot express) means no
961    /// sandbox: `auto` runs commands plain and says why, `required` refuses.
962    #[cfg(target_os = "macos")]
963    #[test]
964    fn an_unconfinable_profile_follows_the_mode() {
965        if backend().is_err() {
966            return; // no sandbox on this machine at all
967        }
968        let dir = tempfile::tempdir().unwrap();
969        let odd = dir.path().join("procage\u{1}odd");
970        std::fs::create_dir_all(&odd).unwrap();
971        let mut config = Config {
972            writable: vec![odd.to_string_lossy().into_owned()],
973            ..Config::default()
974        };
975        let (plain, notice) = runner(dir.path(), &config).unwrap();
976        assert!(!plain.is_sandboxed());
977        assert!(
978            notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
979            "{plain:?}"
980        );
981        config.mode = Mode::Required;
982        let e = runner(dir.path(), &config).unwrap_err();
983        assert!(e.contains("mode = \"required\""), "{e}");
984    }
985
986    #[test]
987    fn a_confinable_profile_is_sandboxed() {
988        let dir = tempfile::tempdir().unwrap();
989        let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
990            assert!(
991                backend().is_err(),
992                "a sandbox exists but the runner is plain"
993            );
994            return;
995        };
996        assert_eq!(s.backend, backend().unwrap());
997    }
998
999    #[test]
1000    fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1001        let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1002        let s = Sandboxed {
1003            profile: Profile::new(Path::new("/w"), &c, None),
1004            backend: Backend { helper: None },
1005            network_allow: c.network_allow.clone(),
1006            network_allow_prefix: c.network_allow_prefix.clone(),
1007        };
1008        let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1009        assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1010        assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1011        assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1012        assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1013    }
1014}