Skip to main content

wm_dispatch/
subprocess_sandbox.rs

1//! B2 — subprocess spawn sandbox registry.
2//!
3//! Tools that declare `Sandbox::Subprocess` launch external processes
4//! through [`wm_core::sandbox::SpawnPolicy`], which the dispatcher injects
5//! into the [`wm_core::Context`] before the call. This module owns the
6//! process-wide runner resolution, the dispatch counters, and the
7//! loud-degrade bookkeeping:
8//!
9//! - **confined**: a declared tool dispatched with a runner attached —
10//!   counted, and the active policy disclosed on the response.
11//! - **degraded**: a declared tool dispatched with no runner resolvable —
12//!   counted, warned (once per tool), and the command *still runs*
13//!   unconfined. Availability first, drift never silent.
14//! - **unconfined spawns**: a tool that declares `spawns` but not
15//!   `Sandbox::Subprocess` — counted and warned once per tool, because a
16//!   declared spawn site that bypasses the policy is exactly the drift
17//!   this seam exists to surface. It does not fail the call.
18//!
19//! Runner discovery lives in [`wm_core::sandbox::detect_runner`]
20//! (`WM_SANDBOX_RUNNER` env → `PATH` lookup). The dispatcher attaches a
21//! registry explicitly via
22//! [`DispatchPipeline::with_subprocess_sandbox`](crate::DispatchPipeline::with_subprocess_sandbox);
23//! without one the declarations are inert (same doctrine as the Landlock
24//! v1 executor).
25
26use std::collections::HashSet;
27use std::sync::Mutex;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use wm_core::sandbox::{ENVELOPE_SCHEMA, RunnerInfo, SpawnPolicy, detect_runner};
31use wm_core::{EffectRow, Sandbox};
32
33/// Process-wide subprocess sandbox registry.
34pub struct SubprocessSandbox {
35    runner: Option<RunnerInfo>,
36    dispatches: AtomicU64,
37    degraded: AtomicU64,
38    unconfined_spawns: AtomicU64,
39    /// Tools already warned about (once-per-tool log discipline).
40    warned: Mutex<HashSet<String>>,
41}
42
43impl SubprocessSandbox {
44    /// Resolve the runner from the environment / `PATH`.
45    #[must_use]
46    pub fn detect() -> Self {
47        Self::with_runner(detect_runner())
48    }
49
50    /// Build with an explicit runner resolution (tests, deterministic deployments).
51    #[must_use]
52    pub fn with_runner(runner: Option<RunnerInfo>) -> Self {
53        Self {
54            runner,
55            dispatches: AtomicU64::new(0),
56            degraded: AtomicU64::new(0),
57            unconfined_spawns: AtomicU64::new(0),
58            warned: Mutex::new(HashSet::new()),
59        }
60    }
61
62    /// Whether a runner is attached.
63    #[must_use]
64    pub const fn is_active(&self) -> bool {
65        self.runner.is_some()
66    }
67
68    /// The resolved runner, if any.
69    #[must_use]
70    pub const fn runner(&self) -> Option<&RunnerInfo> {
71        self.runner.as_ref()
72    }
73
74    /// Whether the tool contractually routes its spawns through the policy.
75    #[must_use]
76    pub fn declared(effects: &EffectRow) -> bool {
77        effects.sandbox == Sandbox::Subprocess
78    }
79
80    /// Per-dispatch policy for the given effect row (net grant derived
81    /// from `Resource::Network`). Inert when no runner is attached.
82    #[must_use]
83    pub fn policy_for(&self, effects: &EffectRow) -> SpawnPolicy {
84        SpawnPolicy::from_runner(
85            self.runner.as_ref().map(|r| r.path.clone()),
86            wm_core::sandbox::net_grant(effects),
87        )
88    }
89
90    /// Count a declared dispatch that carried an active runner.
91    pub fn note_confined(&self) {
92        self.dispatches.fetch_add(1, Ordering::Relaxed);
93    }
94
95    /// Count + warn (once per tool) a declared dispatch with no runner.
96    pub fn note_degraded(&self, tool: &str) {
97        self.dispatches.fetch_add(1, Ordering::Relaxed);
98        self.degraded.fetch_add(1, Ordering::Relaxed);
99        self.warn_once(
100            tool,
101            "subprocess sandbox: no runner resolved — declared spawn runs unconfined (loud-degrade)",
102        );
103    }
104
105    /// Count + warn (once per tool) a spawn-declared tool that has not
106    /// adopted the `Sandbox::Subprocess` contract.
107    pub fn note_unconfined_spawn(&self, tool: &str) {
108        self.unconfined_spawns.fetch_add(1, Ordering::Relaxed);
109        self.warn_once(
110            tool,
111            "subprocess sandbox: tool declares spawns but not Sandbox::Subprocess — its spawns bypass the runner",
112        );
113    }
114
115    fn warn_once(&self, tool: &str, message: &str) {
116        if let Ok(mut warned) = self.warned.lock()
117            && warned.insert(tool.to_string())
118        {
119            tracing::warn!(tool, "{message}");
120        }
121    }
122
123    /// Read-only status for `/status`, `wm doctor`, and tests.
124    #[must_use]
125    pub fn status(&self) -> serde_json::Value {
126        serde_json::json!({
127            "active": self.is_active(),
128            "runner": self.runner.as_ref().map(|r| r.path.display().to_string()),
129            "source": self.runner.as_ref().map(|r| r.source.as_str()),
130            "envelope": ENVELOPE_SCHEMA,
131            "dispatches": self.dispatches.load(Ordering::Relaxed),
132            "degraded": self.degraded.load(Ordering::Relaxed),
133            "unconfined_spawns": self.unconfined_spawns.load(Ordering::Relaxed),
134        })
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::path::PathBuf;
142    use wm_core::Resource;
143    use wm_core::sandbox::RunnerSource;
144
145    fn runner() -> RunnerInfo {
146        RunnerInfo {
147            path: PathBuf::from("/opt/mandala-sandbox"),
148            source: RunnerSource::Env,
149        }
150    }
151
152    fn declared_effects() -> EffectRow {
153        EffectRow {
154            reads: vec![Resource::Network, Resource::Process],
155            spawns: true,
156            sandbox: Sandbox::Subprocess,
157            ..Default::default()
158        }
159    }
160
161    #[test]
162    fn declared_requires_the_subprocess_marker() {
163        assert!(SubprocessSandbox::declared(&declared_effects()));
164        let spawns_only = EffectRow {
165            spawns: true,
166            ..Default::default()
167        };
168        assert!(!SubprocessSandbox::declared(&spawns_only));
169    }
170
171    #[test]
172    fn policy_derives_runner_and_net_grant() {
173        let sb = SubprocessSandbox::with_runner(Some(runner()));
174        let policy = sb.policy_for(&declared_effects());
175        assert!(policy.is_active());
176        assert!(policy.allow_net());
177        assert_eq!(
178            policy.runner(),
179            Some(std::path::Path::new("/opt/mandala-sandbox"))
180        );
181
182        let local = EffectRow {
183            reads: vec![Resource::Process],
184            spawns: true,
185            sandbox: Sandbox::Subprocess,
186            ..Default::default()
187        };
188        assert!(!sb.policy_for(&local).allow_net());
189    }
190
191    #[test]
192    fn missing_runner_degrades_but_still_builds_policy() {
193        let sb = SubprocessSandbox::with_runner(None);
194        let policy = sb.policy_for(&declared_effects());
195        assert!(!policy.is_active());
196        assert!(
197            policy.allow_net(),
198            "grant is declared, not runner-dependent"
199        );
200        sb.note_degraded("oss.bounty.scan");
201        let status = sb.status();
202        assert_eq!(status["active"], false);
203        assert_eq!(status["dispatches"], 1);
204        assert_eq!(status["degraded"], 1);
205    }
206
207    #[test]
208    fn counters_are_separate() {
209        let sb = SubprocessSandbox::with_runner(Some(runner()));
210        sb.note_confined();
211        sb.note_unconfined_spawn("session.record");
212        let status = sb.status();
213        assert_eq!(status["dispatches"], 1);
214        assert_eq!(status["degraded"], 0);
215        assert_eq!(status["unconfined_spawns"], 1);
216        assert_eq!(status["source"], "env");
217    }
218}