Skip to main content

wm_dispatch/
sandbox_exec.rs

1//! Scoped-thread sandbox executor — the Landlock v1 per-tool pathway.
2//!
3//! P-SANDBOX-3 (2026-09-10, Glama execution-sandboxing thread): tools that
4//! declare [`wm_core::Sandbox::StoreScoped`] run on a **fresh OS thread**
5//! that applies a thread-local confinement before the tool body executes.
6//! Landlock restriction is irreversible and thread-local, so a fresh thread
7//! per dispatch is the safe unit: the confined thread exits after the call
8//! and the async workers never inherit a restriction.
9//!
10//! Why scoped threads instead of `spawn_blocking`: block-pool workers are
11//! reused, and a thread-local Landlock restriction applied there would
12//! taint every future task the pool hands that thread. Why not a confined
13//! tokio runtime: `Tool::call` borrows `&mut Context`, so the future is
14//! not `'static` and cannot be moved into a long-lived worker. A scoped
15//! thread creates the future *on* the confined thread, so the borrow stays
16//! valid and the future never crosses a thread boundary.
17//!
18//! Degradation doctrine (matches Landlock v0 / profile-contract): a failed
19//! or unsupported confinement is **loud, never fatal** — the tool runs
20//! unconfined, a `WARN` names the reason, and `stats().degraded` counts it.
21//! The closure supplied by the caller (`wm-mcp` injects the Landlock
22//! ruleset) is the only confinement mechanism here; this crate stays free
23//! of the landlock dependency, preserving the dependency direction.
24//!
25//! v1 scope limits, documented rather than hidden:
26//! - `WM_DISPATCH_TIMEOUT_MS` is enforced inside the confined thread: the
27//!   tool future is dropped on timeout (the same semantics as the normal
28//!   dispatch path). `with_timeout` overrides the env-derived value.
29//! - The per-dispatch cost is one OS thread + one current-thread runtime
30//!   (measured in the acceptance tests; parked-thread pooling is v1.1).
31//! - Subprocess-creating tools take a different seam: they declare
32//!   [`wm_core::Sandbox::Subprocess`] and build spawns through the
33//!   `SpawnPolicy` injected on the context (B2,
34//!   `crate::subprocess_sandbox`), because thread-local Landlock cannot
35//!   confine a child process.
36
37use std::sync::atomic::{AtomicU64, Ordering};
38use std::time::Duration;
39
40use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
41
42/// Environment knob: `WM_LANDLOCK_V1=1` enables the per-tool pathway.
43///
44/// Strict parse (exactly `1`), mirroring `WM_LANDLOCK`. Off by default:
45/// v0 whole-process confinement and v1 per-tool confinement are separate
46/// deployment decisions.
47pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
48
49/// Whether the per-tool pathway was requested. Strict `== "1"` parse.
50#[must_use]
51pub fn v1_requested() -> bool {
52    std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
53}
54
55/// Confinement callback: `Ok(())` = the current thread is restricted;
56/// `Err(reason)` = confinement unavailable (loud-degrade, run unconfined).
57pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
58
59/// Runs [`Sandbox::StoreScoped`] tools on a confined scoped thread.
60pub struct ScopedSandboxExecutor {
61    restrict: RestrictFn,
62    timeout: Option<Duration>,
63    runs: AtomicU64,
64    degraded: AtomicU64,
65    failures: AtomicU64,
66    timeouts: AtomicU64,
67}
68
69impl ScopedSandboxExecutor {
70    /// Build with the caller's confinement callback.
71    ///
72    /// The dispatch timeout defaults to
73    /// [`crate::DispatchPipeline::timeout_from_env`] (`WM_DISPATCH_TIMEOUT_MS`),
74    /// matching the normal dispatch path; [`Self::with_timeout`] overrides it.
75    #[must_use]
76    pub fn new(
77        restrict: impl Fn() -> std::result::Result<(), String> + Send + Sync + 'static,
78    ) -> Self {
79        Self {
80            restrict: Box::new(restrict),
81            timeout: crate::DispatchPipeline::timeout_from_env(),
82            runs: AtomicU64::new(0),
83            degraded: AtomicU64::new(0),
84            failures: AtomicU64::new(0),
85            timeouts: AtomicU64::new(0),
86        }
87    }
88
89    /// Override the dispatch timeout (`None` disables the bound).
90    #[must_use]
91    pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
92        self.timeout = timeout;
93        self
94    }
95
96    /// Dispatch timeouts observed since construction.
97    #[must_use]
98    pub fn timeouts(&self) -> u64 {
99        self.timeouts.load(Ordering::Relaxed)
100    }
101
102    /// (runs, degraded runs, contained panics/runtime failures).
103    #[must_use]
104    pub fn stats(&self) -> (u64, u64, u64) {
105        (
106            self.runs.load(Ordering::Relaxed),
107            self.degraded.load(Ordering::Relaxed),
108            self.failures.load(Ordering::Relaxed),
109        )
110    }
111
112    /// Execute one tool call on a confined thread.
113    ///
114    /// Synchronous by design: the dispatcher blocks while the confined
115    /// thread runs. The configured dispatch timeout is applied inside the
116    /// confined thread — a timed-out tool future is dropped and reported as
117    /// a `CoreError::Tool`, mirroring the normal dispatch path. Panics
118    /// inside the tool are contained by the scoped thread and surface as a
119    /// `CoreError::Tool` — the process survives.
120    pub fn run(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
121        self.runs.fetch_add(1, Ordering::Relaxed);
122        let timeout = self.timeout;
123        let outcome = std::thread::scope(|scope| {
124            scope
125                .spawn(|| {
126                    if let Err(reason) = (self.restrict)() {
127                        self.degraded.fetch_add(1, Ordering::Relaxed);
128                        tracing::warn!(
129                            tool = tool.name(),
130                            reason = %reason,
131                            "sandbox: per-tool confinement unavailable — running unconfined (loud-degrade)"
132                        );
133                    }
134                    let runtime = tokio::runtime::Builder::new_current_thread()
135                        .enable_all()
136                        .build()
137                        .map_err(|e| {
138                            CoreError::Tool(format!("sandbox runtime build failed: {e}"))
139                        })?;
140                    match timeout {
141                        Some(timeout) => match runtime.block_on(async {
142                            tokio::time::timeout(timeout, tool.call(ctx, args)).await
143                        }) {
144                            Ok(result) => result,
145                            Err(_elapsed) => {
146                                self.timeouts.fetch_add(1, Ordering::Relaxed);
147                                tracing::error!(
148                                    tool = tool.name(),
149                                    timeout_ms = timeout.as_millis(),
150                                    "sandboxed tool dispatch timed out"
151                                );
152                                Err(CoreError::Tool(format!(
153                                    "sandboxed tool '{}' timed out after {}ms",
154                                    tool.name(),
155                                    timeout.as_millis()
156                                )))
157                            }
158                        },
159                        None => runtime.block_on(tool.call(ctx, args)),
160                    }
161                })
162                .join()
163        });
164        match outcome {
165            Ok(result) => result,
166            Err(_panic) => {
167                self.failures.fetch_add(1, Ordering::Relaxed);
168                Err(CoreError::Tool(format!(
169                    "sandboxed tool '{}' panicked — contained by the scoped thread",
170                    tool.name()
171                )))
172            }
173        }
174    }
175
176    /// Whether a tool belongs on this pathway: `StoreScoped` declaration.
177    #[must_use]
178    pub fn handles(tool: &dyn Tool) -> bool {
179        tool.effects().sandbox == Sandbox::StoreScoped
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::sync::Arc;
187    use std::sync::atomic::AtomicBool;
188    use wm_core::{BrainWave, EffectRow, Gana, ToolStats};
189
190    struct ProbeTool {
191        effects: EffectRow,
192        stats: ToolStats,
193        /// Set when the tool body runs; the test's restriction callback
194        /// sets `restricted` first, so ordering is observable.
195        restricted_seen: Option<Arc<AtomicBool>>,
196        hang: bool,
197        panic: bool,
198    }
199
200    #[async_trait::async_trait]
201    impl Tool for ProbeTool {
202        fn name(&self) -> &str {
203            "probe"
204        }
205        fn gana(&self) -> Gana {
206            Gana::Heart
207        }
208        fn effects(&self) -> &EffectRow {
209            &self.effects
210        }
211        async fn call(&self, _ctx: &mut wm_core::Context, _args: Args) -> wm_core::Result<Output> {
212            assert!(!self.panic, "probe tool panicked");
213            if self.hang {
214                std::future::pending::<()>().await;
215            }
216            if let Some(flag) = &self.restricted_seen {
217                assert!(
218                    flag.load(Ordering::SeqCst),
219                    "tool must run AFTER the restriction callback"
220                );
221            }
222            Ok(serde_json::json!({"ok": true}))
223        }
224        fn stats(&self) -> &ToolStats {
225            &self.stats
226        }
227    }
228
229    fn probe() -> ProbeTool {
230        ProbeTool {
231            effects: EffectRow {
232                sandbox: Sandbox::StoreScoped,
233                ..Default::default()
234            },
235            stats: ToolStats::default(),
236            restricted_seen: None,
237            hang: false,
238            panic: false,
239        }
240    }
241
242    #[test]
243    fn restrict_runs_before_tool_and_output_passes_through() {
244        let restricted = Arc::new(AtomicBool::new(false));
245        let flag = Arc::clone(&restricted);
246        let executor = ScopedSandboxExecutor::new(move || {
247            flag.store(true, Ordering::SeqCst);
248            Ok(())
249        });
250        let mut tool = probe();
251        tool.restricted_seen = Some(Arc::clone(&restricted));
252        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
253        let out = executor
254            .run(&tool, &mut ctx, serde_json::json!({}))
255            .unwrap();
256        assert_eq!(out["ok"], true);
257        assert_eq!(executor.stats(), (1, 0, 0));
258    }
259
260    #[test]
261    fn confinement_failure_degrades_loud_but_runs() {
262        let executor = ScopedSandboxExecutor::new(|| Err("kernel says no".to_string()));
263        let tool = probe();
264        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
265        let out = executor
266            .run(&tool, &mut ctx, serde_json::json!({}))
267            .unwrap();
268        assert_eq!(out["ok"], true, "loud-degrade keeps availability up");
269        assert_eq!(executor.stats(), (1, 1, 0));
270    }
271
272    #[test]
273    fn tool_panic_is_contained_not_propagated() {
274        let executor = ScopedSandboxExecutor::new(|| Ok(()));
275        let mut tool = probe();
276        tool.panic = true;
277        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
278        let result = executor.run(&tool, &mut ctx, serde_json::json!({}));
279        assert!(result.is_err(), "panic must surface as a tool error");
280        assert_eq!(executor.stats(), (1, 0, 1));
281    }
282
283    #[test]
284    fn handles_only_store_scoped_tools() {
285        let scoped = probe();
286        assert!(ScopedSandboxExecutor::handles(&scoped));
287        let inherited = ProbeTool {
288            effects: EffectRow::pure(),
289            ..probe()
290        };
291        assert!(!ScopedSandboxExecutor::handles(&inherited));
292    }
293
294    #[test]
295    fn env_flag_parses_strictly() {
296        // Pure parse contract mirrored from WM_LANDLOCK: only "1" enables.
297        // (Read-only check — the process env is shared and tests never
298        // mutate it; the strict parse is the property under test.)
299        let parse = |v: Option<&str>| v.is_some_and(|s| s == "1");
300        assert!(parse(Some("1")));
301        assert!(!parse(Some("0")));
302        assert!(!parse(Some("true")));
303        assert!(!parse(None));
304    }
305
306    #[test]
307    fn hung_tool_times_out_promptly_and_is_counted() {
308        let executor =
309            ScopedSandboxExecutor::new(|| Ok(())).with_timeout(Some(Duration::from_millis(50)));
310        let mut tool = probe();
311        tool.hang = true;
312        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
313        let started = std::time::Instant::now();
314        let error = executor
315            .run(&tool, &mut ctx, serde_json::json!({}))
316            .unwrap_err()
317            .to_string();
318        assert!(error.contains("timed out"), "{error}");
319        assert!(
320            started.elapsed() < Duration::from_secs(5),
321            "timeout must return promptly, took {:?}",
322            started.elapsed()
323        );
324        assert_eq!(executor.timeouts(), 1);
325        assert_eq!(executor.stats(), (1, 0, 0));
326    }
327
328    #[test]
329    fn executor_recovers_after_a_timeout() {
330        let executor =
331            ScopedSandboxExecutor::new(|| Ok(())).with_timeout(Some(Duration::from_millis(50)));
332        let mut hung = probe();
333        hung.hang = true;
334        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
335        assert!(
336            executor
337                .run(&hung, &mut ctx, serde_json::json!({}))
338                .is_err()
339        );
340        let out = executor
341            .run(&probe(), &mut ctx, serde_json::json!({}))
342            .unwrap();
343        assert_eq!(out["ok"], true);
344        assert_eq!(executor.timeouts(), 1);
345        assert_eq!(executor.stats(), (2, 0, 0));
346    }
347
348    #[test]
349    fn unbounded_executor_runs_without_a_deadline() {
350        let executor = ScopedSandboxExecutor::new(|| Ok(())).with_timeout(None);
351        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
352        let out = executor
353            .run(&probe(), &mut ctx, serde_json::json!({}))
354            .unwrap();
355        assert_eq!(out["ok"], true);
356        assert_eq!(executor.timeouts(), 0);
357    }
358}