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, VecDeque};
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/// Maximum retained drift incidents awaiting a bridge drain. Bounded so a
34/// storm cannot grow memory; the counters remain the durable totals.
35const EVENT_RING_CAP: usize = 64;
36
37/// A sandbox drift incident surfaced for the Yama v0 bridge.
38///
39/// Only *drift* is recorded — `degraded` (declared spawn ran without a
40/// runner) and `unconfined_spawn` (spawn-declared tool bypassing the
41/// policy). The happy path lives in the counters only.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SandboxEvent {
44    /// Tool whose dispatch produced the incident.
45    pub tool: String,
46    /// Incident kind.
47    pub kind: SandboxEventKind,
48    /// Unix epoch milliseconds at recording time.
49    pub ts_ms: u64,
50}
51
52/// Drift incident kind (snake_case on the wire).
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SandboxEventKind {
55    /// Declared spawn dispatched with no runner resolvable.
56    Degraded,
57    /// Spawn-declared tool that has not adopted `Sandbox::Subprocess`.
58    UnconfinedSpawn,
59}
60
61impl SandboxEventKind {
62    /// Wire name used in the `sandbox_observation` bus payload.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Degraded => "degraded",
67            Self::UnconfinedSpawn => "unconfined_spawn",
68        }
69    }
70}
71
72impl SandboxEvent {
73    /// JSON payload for the `sandbox_observation` bus event. `status` is
74    /// the registry snapshot so the payload carries the running totals
75    /// alongside the incident itself.
76    #[must_use]
77    pub fn to_json(&self, status: &serde_json::Value) -> serde_json::Value {
78        serde_json::json!({
79            "tool": self.tool,
80            "kind": self.kind.as_str(),
81            "counts": {
82                "dispatches": status["dispatches"],
83                "degraded": status["degraded"],
84                "unconfined_spawns": status["unconfined_spawns"],
85            },
86            "ts_ms": self.ts_ms,
87        })
88    }
89}
90
91/// Unix epoch milliseconds (0 if the clock is before the epoch).
92fn now_ms() -> u64 {
93    std::time::SystemTime::now()
94        .duration_since(std::time::UNIX_EPOCH)
95        .map_or(0, |d| d.as_millis() as u64)
96}
97
98/// Process-wide subprocess sandbox registry.
99pub struct SubprocessSandbox {
100    runner: Option<RunnerInfo>,
101    dispatches: AtomicU64,
102    degraded: AtomicU64,
103    unconfined_spawns: AtomicU64,
104    /// Tools already warned about (once-per-tool log discipline).
105    warned: Mutex<HashSet<String>>,
106    /// Bounded ring of drift incidents awaiting a bridge drain.
107    events: Mutex<VecDeque<SandboxEvent>>,
108}
109
110impl SubprocessSandbox {
111    /// Resolve the runner from the environment / `PATH`.
112    #[must_use]
113    pub fn detect() -> Self {
114        Self::with_runner(detect_runner())
115    }
116
117    /// Build with an explicit runner resolution (tests, deterministic deployments).
118    #[must_use]
119    pub fn with_runner(runner: Option<RunnerInfo>) -> Self {
120        Self {
121            runner,
122            dispatches: AtomicU64::new(0),
123            degraded: AtomicU64::new(0),
124            unconfined_spawns: AtomicU64::new(0),
125            warned: Mutex::new(HashSet::new()),
126            events: Mutex::new(VecDeque::new()),
127        }
128    }
129
130    /// Whether a runner is attached.
131    #[must_use]
132    pub const fn is_active(&self) -> bool {
133        self.runner.is_some()
134    }
135
136    /// The resolved runner, if any.
137    #[must_use]
138    pub const fn runner(&self) -> Option<&RunnerInfo> {
139        self.runner.as_ref()
140    }
141
142    /// Whether the tool contractually routes its spawns through the policy.
143    #[must_use]
144    pub fn declared(effects: &EffectRow) -> bool {
145        effects.sandbox == Sandbox::Subprocess
146    }
147
148    /// Per-dispatch policy for the given effect row (net grant derived
149    /// from `Resource::Network`). Inert when no runner is attached.
150    #[must_use]
151    pub fn policy_for(&self, effects: &EffectRow) -> SpawnPolicy {
152        SpawnPolicy::from_runner(
153            self.runner.as_ref().map(|r| r.path.clone()),
154            wm_core::sandbox::net_grant(effects),
155        )
156    }
157
158    /// Count a declared dispatch that carried an active runner.
159    pub fn note_confined(&self) {
160        self.dispatches.fetch_add(1, Ordering::Relaxed);
161    }
162
163    /// Count + warn (once per tool) a declared dispatch with no runner.
164    pub fn note_degraded(&self, tool: &str) {
165        self.dispatches.fetch_add(1, Ordering::Relaxed);
166        self.degraded.fetch_add(1, Ordering::Relaxed);
167        self.record_event(tool, SandboxEventKind::Degraded);
168        self.warn_once(
169            tool,
170            "subprocess sandbox: no runner resolved — declared spawn runs unconfined (loud-degrade)",
171        );
172    }
173
174    /// Count + warn (once per tool) a spawn-declared tool that has not
175    /// adopted the `Sandbox::Subprocess` contract.
176    pub fn note_unconfined_spawn(&self, tool: &str) {
177        self.unconfined_spawns.fetch_add(1, Ordering::Relaxed);
178        self.record_event(tool, SandboxEventKind::UnconfinedSpawn);
179        self.warn_once(
180            tool,
181            "subprocess sandbox: tool declares spawns but not Sandbox::Subprocess — its spawns bypass the runner",
182        );
183    }
184
185    /// Push a drift incident onto the bounded ring (oldest dropped first).
186    fn record_event(&self, tool: &str, kind: SandboxEventKind) {
187        if let Ok(mut events) = self.events.lock() {
188            if events.len() == EVENT_RING_CAP {
189                events.pop_front();
190            }
191            events.push_back(SandboxEvent {
192                tool: tool.to_string(),
193                kind,
194                ts_ms: now_ms(),
195            });
196        }
197    }
198
199    /// Drain pending drift incidents (exactly-once per incident) for the
200    /// Yama v0 bridge. Returns an empty vec when nothing is pending.
201    #[must_use]
202    pub fn drain_events(&self) -> Vec<SandboxEvent> {
203        self.events
204            .lock()
205            .map(|mut events| events.drain(..).collect())
206            .unwrap_or_default()
207    }
208
209    fn warn_once(&self, tool: &str, message: &str) {
210        if let Ok(mut warned) = self.warned.lock()
211            && warned.insert(tool.to_string())
212        {
213            tracing::warn!(tool, "{message}");
214        }
215    }
216
217    /// Read-only status for `/status`, `wm doctor`, and tests.
218    #[must_use]
219    pub fn status(&self) -> serde_json::Value {
220        serde_json::json!({
221            "active": self.is_active(),
222            "runner": self.runner.as_ref().map(|r| r.path.display().to_string()),
223            "source": self.runner.as_ref().map(|r| r.source.as_str()),
224            "envelope": ENVELOPE_SCHEMA,
225            "dispatches": self.dispatches.load(Ordering::Relaxed),
226            "degraded": self.degraded.load(Ordering::Relaxed),
227            "unconfined_spawns": self.unconfined_spawns.load(Ordering::Relaxed),
228        })
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::path::PathBuf;
236    use wm_core::Resource;
237    use wm_core::sandbox::RunnerSource;
238
239    fn runner() -> RunnerInfo {
240        RunnerInfo {
241            path: PathBuf::from("/opt/mandala-sandbox"),
242            source: RunnerSource::Env,
243        }
244    }
245
246    fn declared_effects() -> EffectRow {
247        EffectRow {
248            reads: vec![Resource::Network, Resource::Process],
249            spawns: true,
250            sandbox: Sandbox::Subprocess,
251            ..Default::default()
252        }
253    }
254
255    #[test]
256    fn declared_requires_the_subprocess_marker() {
257        assert!(SubprocessSandbox::declared(&declared_effects()));
258        let spawns_only = EffectRow {
259            spawns: true,
260            ..Default::default()
261        };
262        assert!(!SubprocessSandbox::declared(&spawns_only));
263    }
264
265    #[test]
266    fn policy_derives_runner_and_net_grant() {
267        let sb = SubprocessSandbox::with_runner(Some(runner()));
268        let policy = sb.policy_for(&declared_effects());
269        assert!(policy.is_active());
270        assert!(policy.allow_net());
271        assert_eq!(
272            policy.runner(),
273            Some(std::path::Path::new("/opt/mandala-sandbox"))
274        );
275
276        let local = EffectRow {
277            reads: vec![Resource::Process],
278            spawns: true,
279            sandbox: Sandbox::Subprocess,
280            ..Default::default()
281        };
282        assert!(!sb.policy_for(&local).allow_net());
283    }
284
285    #[test]
286    fn missing_runner_degrades_but_still_builds_policy() {
287        let sb = SubprocessSandbox::with_runner(None);
288        let policy = sb.policy_for(&declared_effects());
289        assert!(!policy.is_active());
290        assert!(
291            policy.allow_net(),
292            "grant is declared, not runner-dependent"
293        );
294        sb.note_degraded("oss.bounty.scan");
295        let status = sb.status();
296        assert_eq!(status["active"], false);
297        assert_eq!(status["dispatches"], 1);
298        assert_eq!(status["degraded"], 1);
299    }
300
301    #[test]
302    fn counters_are_separate() {
303        let sb = SubprocessSandbox::with_runner(Some(runner()));
304        sb.note_confined();
305        sb.note_unconfined_spawn("session.record");
306        let status = sb.status();
307        assert_eq!(status["dispatches"], 1);
308        assert_eq!(status["degraded"], 0);
309        assert_eq!(status["unconfined_spawns"], 1);
310        assert_eq!(status["source"], "env");
311    }
312
313    #[test]
314    fn drift_events_are_recorded_and_drained_once() {
315        let sb = SubprocessSandbox::with_runner(None);
316        sb.note_degraded("oss.bounty.scan");
317        sb.note_unconfined_spawn("session.record");
318
319        let events = sb.drain_events();
320        assert_eq!(events.len(), 2);
321        assert_eq!(events[0].tool, "oss.bounty.scan");
322        assert_eq!(events[0].kind, SandboxEventKind::Degraded);
323        assert_eq!(events[1].kind, SandboxEventKind::UnconfinedSpawn);
324        assert!(events[0].ts_ms > 0);
325        assert!(sb.drain_events().is_empty(), "drain must be exactly-once");
326    }
327
328    #[test]
329    fn happy_path_records_no_events() {
330        let sb = SubprocessSandbox::with_runner(Some(runner()));
331        sb.note_confined();
332        sb.note_confined();
333        assert!(sb.drain_events().is_empty());
334        assert_eq!(sb.status()["dispatches"], 2);
335    }
336
337    #[test]
338    fn event_ring_is_bounded_oldest_first() {
339        let sb = SubprocessSandbox::with_runner(None);
340        for i in 0..(EVENT_RING_CAP + 10) {
341            sb.note_unconfined_spawn(&format!("t{i}"));
342        }
343        let events = sb.drain_events();
344        assert_eq!(events.len(), EVENT_RING_CAP);
345        assert_eq!(events[0].tool, "t10", "oldest ten should be dropped");
346        assert_eq!(
347            sb.status()["unconfined_spawns"],
348            (EVENT_RING_CAP + 10) as u64,
349            "counters stay exact while the ring is bounded"
350        );
351    }
352}