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        .map_err(|e| format!("plugin failed to load in the sandbox: {e}"))
206}
207
208/// Build a detector from its module + granted capabilities, run its `detect()`
209/// export once, and return whether it voted to suppress (by calling
210/// `host_suppress`). A module that fails to build, has no `detect` export, or
211/// traps is treated as **no suppression** — a broken detector never blocks a
212/// break. Pure aside from the probes the granted host functions perform.
213pub fn evaluate_detector(
214    module: &[u8],
215    capabilities: &[Capability],
216    process_pattern: Option<String>,
217) -> bool {
218    let ctx = SandboxContext {
219        process_pattern,
220        verdict: Arc::new(AtomicBool::new(false)),
221    };
222    let verdict = ctx.verdict.clone();
223    let mut plugin = match build_sandboxed_plugin(module, capabilities, &ctx) {
224        Ok(plugin) => plugin,
225        Err(_) => return false,
226    };
227    verdict.store(false, Ordering::Relaxed);
228    // We don't care about the call's output/return — only whether the module
229    // reported a verdict before returning or trapping.
230    let _ = plugin.call::<&str, &str>("detect", "");
231    verdict.load(Ordering::Relaxed)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn wasm(wat: &str) -> Vec<u8> {
239        wat::parse_str(wat).expect("valid WAT")
240    }
241
242    #[test]
243    fn host_function_name_covers_every_capability() {
244        for cap in [
245            Capability::DetectForegroundWindow,
246            Capability::DetectProcesses,
247            Capability::DetectFile("/p".to_string()),
248            Capability::ExportFile("/p".to_string()),
249            Capability::ExportHttp("127.0.0.1:8080".to_string()),
250        ] {
251            assert!(!host_function_name(&cap).is_empty());
252        }
253    }
254
255    /// A module that calls the named `() -> i64` host function and **traps**
256    /// when it returns non-zero, so a test can read the host's boolean answer
257    /// through call success (false) vs. error (true) — no extism output
258    /// marshalling needed.
259    fn trap_if_true_module(host_fn: &str) -> Vec<u8> {
260        wasm(&format!(
261            r#"(module
262                 (import "extism:host/user" "{host_fn}" (func $f (result i64)))
263                 (memory (export "memory") 1)
264                 (func (export "run") (result i32)
265                   (if (i64.ne (call $f) (i64.const 0)) (then unreachable))
266                   (i32.const 0)))"#
267        ))
268    }
269
270    #[test]
271    fn loads_a_clean_module_with_no_imports() {
272        let module = wasm(
273            r#"(module
274                 (memory (export "memory") 1)
275                 (func (export "run") (result i32) i32.const 0))"#,
276        );
277        assert!(build_sandboxed_plugin(&module, &[], &SandboxContext::default()).is_ok());
278    }
279
280    /// Guards the reachability argument behind #323. Two unpatched wasmtime
281    /// advisories (RUSTSEC-2026-0269, a WASI filesystem sandbox escape, and
282    /// RUSTSEC-2026-0222) ride in on extism's pinned wasmtime 43.x, which has
283    /// no fix on its line. Neither is reachable here *because* the builder
284    /// sets `with_wasi(false)`: without WASI in the linker there are no
285    /// preopened directories and no `wasi_snapshot_preview1` import to call,
286    /// so the vulnerable path-resolution code is never instantiated.
287    ///
288    /// That argument holds only as long as WASI stays off. Flipping
289    /// `with_wasi(true)` would silently turn a documented non-issue into a
290    /// live sandbox escape, so pin it behaviourally rather than by comment.
291    #[test]
292    fn rejects_a_module_importing_the_wasi_filesystem() {
293        // path_open is the exact entry point RUSTSEC-2026-0269 exploits.
294        let module = wasm(
295            r#"(module
296                 (import "wasi_snapshot_preview1" "path_open"
297                   (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
298                 (memory (export "memory") 1)
299                 (func (export "run") (result i32) i32.const 0))"#,
300        );
301        let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default())
302            .expect_err("WASI must not be linked into the plugin sandbox");
303        assert!(
304            err.contains("failed to load"),
305            "a wasi_snapshot_preview1 import must fail the load, got: {err}"
306        );
307    }
308
309    #[test]
310    fn rejects_a_module_importing_an_ungranted_host_function() {
311        // Imports host_process_running but no detect:processes grant.
312        let module = trap_if_true_module("host_process_running");
313        let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default()).unwrap_err();
314        assert!(
315            err.contains("failed to load"),
316            "ungranted import should fail the load, got: {err}"
317        );
318    }
319
320    #[test]
321    fn host_process_running_reports_a_live_process_to_the_module() {
322        // "entracte" is a whole token of the test binary's process name on
323        // every platform (see the detect.rs test for the rationale).
324        // Pattern matches a live process → host returns true → the module
325        // traps → the call errors.
326        let module = trap_if_true_module("host_process_running");
327        let mut plugin = build_sandboxed_plugin(
328            &module,
329            &[Capability::DetectProcesses],
330            &SandboxContext {
331                process_pattern: Some("entracte".to_string()),
332                ..Default::default()
333            },
334        )
335        .expect("granted detect:processes builds");
336        assert!(
337            plugin.call::<&str, &str>("run", "").is_err(),
338            "a matching process should be reported true (module traps)"
339        );
340
341        // A pattern that matches nothing → host returns false → call succeeds.
342        let module = trap_if_true_module("host_process_running");
343        let mut plugin = build_sandboxed_plugin(
344            &module,
345            &[Capability::DetectProcesses],
346            &SandboxContext {
347                process_pattern: Some("entracte-no-such-process-zzz".to_string()),
348                ..Default::default()
349            },
350        )
351        .unwrap();
352        assert!(plugin.call::<&str, &str>("run", "").is_ok());
353    }
354
355    #[test]
356    fn host_read_flag_reports_the_granted_files_truthiness() {
357        let dir = crate::test_support::temp_dir();
358        let flag = dir.path().join("focus.flag");
359        std::fs::write(&flag, "true").unwrap();
360        let cap = Capability::DetectFile(flag.display().to_string());
361
362        // Truthy flag → host returns true → module traps → call errors.
363        let module = trap_if_true_module("host_read_flag");
364        let mut plugin = build_sandboxed_plugin(
365            &module,
366            std::slice::from_ref(&cap),
367            &SandboxContext::default(),
368        )
369        .expect("granted detect:file builds");
370        assert!(plugin.call::<&str, &str>("run", "").is_err());
371
372        // Flip the flag to falsey → host returns false → call succeeds.
373        std::fs::write(&flag, "0").unwrap();
374        let module = trap_if_true_module("host_read_flag");
375        let mut plugin = build_sandboxed_plugin(
376            &module,
377            std::slice::from_ref(&cap),
378            &SandboxContext::default(),
379        )
380        .unwrap();
381        assert!(plugin.call::<&str, &str>("run", "").is_ok());
382    }
383
384    #[test]
385    fn a_stubbed_capability_registers_an_inert_host_function() {
386        // Foreground-window is still a stub: a granted module that calls it
387        // links and the stub returns false, so the trap-if-true module's call
388        // succeeds.
389        let module = trap_if_true_module("host_foreground_window");
390        let mut plugin = build_sandboxed_plugin(
391            &module,
392            &[Capability::DetectForegroundWindow],
393            &SandboxContext::default(),
394        )
395        .expect("granted detect:foreground-window builds");
396        assert!(
397            plugin.call::<&str, &str>("run", "").is_ok(),
398            "the stub should return false (no suppression)"
399        );
400    }
401
402    #[test]
403    fn duplicate_capabilities_register_their_host_function_once() {
404        // Two detect:file grants map to one host_read_flag; the second is
405        // skipped so the module links cleanly against a single registration.
406        let module = trap_if_true_module("host_read_flag");
407        let grants = vec![
408            Capability::DetectFile("/tmp/a.flag".to_string()),
409            Capability::DetectFile("/tmp/b.flag".to_string()),
410        ];
411        assert!(build_sandboxed_plugin(&module, &grants, &SandboxContext::default()).is_ok());
412    }
413
414    /// A detector module that votes to suppress (via `host_suppress`) when
415    /// the granted `host_process_running` probe matches.
416    fn suppress_if_process_module() -> Vec<u8> {
417        wasm(
418            r#"(module
419                 (import "extism:host/user" "host_process_running" (func $proc (result i64)))
420                 (import "extism:host/user" "host_suppress" (func $suppress))
421                 (memory (export "memory") 1)
422                 (func (export "detect") (result i32)
423                   (if (i64.ne (call $proc) (i64.const 0)) (then (call $suppress)))
424                   (i32.const 0)))"#,
425        )
426    }
427
428    #[test]
429    fn evaluate_detector_reports_the_modules_verdict() {
430        let module = suppress_if_process_module();
431        // "entracte" matches the live test binary → module votes suppress.
432        assert!(evaluate_detector(
433            &module,
434            &[Capability::DetectProcesses],
435            Some("entracte".to_string())
436        ));
437        // A pattern matching nothing → no vote → no suppression.
438        assert!(!evaluate_detector(
439            &module,
440            &[Capability::DetectProcesses],
441            Some("entracte-no-such-process-zzz".to_string())
442        ));
443    }
444
445    #[test]
446    fn evaluate_detector_is_false_when_the_module_does_not_vote() {
447        // `detect` exists but calls nothing — no verdict reported.
448        let module = wasm(
449            r#"(module
450                 (memory (export "memory") 1)
451                 (func (export "detect") (result i32) (i32.const 0)))"#,
452        );
453        assert!(!evaluate_detector(&module, &[], None));
454    }
455
456    #[test]
457    fn evaluate_detector_treats_a_broken_module_as_no_suppression() {
458        // No `detect` export → the call fails → false, not a blocked break.
459        let no_export = wasm(r#"(module (memory (export "memory") 1))"#);
460        assert!(!evaluate_detector(&no_export, &[], None));
461
462        // Imports an ungranted host function → fails to build → false.
463        let ungranted = trap_if_true_module("host_foreground_window");
464        assert!(!evaluate_detector(&ungranted, &[], None));
465    }
466
467    #[test]
468    fn a_runaway_module_is_trapped_not_hung() {
469        // Infinite loop; the wall-clock timeout (and fuel) must abort the
470        // call rather than hang the test.
471        let module = wasm(
472            r#"(module
473                 (memory (export "memory") 1)
474                 (func (export "run") (result i32)
475                   (loop $l (br $l))
476                   (i32.const 0)))"#,
477        );
478        let mut plugin =
479            build_sandboxed_plugin(&module, &[], &SandboxContext::default()).expect("module loads");
480        let started = std::time::Instant::now();
481        let result = plugin.call::<&str, &str>("run", "");
482        let elapsed = started.elapsed();
483        assert!(result.is_err(), "runaway call must error, not return");
484        assert!(
485            elapsed < Duration::from_secs(5),
486            "metering must abort the runaway promptly"
487        );
488    }
489}