Skip to main content

wm_core/
sandbox.rs

1//! Subprocess spawn policy — the B2 seam between tools and the OS runner.
2//!
3//! In-process Landlock (v0/v1) cannot confine a child process a tool
4//! launches: the restriction is thread-local and the child never inherits
5//! it. Confinement therefore has to wrap the *spawned command* instead.
6//! The dispatcher resolves a [`SpawnPolicy`] from the tool's effect row and
7//! hands it to the tool through [`crate::Context::spawn`]; the tool builds
8//! its `std::process::Command` through [`SpawnPolicy::command`].
9//!
10//! When a runner is active the command is invoked as
11//! `mandala-sandbox --exec '<json envelope>'`; the envelope carries the
12//! program, args, and whether the tool's effect row grants network. The OS
13//! wrapper owns the actual containment policy (bubblewrap profile,
14//! namespaces, binds) — WM only decides *which* commands are eligible and
15//! *what* the tool declared. When no runner resolves, `command()` returns a
16//! plain command; the dispatcher is responsible for the loud-degrade
17//! warning and counter (the doctrine of Landlock v0/v1).
18//!
19//! Runner discovery (strict, in order):
20//! 1. `WM_SANDBOX_RUNNER` — explicit path; `""`/`0`/`false`/`off`/`none`
21//!    disable; a value that is not a file disables with a WARN.
22//! 2. `mandala-sandbox` on `PATH`.
23
24use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use crate::effects::{EffectRow, Resource};
28
29/// Environment override for the runner path.
30pub const RUNNER_ENV: &str = "WM_SANDBOX_RUNNER";
31
32/// Program name looked up on `PATH` when no explicit override is set.
33pub const RUNNER_PROGRAM: &str = "mandala-sandbox";
34
35/// Schema tag carried in every `--exec` JSON envelope.
36pub const ENVELOPE_SCHEMA: &str = "wm-sandbox-exec-v1";
37
38/// How the runner path was resolved.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum RunnerSource {
41    /// Explicit `WM_SANDBOX_RUNNER` override.
42    Env,
43    /// Found on `PATH`.
44    Path,
45}
46
47impl RunnerSource {
48    /// Stable string for status/report rendering.
49    #[must_use]
50    pub const fn as_str(self) -> &'static str {
51        match self {
52            Self::Env => "env",
53            Self::Path => "path",
54        }
55    }
56}
57
58/// A resolved sandbox runner.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RunnerInfo {
61    /// Absolute (or PATH-relative) runner executable.
62    pub path: PathBuf,
63    /// How it was resolved.
64    pub source: RunnerSource,
65}
66
67/// Explicit-off tokens for [`RUNNER_ENV`] (case-insensitive).
68const DISABLED_TOKENS: &[&str] = &["0", "false", "off", "none"];
69
70/// Classification of the `WM_SANDBOX_RUNNER` value.
71#[derive(Debug, Clone, PartialEq, Eq)]
72enum EnvRunner {
73    /// Variable not set — fall through to `PATH`.
74    Unset,
75    /// Explicitly disabled (token or non-file path).
76    Disabled,
77    /// Explicit path.
78    Path(PathBuf),
79}
80
81fn classify_env(raw: &str) -> EnvRunner {
82    let trimmed = raw.trim();
83    if trimmed.is_empty()
84        || DISABLED_TOKENS
85            .iter()
86            .any(|v| trimmed.eq_ignore_ascii_case(v))
87    {
88        return EnvRunner::Disabled;
89    }
90    let path = PathBuf::from(trimmed);
91    if path.is_file() {
92        EnvRunner::Path(path)
93    } else {
94        tracing::warn!(
95            value = trimmed,
96            "WM_SANDBOX_RUNNER does not name a file — subprocess sandbox disabled"
97        );
98        EnvRunner::Disabled
99    }
100}
101
102fn env_runner() -> EnvRunner {
103    std::env::var(RUNNER_ENV).map_or(EnvRunner::Unset, |raw| classify_env(&raw))
104}
105
106fn path_runner_in(path_var: &std::ffi::OsStr) -> Option<RunnerInfo> {
107    std::env::split_paths(path_var)
108        .map(|dir| dir.join(RUNNER_PROGRAM))
109        .find(|candidate| candidate.is_file())
110        .map(|path| RunnerInfo {
111            path,
112            source: RunnerSource::Path,
113        })
114}
115
116fn path_runner() -> Option<RunnerInfo> {
117    std::env::var_os("PATH").and_then(|path_var| path_runner_in(&path_var))
118}
119
120/// Resolve the active sandbox runner: explicit env override, else `PATH`.
121#[must_use]
122pub fn detect_runner() -> Option<RunnerInfo> {
123    match env_runner() {
124        EnvRunner::Path(path) => Some(RunnerInfo {
125            path,
126            source: RunnerSource::Env,
127        }),
128        EnvRunner::Disabled => None,
129        EnvRunner::Unset => path_runner(),
130    }
131}
132
133/// Whether the effect row grants network access (drives the runner's
134/// `--net` selection for the spawned command).
135#[must_use]
136pub fn net_grant(effects: &EffectRow) -> bool {
137    effects
138        .reads
139        .iter()
140        .chain(effects.writes.iter())
141        .any(|r| matches!(r, Resource::Network))
142}
143
144/// Per-dispatch spawn confinement policy handed to tools via
145/// [`crate::Context::spawn`].
146///
147/// `runner: None` = inert policy: [`Self::command`] returns a plain
148/// command (the dispatcher counts and warns the degradation).
149#[derive(Debug, Clone, Default)]
150pub struct SpawnPolicy {
151    runner: Option<PathBuf>,
152    allow_net: bool,
153}
154
155impl SpawnPolicy {
156    /// Inert policy — commands run unconfined.
157    #[must_use]
158    pub fn disabled() -> Self {
159        Self::default()
160    }
161
162    /// Build from a resolved runner path and network grant.
163    #[must_use]
164    pub const fn from_runner(runner: Option<PathBuf>, allow_net: bool) -> Self {
165        Self { runner, allow_net }
166    }
167
168    /// Detect a runner and build a policy with an explicit net grant.
169    #[must_use]
170    pub fn detected(allow_net: bool) -> Self {
171        Self::from_runner(detect_runner().map(|r| r.path), allow_net)
172    }
173
174    /// Detect a runner and derive the net grant from the effect row.
175    #[must_use]
176    pub fn for_effects(effects: &EffectRow) -> Self {
177        Self::detected(net_grant(effects))
178    }
179
180    /// Whether a runner is attached.
181    #[must_use]
182    pub const fn is_active(&self) -> bool {
183        self.runner.is_some()
184    }
185
186    /// The attached runner path, if any.
187    #[must_use]
188    pub fn runner(&self) -> Option<&Path> {
189        self.runner.as_deref()
190    }
191
192    /// Whether the spawned command may use the network.
193    #[must_use]
194    pub const fn allow_net(&self) -> bool {
195        self.allow_net
196    }
197
198    /// Build the envelope the OS runner interprets.
199    #[must_use]
200    pub fn envelope(&self, program: &str, args: &[&str]) -> serde_json::Value {
201        serde_json::json!({
202            "schema": ENVELOPE_SCHEMA,
203            "program": program,
204            "args": args,
205            "net": self.allow_net,
206        })
207    }
208
209    /// Serialized envelope (what actually lands on the runner's argv).
210    #[must_use]
211    pub fn envelope_json(&self, program: &str, args: &[&str]) -> String {
212        self.envelope(program, args).to_string()
213    }
214
215    /// Build a command honoring the policy.
216    ///
217    /// Active runner → `runner --exec '<envelope>'`; inactive → plain
218    /// `program args...`. Callers must use this for every external spawn
219    /// when the tool declares `Sandbox::Subprocess`.
220    #[must_use]
221    pub fn command(&self, program: &str, args: &[&str]) -> Command {
222        match &self.runner {
223            None => {
224                let mut cmd = Command::new(program);
225                cmd.args(args);
226                cmd
227            }
228            Some(runner) => {
229                let mut cmd = Command::new(runner);
230                cmd.arg("--exec")
231                    .arg(self.envelope_json(program, args));
232                cmd
233            }
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use std::ffi::OsStr;
242
243    fn network_effects() -> EffectRow {
244        EffectRow {
245            reads: vec![Resource::Network],
246            ..Default::default()
247        }
248    }
249
250    #[test]
251    fn env_classification_is_strict() {
252        assert_eq!(classify_env(""), EnvRunner::Disabled);
253        assert_eq!(classify_env("0"), EnvRunner::Disabled);
254        assert_eq!(classify_env("OFF"), EnvRunner::Disabled);
255        assert_eq!(classify_env("none"), EnvRunner::Disabled);
256        assert_eq!(classify_env("  false "), EnvRunner::Disabled);
257        // A non-existent path disables (with a warning), never falls back.
258        assert_eq!(classify_env("/no/such/runner"), EnvRunner::Disabled);
259        // An existing file resolves.
260        let exe = std::env::current_exe().expect("test exe");
261        assert_eq!(
262            classify_env(exe.to_str().unwrap()),
263            EnvRunner::Path(exe)
264        );
265    }
266
267    #[test]
268    fn path_lookup_finds_runner_only_when_present() {
269        let dir = std::env::temp_dir().join(format!("wm-sandbox-test-{}", std::process::id()));
270        std::fs::create_dir_all(&dir).unwrap();
271        let path_var = dir.as_os_str();
272        assert_eq!(path_runner_in(path_var), None, "empty dir finds nothing");
273        let fake = dir.join(RUNNER_PROGRAM);
274        std::fs::write(&fake, b"#!/bin/sh\n").unwrap();
275        let found = path_runner_in(path_var).expect("runner found");
276        assert_eq!(found.path, fake);
277        assert_eq!(found.source, RunnerSource::Path);
278        std::fs::remove_dir_all(&dir).ok();
279    }
280
281    #[test]
282    fn inactive_policy_builds_plain_command() {
283        let policy = SpawnPolicy::disabled();
284        assert!(!policy.is_active());
285        let cmd = policy.command("gh", &["issue", "list"]);
286        assert_eq!(cmd.get_program(), OsStr::new("gh"));
287        let args: Vec<_> = cmd.get_args().collect();
288        assert_eq!(args, vec![OsStr::new("issue"), OsStr::new("list")]);
289    }
290
291    #[test]
292    fn active_policy_wraps_with_json_envelope() {
293        let policy = SpawnPolicy::from_runner(Some(PathBuf::from("/opt/mandala-sandbox")), true);
294        assert!(policy.is_active());
295        let cmd = policy.command("timeout", &["30", "gh"]);
296        assert_eq!(cmd.get_program(), OsStr::new("/opt/mandala-sandbox"));
297        let args: Vec<_> = cmd.get_args().collect();
298        assert_eq!(args[0], OsStr::new("--exec"));
299        let envelope: serde_json::Value =
300            serde_json::from_str(args[1].to_str().unwrap()).expect("envelope is JSON");
301        assert_eq!(envelope["schema"], ENVELOPE_SCHEMA);
302        assert_eq!(envelope["program"], "timeout");
303        assert_eq!(envelope["args"], serde_json::json!(["30", "gh"]));
304        assert_eq!(envelope["net"], true);
305    }
306
307    #[test]
308    fn net_grant_derives_from_effect_row() {
309        assert!(net_grant(&network_effects()));
310        assert!(!net_grant(&EffectRow::pure()));
311        let write_net = EffectRow {
312            writes: vec![Resource::Network],
313            ..Default::default()
314        };
315        assert!(net_grant(&write_net));
316    }
317
318    #[test]
319    fn for_effects_uses_detection_and_declared_network() {
320        let policy = SpawnPolicy::for_effects(&network_effects());
321        assert!(policy.allow_net());
322        // In this test process no runner is configured, so the policy is
323        // inert — but the net grant still reflects the declaration.
324        let envelope = policy.envelope("true", &[]);
325        assert_eq!(envelope["net"], true);
326    }
327}