use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
#[must_use]
pub fn v1_requested() -> bool {
std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
}
pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
pub struct ScopedSandboxExecutor {
restrict: RestrictFn,
timeout: Option<Duration>,
runs: AtomicU64,
degraded: AtomicU64,
failures: AtomicU64,
timeouts: AtomicU64,
}
impl ScopedSandboxExecutor {
#[must_use]
pub fn new(
restrict: impl Fn() -> std::result::Result<(), String> + Send + Sync + 'static,
) -> Self {
Self {
restrict: Box::new(restrict),
timeout: crate::DispatchPipeline::timeout_from_env(),
runs: AtomicU64::new(0),
degraded: AtomicU64::new(0),
failures: AtomicU64::new(0),
timeouts: AtomicU64::new(0),
}
}
#[must_use]
pub const fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn timeouts(&self) -> u64 {
self.timeouts.load(Ordering::Relaxed)
}
#[must_use]
pub fn stats(&self) -> (u64, u64, u64) {
(
self.runs.load(Ordering::Relaxed),
self.degraded.load(Ordering::Relaxed),
self.failures.load(Ordering::Relaxed),
)
}
pub fn run(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
self.runs.fetch_add(1, Ordering::Relaxed);
let timeout = self.timeout;
let outcome = std::thread::scope(|scope| {
scope
.spawn(|| {
if let Err(reason) = (self.restrict)() {
self.degraded.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
tool = tool.name(),
reason = %reason,
"sandbox: per-tool confinement unavailable — running unconfined (loud-degrade)"
);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
CoreError::Tool(format!("sandbox runtime build failed: {e}"))
})?;
match timeout {
Some(timeout) => match runtime.block_on(async {
tokio::time::timeout(timeout, tool.call(ctx, args)).await
}) {
Ok(result) => result,
Err(_elapsed) => {
self.timeouts.fetch_add(1, Ordering::Relaxed);
tracing::error!(
tool = tool.name(),
timeout_ms = timeout.as_millis(),
"sandboxed tool dispatch timed out"
);
Err(CoreError::Tool(format!(
"sandboxed tool '{}' timed out after {}ms",
tool.name(),
timeout.as_millis()
)))
}
},
None => runtime.block_on(tool.call(ctx, args)),
}
})
.join()
});
match outcome {
Ok(result) => result,
Err(_panic) => {
self.failures.fetch_add(1, Ordering::Relaxed);
Err(CoreError::Tool(format!(
"sandboxed tool '{}' panicked — contained by the scoped thread",
tool.name()
)))
}
}
}
#[must_use]
pub fn handles(tool: &dyn Tool) -> bool {
tool.effects().sandbox == Sandbox::StoreScoped
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use wm_core::{BrainWave, EffectRow, Gana, ToolStats};
struct ProbeTool {
effects: EffectRow,
stats: ToolStats,
restricted_seen: Option<Arc<AtomicBool>>,
hang: bool,
panic: bool,
}
#[async_trait::async_trait]
impl Tool for ProbeTool {
fn name(&self) -> &str {
"probe"
}
fn gana(&self) -> Gana {
Gana::Heart
}
fn effects(&self) -> &EffectRow {
&self.effects
}
async fn call(&self, _ctx: &mut wm_core::Context, _args: Args) -> wm_core::Result<Output> {
assert!(!self.panic, "probe tool panicked");
if self.hang {
std::future::pending::<()>().await;
}
if let Some(flag) = &self.restricted_seen {
assert!(
flag.load(Ordering::SeqCst),
"tool must run AFTER the restriction callback"
);
}
Ok(serde_json::json!({"ok": true}))
}
fn stats(&self) -> &ToolStats {
&self.stats
}
}
fn probe() -> ProbeTool {
ProbeTool {
effects: EffectRow {
sandbox: Sandbox::StoreScoped,
..Default::default()
},
stats: ToolStats::default(),
restricted_seen: None,
hang: false,
panic: false,
}
}
#[test]
fn restrict_runs_before_tool_and_output_passes_through() {
let restricted = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&restricted);
let executor = ScopedSandboxExecutor::new(move || {
flag.store(true, Ordering::SeqCst);
Ok(())
});
let mut tool = probe();
tool.restricted_seen = Some(Arc::clone(&restricted));
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
let out = executor
.run(&tool, &mut ctx, serde_json::json!({}))
.unwrap();
assert_eq!(out["ok"], true);
assert_eq!(executor.stats(), (1, 0, 0));
}
#[test]
fn confinement_failure_degrades_loud_but_runs() {
let executor = ScopedSandboxExecutor::new(|| Err("kernel says no".to_string()));
let tool = probe();
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
let out = executor
.run(&tool, &mut ctx, serde_json::json!({}))
.unwrap();
assert_eq!(out["ok"], true, "loud-degrade keeps availability up");
assert_eq!(executor.stats(), (1, 1, 0));
}
#[test]
fn tool_panic_is_contained_not_propagated() {
let executor = ScopedSandboxExecutor::new(|| Ok(()));
let mut tool = probe();
tool.panic = true;
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
let result = executor.run(&tool, &mut ctx, serde_json::json!({}));
assert!(result.is_err(), "panic must surface as a tool error");
assert_eq!(executor.stats(), (1, 0, 1));
}
#[test]
fn handles_only_store_scoped_tools() {
let scoped = probe();
assert!(ScopedSandboxExecutor::handles(&scoped));
let inherited = ProbeTool {
effects: EffectRow::pure(),
..probe()
};
assert!(!ScopedSandboxExecutor::handles(&inherited));
}
#[test]
fn env_flag_parses_strictly() {
let parse = |v: Option<&str>| v.is_some_and(|s| s == "1");
assert!(parse(Some("1")));
assert!(!parse(Some("0")));
assert!(!parse(Some("true")));
assert!(!parse(None));
}
#[test]
fn hung_tool_times_out_promptly_and_is_counted() {
let executor =
ScopedSandboxExecutor::new(|| Ok(())).with_timeout(Some(Duration::from_millis(50)));
let mut tool = probe();
tool.hang = true;
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
let started = std::time::Instant::now();
let error = executor
.run(&tool, &mut ctx, serde_json::json!({}))
.unwrap_err()
.to_string();
assert!(error.contains("timed out"), "{error}");
assert!(
started.elapsed() < Duration::from_secs(5),
"timeout must return promptly, took {:?}",
started.elapsed()
);
assert_eq!(executor.timeouts(), 1);
assert_eq!(executor.stats(), (1, 0, 0));
}
#[test]
fn executor_recovers_after_a_timeout() {
let executor =
ScopedSandboxExecutor::new(|| Ok(())).with_timeout(Some(Duration::from_millis(50)));
let mut hung = probe();
hung.hang = true;
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
assert!(
executor
.run(&hung, &mut ctx, serde_json::json!({}))
.is_err()
);
let out = executor
.run(&probe(), &mut ctx, serde_json::json!({}))
.unwrap();
assert_eq!(out["ok"], true);
assert_eq!(executor.timeouts(), 1);
assert_eq!(executor.stats(), (2, 0, 0));
}
#[test]
fn unbounded_executor_runs_without_a_deadline() {
let executor = ScopedSandboxExecutor::new(|| Ok(())).with_timeout(None);
let mut ctx = wm_core::Context::new(BrainWave::Gamma);
let out = executor
.run(&probe(), &mut ctx, serde_json::json!({}))
.unwrap();
assert_eq!(out["ok"], true);
assert_eq!(executor.timeouts(), 0);
}
}