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