entracte_lib/plugins/runtime.rs
1//! The WASM capability sandbox (#156, slice 4).
2//!
3//! A plugin module is loaded into extism (embedding wasmtime) with **no
4//! ambient authority**: WASI is disabled, so the module gets no filesystem,
5//! clock, randomness, or network. Its entire outside-world surface is the
6//! host functions the host registers — and the host registers a function
7//! *only* if the matching capability was granted. A module that imports a
8//! host function whose capability wasn't granted **fails to load** (a
9//! wasmtime link error): the runtime half of the capability model, paired
10//! with the manifest-level `imports`↔grant check in `validate_manifest`.
11//!
12//! Execution is bounded three ways — a memory cap, a wasmtime fuel cap, and a
13//! wall-clock timeout — so a runaway module is trapped, never hangs the host.
14//!
15//! The host-function **bodies** here are stubs: this slice establishes the
16//! sandbox, the capability→host-function ABI, and the enforcement/metering
17//! contract. The detector and export slices replace the stubs with real,
18//! scope-checked implementations (read the foreground window, POST to the
19//! granted origin, …).
20
21use std::path::PathBuf;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25
26use extism::{
27 CurrentPlugin, Error as ExtismError, Manifest, PluginBuilder, UserData, Val, ValType, Wasm,
28};
29
30use super::detect;
31use super::manifest::Capability;
32
33/// The host-function name a detector calls to vote "suppress the next break".
34/// Ungated (every detector may report a verdict) and always registered, so a
35/// detector module always links against it.
36const HOST_SUPPRESS: &str = "host_suppress";
37
38/// Per-install context the host functions need beyond the capability scopes
39/// themselves, plus the verdict channel.
40#[derive(Debug, Clone, Default)]
41pub struct SandboxContext {
42 /// The process pattern a `detect:processes` grant matches against (from
43 /// the manifest's detect config). The `detect:file:<path>` scope lives in
44 /// the capability itself.
45 pub process_pattern: Option<String>,
46 /// Set `true` when the module calls `host_suppress()` during a `detect()`
47 /// run. The evaluator resets it before each run and reads it after, so a
48 /// cached plugin's verdict is fresh each cycle.
49 pub verdict: Arc<AtomicBool>,
50}
51
52/// Memory ceiling for a plugin instance, in 64 KiB wasm pages. 64 pages =
53/// 4 MiB — generous for a detector/export module, tight enough that a
54/// runaway allocation traps quickly.
55pub const DEFAULT_MEMORY_MAX_PAGES: u32 = 64;
56
57/// Wall-clock ceiling for a single plugin call. Detectors run on a throttled
58/// interval off the scheduler tick, so 250 ms is ample for a real probe
59/// while bounding an accidental infinite loop.
60pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(250);
61
62/// wasmtime fuel ceiling per instance — belt to the timeout's braces, so a
63/// tight CPU loop traps on fuel even if the timer thread is starved. Roughly
64/// one unit per wasm instruction; 5e8 is far above any real probe.
65pub const DEFAULT_FUEL: u64 = 500_000_000;
66
67/// The host-function name a capability unlocks in the `extism:host/user`
68/// namespace, i.e. the symbol a module imports to use that capability. The
69/// sandbox registers exactly these for the granted capabilities.
70pub fn host_function_name(cap: &Capability) -> &'static str {
71 match cap {
72 Capability::DetectForegroundWindow => "host_foreground_window",
73 Capability::DetectProcesses => "host_process_running",
74 Capability::DetectFile(_) => "host_read_flag",
75 Capability::ExportFile(_) => "host_write_file",
76 Capability::ExportHttp(_) => "host_http_post",
77 }
78}
79
80/// Set the single i64 output of a host function to a boolean (1/0).
81fn set_bool(outputs: &mut [Val], value: bool) {
82 if let Some(out) = outputs.first_mut() {
83 *out = Val::I64(value as i64);
84 }
85}
86
87/// Placeholder body for host functions whose real implementation lands in a
88/// later slice (foreground-window and the export sinks). Returns 0. Its mere
89/// presence is still gated by the capability, so registering it doesn't widen
90/// the module's reach.
91fn host_stub(
92 _plugin: &mut CurrentPlugin,
93 _inputs: &[Val],
94 outputs: &mut [Val],
95 _user_data: UserData<()>,
96) -> Result<(), ExtismError> {
97 set_bool(outputs, false);
98 Ok(())
99}
100
101/// Register a `() -> i64` boolean host function whose answer is `probe(data)`.
102/// `data` is captured host-side from the grant/context — never supplied by
103/// the module — so the probe is scope-checked by construction.
104fn register_probe<'a, T, F>(
105 builder: PluginBuilder<'a>,
106 name: &str,
107 data: T,
108 probe: F,
109) -> PluginBuilder<'a>
110where
111 T: Send + Sync + 'static,
112 F: Fn(&T) -> bool + Send + Sync + 'static,
113{
114 builder.with_function(
115 name,
116 [],
117 [ValType::I64],
118 UserData::new(data),
119 move |_p, _in, out, ud| {
120 let guard = ud.get()?;
121 set_bool(out, probe(&guard.lock().unwrap()));
122 Ok(())
123 },
124 )
125}
126
127/// Register the host function a single capability unlocks. All host functions
128/// are `() -> i64` booleans in this ABI; the data each one reads (the process
129/// pattern, the granted file path) is captured from the grant + context, so a
130/// module cannot influence what's probed.
131fn register_capability<'a>(
132 builder: PluginBuilder<'a>,
133 cap: &Capability,
134 ctx: &SandboxContext,
135) -> PluginBuilder<'a> {
136 let name = host_function_name(cap);
137 match cap {
138 Capability::DetectProcesses => {
139 let pattern = ctx.process_pattern.clone().unwrap_or_default();
140 register_probe(builder, name, pattern, |p: &String| {
141 detect::process_running(p)
142 })
143 }
144 Capability::DetectFile(path) => {
145 register_probe(builder, name, PathBuf::from(path), |p: &PathBuf| {
146 detect::read_flag(p)
147 })
148 }
149 // Foreground-window and the export sinks are stubbed until their
150 // slices; registered (gated) but inert.
151 Capability::DetectForegroundWindow
152 | Capability::ExportFile(_)
153 | Capability::ExportHttp(_) => {
154 builder.with_function(name, [], [ValType::I64], UserData::default(), host_stub)
155 }
156 }
157}
158
159/// Build a sandboxed plugin from `module` bytes, registering host functions
160/// **only** for the granted `capabilities`. WASI is off and memory / fuel /
161/// timeout are bounded (see the `DEFAULT_*` consts). Returns a user-facing
162/// error if the module fails to compile, link, or instantiate — including the
163/// case where it imports a host function whose capability wasn't granted.
164///
165/// Duplicate host-function names are registered once (the first grant wins),
166/// so a plugin with two `detect:file:<path>` grants links cleanly.
167pub fn build_sandboxed_plugin(
168 module: &[u8],
169 capabilities: &[Capability],
170 ctx: &SandboxContext,
171) -> Result<extism::Plugin, String> {
172 let manifest = Manifest::new([Wasm::data(module.to_vec())])
173 .with_memory_max(DEFAULT_MEMORY_MAX_PAGES)
174 .with_timeout(DEFAULT_TIMEOUT);
175
176 let mut builder = PluginBuilder::new(manifest)
177 .with_wasi(false)
178 .with_fuel_limit(DEFAULT_FUEL);
179
180 // The ungated verdict channel: calling it flips the shared flag.
181 let verdict = ctx.verdict.clone();
182 builder = builder.with_function(
183 HOST_SUPPRESS,
184 [],
185 [],
186 UserData::new(verdict),
187 |_p, _in, _out, ud| {
188 ud.get()?.lock().unwrap().store(true, Ordering::Relaxed);
189 Ok(())
190 },
191 );
192
193 let mut registered: Vec<&str> = Vec::new();
194 for cap in capabilities {
195 let name = host_function_name(cap);
196 if registered.contains(&name) {
197 continue;
198 }
199 registered.push(name);
200 builder = register_capability(builder, cap, ctx);
201 }
202
203 builder
204 .build()
205 // `{e:#}` not `{e}`: extism returns an `anyhow::Error` whose outermost
206 // message is generic ("failed to parse WebAssembly module"), with the
207 // actual reason — the unresolved import's name, the malformed section,
208 // "expected a core wasm module" — only in the cause chain. The plugin
209 // author needs that reason: this string's only sink is the install
210 // failure shown in Settings → Plugins (`install_plugin`), and the
211 // detector-eval path discards it entirely, so if it is not in here it
212 // is nowhere.
213 .map_err(|e| format!("plugin failed to load in the sandbox: {e:#}"))
214}
215
216/// Build a detector from its module + granted capabilities, run its `detect()`
217/// export once, and return whether it voted to suppress (by calling
218/// `host_suppress`). A module that fails to build, has no `detect` export, or
219/// traps is treated as **no suppression** — a broken detector never blocks a
220/// break. Pure aside from the probes the granted host functions perform.
221pub fn evaluate_detector(
222 module: &[u8],
223 capabilities: &[Capability],
224 process_pattern: Option<String>,
225) -> bool {
226 let ctx = SandboxContext {
227 process_pattern,
228 verdict: Arc::new(AtomicBool::new(false)),
229 };
230 let verdict = ctx.verdict.clone();
231 let mut plugin = match build_sandboxed_plugin(module, capabilities, &ctx) {
232 Ok(plugin) => plugin,
233 Err(_) => return false,
234 };
235 verdict.store(false, Ordering::Relaxed);
236 // We don't care about the call's output/return — only whether the module
237 // reported a verdict before returning or trapping.
238 let _ = plugin.call::<&str, &str>("detect", "");
239 verdict.load(Ordering::Relaxed)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 fn wasm(wat: &str) -> Vec<u8> {
247 wat::parse_str(wat).expect("valid WAT")
248 }
249
250 #[test]
251 fn host_function_name_covers_every_capability() {
252 for cap in [
253 Capability::DetectForegroundWindow,
254 Capability::DetectProcesses,
255 Capability::DetectFile("/p".to_string()),
256 Capability::ExportFile("/p".to_string()),
257 Capability::ExportHttp("127.0.0.1:8080".to_string()),
258 ] {
259 assert!(!host_function_name(&cap).is_empty());
260 }
261 }
262
263 /// A module that calls the named `() -> i64` host function and **traps**
264 /// when it returns non-zero, so a test can read the host's boolean answer
265 /// through call success (false) vs. error (true) — no extism output
266 /// marshalling needed.
267 fn trap_if_true_module(host_fn: &str) -> Vec<u8> {
268 wasm(&format!(
269 r#"(module
270 (import "extism:host/user" "{host_fn}" (func $f (result i64)))
271 (memory (export "memory") 1)
272 (func (export "run") (result i32)
273 (if (i64.ne (call $f) (i64.const 0)) (then unreachable))
274 (i32.const 0)))"#
275 ))
276 }
277
278 #[test]
279 fn loads_a_clean_module_with_no_imports() {
280 let module = wasm(
281 r#"(module
282 (memory (export "memory") 1)
283 (func (export "run") (result i32) i32.const 0))"#,
284 );
285 assert!(build_sandboxed_plugin(&module, &[], &SandboxContext::default()).is_ok());
286 }
287
288 /// Guards the first of the two reachability invariants behind #323:
289 /// **WASI stays off**. RUSTSEC-2026-0269 is a filesystem sandbox escape in
290 /// wasmtime's WASI path resolution, unpatched on the wasmtime line extism
291 /// pins (the version analysis lives in `src-tauri/deny.toml`, which is where
292 /// it stays current). It is not reachable here *because*
293 /// the builder sets `with_wasi(false)`: without WASI in the linker there
294 /// are no preopened directories and no `wasi_snapshot_preview1` import to
295 /// call, so the vulnerable path-resolution code is never instantiated.
296 ///
297 /// That argument holds only as long as WASI stays off. Flipping
298 /// `with_wasi(true)` would silently turn a documented non-issue into a
299 /// live sandbox escape, so pin it behaviourally rather than by comment.
300 #[test]
301 fn rejects_a_module_importing_the_wasi_filesystem() {
302 // path_open is the exact entry point RUSTSEC-2026-0269 exploits.
303 let module = wasm(
304 r#"(module
305 (import "wasi_snapshot_preview1" "path_open"
306 (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
307 (memory (export "memory") 1)
308 (func (export "run") (result i32) i32.const 0))"#,
309 );
310 let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default())
311 .expect_err("WASI must not be linked into the plugin sandbox");
312 assert!(
313 err.contains("wasi_snapshot_preview1::path_open"),
314 "the wasi_snapshot_preview1 import must be left unresolved, got: {err}"
315 );
316 }
317
318 /// Guards the second reachability invariant behind #323: **no components**.
319 /// RUSTSEC-2026-0316 ("dynamic record lifting can allocate beyond the
320 /// hostcall fuel limit") is a component-model bug — it lives in wasmtime's
321 /// dynamically-typed `wasmtime::component::Val` API, which hosts reach only
322 /// by instantiating a component. extism never touches the component model:
323 /// `extism::Val` is a type alias for the *core* `wasmtime::Val`, and a
324 /// plugin is always a core wasm module.
325 ///
326 /// Like the WASI invariant above, that holds only while it holds, so pin it
327 /// behaviourally: a component binary must fail to load, and must fail
328 /// *because* it is a component. Both inputs are bare preambles, so they are
329 /// byte-identical apart from the 4-byte version/layer field that is the only
330 /// thing distinguishing the two formats — the core module loading while the
331 /// component is rejected therefore shows the rejection is about the format
332 /// and not about the bytes being short or malformed. The assertion below
333 /// keeps that control honest rather than leaving it to the reader.
334 #[test]
335 fn rejects_a_wasm_component() {
336 let core_module = wasm("(module)");
337 let component = wasm("(component)");
338 assert_eq!(
339 (&core_module[..4], core_module.len()),
340 (&component[..4], component.len()),
341 "the two inputs must differ only in the version/layer field"
342 );
343
344 assert!(
345 build_sandboxed_plugin(&core_module, &[], &SandboxContext::default()).is_ok(),
346 "the core-module preamble must load, or this test proves nothing"
347 );
348
349 let err = build_sandboxed_plugin(&component, &[], &SandboxContext::default())
350 .expect_err("a wasm component must not load in the plugin sandbox");
351 assert!(
352 err.contains("component"),
353 "a component must be rejected as a component, got: {err}"
354 );
355 }
356
357 #[test]
358 fn rejects_a_module_importing_an_ungranted_host_function() {
359 // Imports host_process_running but no detect:processes grant.
360 let module = trap_if_true_module("host_process_running");
361 let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default()).unwrap_err();
362 assert!(
363 err.contains("extism:host/user::host_process_running"),
364 "the ungranted import must be left unresolved, got: {err}"
365 );
366 }
367
368 #[test]
369 fn host_process_running_reports_a_live_process_to_the_module() {
370 // "entracte" is a whole token of the test binary's process name on
371 // every platform (see the detect.rs test for the rationale).
372 // Pattern matches a live process → host returns true → the module
373 // traps → the call errors.
374 let module = trap_if_true_module("host_process_running");
375 let mut plugin = build_sandboxed_plugin(
376 &module,
377 &[Capability::DetectProcesses],
378 &SandboxContext {
379 process_pattern: Some("entracte".to_string()),
380 ..Default::default()
381 },
382 )
383 .expect("granted detect:processes builds");
384 assert!(
385 plugin.call::<&str, &str>("run", "").is_err(),
386 "a matching process should be reported true (module traps)"
387 );
388
389 // A pattern that matches nothing → host returns false → call succeeds.
390 let module = trap_if_true_module("host_process_running");
391 let mut plugin = build_sandboxed_plugin(
392 &module,
393 &[Capability::DetectProcesses],
394 &SandboxContext {
395 process_pattern: Some("entracte-no-such-process-zzz".to_string()),
396 ..Default::default()
397 },
398 )
399 .unwrap();
400 assert!(plugin.call::<&str, &str>("run", "").is_ok());
401 }
402
403 #[test]
404 fn host_read_flag_reports_the_granted_files_truthiness() {
405 let dir = crate::test_support::temp_dir();
406 let flag = dir.path().join("focus.flag");
407 std::fs::write(&flag, "true").unwrap();
408 let cap = Capability::DetectFile(flag.display().to_string());
409
410 // Truthy flag → host returns true → module traps → call errors.
411 let module = trap_if_true_module("host_read_flag");
412 let mut plugin = build_sandboxed_plugin(
413 &module,
414 std::slice::from_ref(&cap),
415 &SandboxContext::default(),
416 )
417 .expect("granted detect:file builds");
418 assert!(plugin.call::<&str, &str>("run", "").is_err());
419
420 // Flip the flag to falsey → host returns false → call succeeds.
421 std::fs::write(&flag, "0").unwrap();
422 let module = trap_if_true_module("host_read_flag");
423 let mut plugin = build_sandboxed_plugin(
424 &module,
425 std::slice::from_ref(&cap),
426 &SandboxContext::default(),
427 )
428 .unwrap();
429 assert!(plugin.call::<&str, &str>("run", "").is_ok());
430 }
431
432 #[test]
433 fn a_stubbed_capability_registers_an_inert_host_function() {
434 // Foreground-window is still a stub: a granted module that calls it
435 // links and the stub returns false, so the trap-if-true module's call
436 // succeeds.
437 let module = trap_if_true_module("host_foreground_window");
438 let mut plugin = build_sandboxed_plugin(
439 &module,
440 &[Capability::DetectForegroundWindow],
441 &SandboxContext::default(),
442 )
443 .expect("granted detect:foreground-window builds");
444 assert!(
445 plugin.call::<&str, &str>("run", "").is_ok(),
446 "the stub should return false (no suppression)"
447 );
448 }
449
450 #[test]
451 fn duplicate_capabilities_register_their_host_function_once() {
452 // Two detect:file grants map to one host_read_flag; the second is
453 // skipped so the module links cleanly against a single registration.
454 let module = trap_if_true_module("host_read_flag");
455 let grants = vec![
456 Capability::DetectFile("/tmp/a.flag".to_string()),
457 Capability::DetectFile("/tmp/b.flag".to_string()),
458 ];
459 assert!(build_sandboxed_plugin(&module, &grants, &SandboxContext::default()).is_ok());
460 }
461
462 /// A detector module that votes to suppress (via `host_suppress`) when
463 /// the granted `host_process_running` probe matches.
464 fn suppress_if_process_module() -> Vec<u8> {
465 wasm(
466 r#"(module
467 (import "extism:host/user" "host_process_running" (func $proc (result i64)))
468 (import "extism:host/user" "host_suppress" (func $suppress))
469 (memory (export "memory") 1)
470 (func (export "detect") (result i32)
471 (if (i64.ne (call $proc) (i64.const 0)) (then (call $suppress)))
472 (i32.const 0)))"#,
473 )
474 }
475
476 #[test]
477 fn evaluate_detector_reports_the_modules_verdict() {
478 let module = suppress_if_process_module();
479 // "entracte" matches the live test binary → module votes suppress.
480 assert!(evaluate_detector(
481 &module,
482 &[Capability::DetectProcesses],
483 Some("entracte".to_string())
484 ));
485 // A pattern matching nothing → no vote → no suppression.
486 assert!(!evaluate_detector(
487 &module,
488 &[Capability::DetectProcesses],
489 Some("entracte-no-such-process-zzz".to_string())
490 ));
491 }
492
493 #[test]
494 fn evaluate_detector_is_false_when_the_module_does_not_vote() {
495 // `detect` exists but calls nothing — no verdict reported.
496 let module = wasm(
497 r#"(module
498 (memory (export "memory") 1)
499 (func (export "detect") (result i32) (i32.const 0)))"#,
500 );
501 assert!(!evaluate_detector(&module, &[], None));
502 }
503
504 #[test]
505 fn evaluate_detector_treats_a_broken_module_as_no_suppression() {
506 // No `detect` export → the call fails → false, not a blocked break.
507 let no_export = wasm(r#"(module (memory (export "memory") 1))"#);
508 assert!(!evaluate_detector(&no_export, &[], None));
509
510 // Imports an ungranted host function → fails to build → false.
511 let ungranted = trap_if_true_module("host_foreground_window");
512 assert!(!evaluate_detector(&ungranted, &[], None));
513 }
514
515 #[test]
516 fn a_runaway_module_is_trapped_not_hung() {
517 // Infinite loop; the wall-clock timeout (and fuel) must abort the
518 // call rather than hang the test.
519 let module = wasm(
520 r#"(module
521 (memory (export "memory") 1)
522 (func (export "run") (result i32)
523 (loop $l (br $l))
524 (i32.const 0)))"#,
525 );
526 let mut plugin =
527 build_sandboxed_plugin(&module, &[], &SandboxContext::default()).expect("module loads");
528 let started = std::time::Instant::now();
529 let result = plugin.call::<&str, &str>("run", "");
530 let elapsed = started.elapsed();
531 assert!(result.is_err(), "runaway call must error, not return");
532 assert!(
533 elapsed < Duration::from_secs(5),
534 "metering must abort the runaway promptly"
535 );
536 }
537}