wm_dispatch/
sandbox_exec.rs1use std::sync::atomic::{AtomicU64, Ordering};
37
38use wm_core::{Args, Context, CoreError, Output, Result, Sandbox, Tool};
39
40pub const V1_FLAG_ENV: &str = "WM_LANDLOCK_V1";
46
47#[must_use]
49pub fn v1_requested() -> bool {
50 std::env::var(V1_FLAG_ENV).is_ok_and(|v| v == "1")
51}
52
53pub type RestrictFn = Box<dyn Fn() -> std::result::Result<(), String> + Send + Sync>;
56
57pub struct ScopedSandboxExecutor {
59 restrict: RestrictFn,
60 runs: AtomicU64,
61 degraded: AtomicU64,
62 failures: AtomicU64,
63}
64
65impl ScopedSandboxExecutor {
66 #[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 #[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 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 #[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 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 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}