1use std::sync::atomic::{AtomicU64, Ordering};
38use std::time::Duration;
39
40use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
41
42pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
48
49#[must_use]
51pub fn v1_requested() -> bool {
52 std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
53}
54
55pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
58
59pub 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 #[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 #[must_use]
91 pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
92 self.timeout = timeout;
93 self
94 }
95
96 #[must_use]
98 pub fn timeouts(&self) -> u64 {
99 self.timeouts.load(Ordering::Relaxed)
100 }
101
102 #[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 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 #[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 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 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}