wm_dispatch/
sandbox_exec.rs1use std::sync::atomic::{AtomicU64, Ordering};
36
37use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
38
39pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
45
46#[must_use]
48pub fn v1_requested() -> bool {
49 std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
50}
51
52pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
55
56pub struct ScopedSandboxExecutor {
58 restrict: RestrictFn,
59 runs: AtomicU64,
60 degraded: AtomicU64,
61 failures: AtomicU64,
62}
63
64impl ScopedSandboxExecutor {
65 #[must_use]
67 pub fn new(
68 restrict: impl Fn() -> std::result::Result<(), String> + Send + Sync + 'static,
69 ) -> Self {
70 Self {
71 restrict: Box::new(restrict),
72 runs: AtomicU64::new(0),
73 degraded: AtomicU64::new(0),
74 failures: AtomicU64::new(0),
75 }
76 }
77
78 #[must_use]
80 pub fn stats(&self) -> (u64, u64, u64) {
81 (
82 self.runs.load(Ordering::Relaxed),
83 self.degraded.load(Ordering::Relaxed),
84 self.failures.load(Ordering::Relaxed),
85 )
86 }
87
88 pub fn run(&self, tool: &dyn Tool, ctx: &mut Context, args: Args) -> Result<Output> {
94 self.runs.fetch_add(1, Ordering::Relaxed);
95 let outcome = std::thread::scope(|scope| {
96 scope
97 .spawn(|| {
98 if let Err(reason) = (self.restrict)() {
99 self.degraded.fetch_add(1, Ordering::Relaxed);
100 tracing::warn!(
101 tool = tool.name(),
102 reason = %reason,
103 "sandbox: per-tool confinement unavailable — running unconfined (loud-degrade)"
104 );
105 }
106 let runtime = tokio::runtime::Builder::new_current_thread()
107 .enable_all()
108 .build()
109 .map_err(|e| {
110 CoreError::Tool(format!("sandbox runtime build failed: {e}"))
111 })?;
112 runtime.block_on(tool.call(ctx, args))
113 })
114 .join()
115 });
116 match outcome {
117 Ok(result) => result,
118 Err(_panic) => {
119 self.failures.fetch_add(1, Ordering::Relaxed);
120 Err(CoreError::Tool(format!(
121 "sandboxed tool '{}' panicked — contained by the scoped thread",
122 tool.name()
123 )))
124 }
125 }
126 }
127
128 #[must_use]
130 pub fn handles(tool: &dyn Tool) -> bool {
131 tool.effects().sandbox == Sandbox::StoreScoped
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::sync::Arc;
139 use std::sync::atomic::AtomicBool;
140 use wm_core::{BrainWave, EffectRow, Gana, ToolStats};
141
142 struct ProbeTool {
143 effects: EffectRow,
144 stats: ToolStats,
145 restricted_seen: Option<Arc<AtomicBool>>,
148 panic: bool,
149 }
150
151 #[async_trait::async_trait]
152 impl Tool for ProbeTool {
153 fn name(&self) -> &str {
154 "probe"
155 }
156 fn gana(&self) -> Gana {
157 Gana::Heart
158 }
159 fn effects(&self) -> &EffectRow {
160 &self.effects
161 }
162 async fn call(&self, _ctx: &mut wm_core::Context, _args: Args) -> wm_core::Result<Output> {
163 assert!(!self.panic, "probe tool panicked");
164 if let Some(flag) = &self.restricted_seen {
165 assert!(
166 flag.load(Ordering::SeqCst),
167 "tool must run AFTER the restriction callback"
168 );
169 }
170 Ok(serde_json::json!({"ok": true}))
171 }
172 fn stats(&self) -> &ToolStats {
173 &self.stats
174 }
175 }
176
177 fn probe() -> ProbeTool {
178 ProbeTool {
179 effects: EffectRow {
180 sandbox: Sandbox::StoreScoped,
181 ..Default::default()
182 },
183 stats: ToolStats::default(),
184 restricted_seen: None,
185 panic: false,
186 }
187 }
188
189 #[test]
190 fn restrict_runs_before_tool_and_output_passes_through() {
191 let restricted = Arc::new(AtomicBool::new(false));
192 let flag = Arc::clone(&restricted);
193 let executor = ScopedSandboxExecutor::new(move || {
194 flag.store(true, Ordering::SeqCst);
195 Ok(())
196 });
197 let mut tool = probe();
198 tool.restricted_seen = Some(Arc::clone(&restricted));
199 let mut ctx = wm_core::Context::new(BrainWave::Gamma);
200 let out = executor
201 .run(&tool, &mut ctx, serde_json::json!({}))
202 .unwrap();
203 assert_eq!(out["ok"], true);
204 assert_eq!(executor.stats(), (1, 0, 0));
205 }
206
207 #[test]
208 fn confinement_failure_degrades_loud_but_runs() {
209 let executor = ScopedSandboxExecutor::new(|| Err("kernel says no".to_string()));
210 let tool = probe();
211 let mut ctx = wm_core::Context::new(BrainWave::Gamma);
212 let out = executor
213 .run(&tool, &mut ctx, serde_json::json!({}))
214 .unwrap();
215 assert_eq!(out["ok"], true, "loud-degrade keeps availability up");
216 assert_eq!(executor.stats(), (1, 1, 0));
217 }
218
219 #[test]
220 fn tool_panic_is_contained_not_propagated() {
221 let executor = ScopedSandboxExecutor::new(|| Ok(()));
222 let mut tool = probe();
223 tool.panic = true;
224 let mut ctx = wm_core::Context::new(BrainWave::Gamma);
225 let result = executor.run(&tool, &mut ctx, serde_json::json!({}));
226 assert!(result.is_err(), "panic must surface as a tool error");
227 assert_eq!(executor.stats(), (1, 0, 1));
228 }
229
230 #[test]
231 fn handles_only_store_scoped_tools() {
232 let scoped = probe();
233 assert!(ScopedSandboxExecutor::handles(&scoped));
234 let inherited = ProbeTool {
235 effects: EffectRow::pure(),
236 ..probe()
237 };
238 assert!(!ScopedSandboxExecutor::handles(&inherited));
239 }
240
241 #[test]
242 fn env_flag_parses_strictly() {
243 let parse = |v: Option<&str>| v.is_some_and(|s| s == "1");
247 assert!(parse(Some("1")));
248 assert!(!parse(Some("0")));
249 assert!(!parse(Some("true")));
250 assert!(!parse(None));
251 }
252}