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 not applied to the sandboxed path (the
27//!   call is synchronous in the dispatcher); timeout parity is v1.1.
28//! - The per-dispatch cost is one OS thread + one current-thread runtime
29//!   (measured in the acceptance tests; parked-thread pooling is v1.1).
30//! - Subprocess-creating tools take a different seam: they declare
31//!   [`wm_core::Sandbox::Subprocess`] and build spawns through the
32//!   `SpawnPolicy` injected on the context (B2,
33//!   `crate::subprocess_sandbox`), because thread-local Landlock cannot
34//!   confine a child process.
35
36use std::sync::atomic::{AtomicU64, Ordering};
37
38use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
39
40/// Environment knob: `WM_LANDLOCK_V1=1` enables the per-tool pathway.
41///
42/// Strict parse (exactly `1`), mirroring `WM_LANDLOCK`. Off by default:
43/// v0 whole-process confinement and v1 per-tool confinement are separate
44/// deployment decisions.
45pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
46
47/// Whether the per-tool pathway was requested. Strict `== "1"` parse.
48#[must_use]
49pub fn v1_requested() -> bool {
50    std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
51}
52
53/// Confinement callback: `Ok(())` = the current thread is restricted;
54/// `Err(reason)` = confinement unavailable (loud-degrade, run unconfined).
55pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
56
57/// Runs [`Sandbox::StoreScoped`] tools on a confined scoped thread.
58pub struct ScopedSandboxExecutor {
59    restrict: RestrictFn,
60    runs: AtomicU64,
61    degraded: AtomicU64,
62    failures: AtomicU64,
63}
64
65impl ScopedSandboxExecutor {
66    /// Build with the caller's confinement callback.
67    #[must_use]
68    pub fn new(
69        restrict: impl Fn() -> std::result::Result<(), String> + Send + Sync + 'static,
70    ) -> Self {
71        Self {
72            restrict: Box::new(restrict),
73            runs: AtomicU64::new(0),
74            degraded: AtomicU64::new(0),
75            failures: AtomicU64::new(0),
76        }
77    }
78
79    /// (runs, degraded runs, contained panics/runtime failures).
80    #[must_use]
81    pub fn stats(&self) -> (u64, u64, u64) {
82        (
83            self.runs.load(Ordering::Relaxed),
84            self.degraded.load(Ordering::Relaxed),
85            self.failures.load(Ordering::Relaxed),
86        )
87    }
88
89    /// Execute one tool call on a confined thread.
90    ///
91    /// Synchronous by design: the dispatcher blocks while the confined
92    /// thread runs. Panics inside the tool are contained by the scoped
93    /// thread and surface as a `CoreError::Tool` — the process survives.
94    pub fn run(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
95        self.runs.fetch_add(1, Ordering::Relaxed);
96        let outcome = std::thread::scope(|scope| {
97            scope
98                .spawn(|| {
99                    if let Err(reason) = (self.restrict)() {
100                        self.degraded.fetch_add(1, Ordering::Relaxed);
101                        tracing::warn!(
102                            tool = tool.name(),
103                            reason = %reason,
104                            "sandbox: per-tool confinement unavailable — running unconfined (loud-degrade)"
105                        );
106                    }
107                    let runtime = tokio::runtime::Builder::new_current_thread()
108                        .enable_all()
109                        .build()
110                        .map_err(|e| {
111                            CoreError::Tool(format!("sandbox runtime build failed: {e}"))
112                        })?;
113                    runtime.block_on(tool.call(ctx, args))
114                })
115                .join()
116        });
117        match outcome {
118            Ok(result) => result,
119            Err(_panic) => {
120                self.failures.fetch_add(1, Ordering::Relaxed);
121                Err(CoreError::Tool(format!(
122                    "sandboxed tool '{}' panicked — contained by the scoped thread",
123                    tool.name()
124                )))
125            }
126        }
127    }
128
129    /// Whether a tool belongs on this pathway: `StoreScoped` declaration.
130    #[must_use]
131    pub fn handles(tool: &dyn Tool) -> bool {
132        tool.effects().sandbox == Sandbox::StoreScoped
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::sync::Arc;
140    use std::sync::atomic::AtomicBool;
141    use wm_core::{BrainWave, EffectRow, Gana, ToolStats};
142
143    struct ProbeTool {
144        effects: EffectRow,
145        stats: ToolStats,
146        /// Set when the tool body runs; the test's restriction callback
147        /// sets `restricted` first, so ordering is observable.
148        restricted_seen: Option<Arc<AtomicBool>>,
149        panic: bool,
150    }
151
152    #[async_trait::async_trait]
153    impl Tool for ProbeTool {
154        fn name(&self) -> &str {
155            "probe"
156        }
157        fn gana(&self) -> Gana {
158            Gana::Heart
159        }
160        fn effects(&self) -> &EffectRow {
161            &self.effects
162        }
163        async fn call(&self, _ctx: &mut wm_core::Context, _args: Args) -> wm_core::Result<Output> {
164            assert!(!self.panic, "probe tool panicked");
165            if let Some(flag) = &self.restricted_seen {
166                assert!(
167                    flag.load(Ordering::SeqCst),
168                    "tool must run AFTER the restriction callback"
169                );
170            }
171            Ok(serde_json::json!({"ok": true}))
172        }
173        fn stats(&self) -> &ToolStats {
174            &self.stats
175        }
176    }
177
178    fn probe() -> ProbeTool {
179        ProbeTool {
180            effects: EffectRow {
181                sandbox: Sandbox::StoreScoped,
182                ..Default::default()
183            },
184            stats: ToolStats::default(),
185            restricted_seen: None,
186            panic: false,
187        }
188    }
189
190    #[test]
191    fn restrict_runs_before_tool_and_output_passes_through() {
192        let restricted = Arc::new(AtomicBool::new(false));
193        let flag = Arc::clone(&restricted);
194        let executor = ScopedSandboxExecutor::new(move || {
195            flag.store(true, Ordering::SeqCst);
196            Ok(())
197        });
198        let mut tool = probe();
199        tool.restricted_seen = Some(Arc::clone(&restricted));
200        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
201        let out = executor
202            .run(&tool, &mut ctx, serde_json::json!({}))
203            .unwrap();
204        assert_eq!(out["ok"], true);
205        assert_eq!(executor.stats(), (1, 0, 0));
206    }
207
208    #[test]
209    fn confinement_failure_degrades_loud_but_runs() {
210        let executor = ScopedSandboxExecutor::new(|| Err("kernel says no".to_string()));
211        let tool = probe();
212        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
213        let out = executor
214            .run(&tool, &mut ctx, serde_json::json!({}))
215            .unwrap();
216        assert_eq!(out["ok"], true, "loud-degrade keeps availability up");
217        assert_eq!(executor.stats(), (1, 1, 0));
218    }
219
220    #[test]
221    fn tool_panic_is_contained_not_propagated() {
222        let executor = ScopedSandboxExecutor::new(|| Ok(()));
223        let mut tool = probe();
224        tool.panic = true;
225        let mut ctx = wm_core::Context::new(BrainWave::Gamma);
226        let result = executor.run(&tool, &mut ctx, serde_json::json!({}));
227        assert!(result.is_err(), "panic must surface as a tool error");
228        assert_eq!(executor.stats(), (1, 0, 1));
229    }
230
231    #[test]
232    fn handles_only_store_scoped_tools() {
233        let scoped = probe();
234        assert!(ScopedSandboxExecutor::handles(&scoped));
235        let inherited = ProbeTool {
236            effects: EffectRow::pure(),
237            ..probe()
238        };
239        assert!(!ScopedSandboxExecutor::handles(&inherited));
240    }
241
242    #[test]
243    fn env_flag_parses_strictly() {
244        // Pure parse contract mirrored from WM_LANDLOCK: only "1" enables.
245        // (Read-only check — the process env is shared and tests never
246        // mutate it; the strict parse is the property under test.)
247        let parse = |v: Option<&str>| v.is_some_and(|s| s == "1");
248        assert!(parse(Some("1")));
249        assert!(!parse(Some("0")));
250        assert!(!parse(Some("true")));
251        assert!(!parse(None));
252    }
253}