Skip to main content

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}