Skip to main content

yosh_plugin_manager/
metadata_extract.rs

1//! Extract `plugin-info` from a WebAssembly Component plugin without
2//! granting it any host capability.
3//!
4//! See `docs/superpowers/specs/2026-04-27-wasm-plugin-runtime-design.md` ยง7
5//! "Plugin manager pipeline" โ€” the manager wants to display
6//! `required-capabilities` and `implemented-hooks` in `yosh-plugin list`
7//! without instantiating the plugin twice (once at sync, once at startup).
8//! Extracting once here, caching in `plugins.lock`, lets `list` run
9//! offline and lets the host trust the lockfile values.
10//!
11//! ## Sandboxing
12//!
13//! The metadata contract (WIT interface `plugin`) says "implementations
14//! MUST NOT invoke any `yosh:plugin/*` host import from inside `metadata`."
15//! The host enforces this with the same deny-stub pattern when its
16//! `with_env` guard is not active (null env pointer). We enforce it here
17//! by registering EVERY `yosh:plugin/*` import as a deny-stub returning
18//! `Err(Denied)` regardless of input. WASI is restricted to clocks +
19//! random (matching the host's permanent allowlist).
20//!
21//! ## Watchdog
22//!
23//! The engine returned by `precompile::make_engine()` has
24//! `epoch_interruption(true)`. We bump the epoch from a detached thread
25//! after 5 seconds to interrupt a hung `metadata()` call. A well-behaved
26//! plugin runs `metadata` in microseconds.
27
28use std::time::Duration;
29
30use wasmtime::component::{Component, Linker, ResourceTable};
31use wasmtime::{Engine, Store};
32use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
33
34use crate::generated::yosh::plugin::types::{ErrorCode, HookName, IoStream};
35use crate::generated::{PluginWorld, PluginWorldPre};
36
37/// Per-store data for the metadata extraction sandbox. Carries a
38/// fully-empty `WasiCtx` (no preopens, no env, no stdio mapping) so the
39/// limited WASI surface still works but yields nothing useful โ€” exactly
40/// what we want for a metadata read.
41pub struct MetadataCtx {
42    table: ResourceTable,
43    wasi: WasiCtx,
44}
45
46impl Default for MetadataCtx {
47    fn default() -> Self {
48        // Defaults: no preopens, no env vars, no stdin/stdout/stderr.
49        // Plugins that try to read clocks/random get real values; anything
50        // else from `wasi:cli`, `wasi:filesystem`, `wasi:sockets`, etc.
51        // hits an unsatisfied import at link time.
52        let wasi = WasiCtxBuilder::new().build();
53        MetadataCtx {
54            table: ResourceTable::new(),
55            wasi,
56        }
57    }
58}
59
60impl WasiView for MetadataCtx {
61    fn ctx(&mut self) -> &mut WasiCtx {
62        &mut self.wasi
63    }
64
65    fn table(&mut self) -> &mut ResourceTable {
66        &mut self.table
67    }
68}
69
70/// What the manager extracts from each plugin during sync.
71#[derive(Debug, Clone)]
72pub struct ExtractedMetadata {
73    /// Plugin self-reported name. Useful sanity-check vs `plugins.toml`.
74    pub name: String,
75    pub version: String,
76    pub commands: Vec<String>,
77    pub required_capabilities: Vec<String>,
78    pub implemented_hooks: Vec<String>,
79}
80
81/// Extract plugin metadata. Compiles the wasm with the given engine,
82/// builds an all-deny linker, instantiates, calls `metadata()`, returns
83/// the result. A 5-second epoch watchdog interrupts the call if the
84/// plugin hangs.
85pub fn extract(engine: &Engine, wasm_bytes: &[u8]) -> Result<ExtractedMetadata, String> {
86    let component = Component::new(engine, wasm_bytes)
87        .map_err(|e| format!("metadata: compile component: {}", e))?;
88
89    let mut linker = Linker::<MetadataCtx>::new(engine);
90    register_limited_wasi(&mut linker).map_err(|e| format!("metadata: register WASI: {}", e))?;
91    register_all_deny_imports(&mut linker)
92        .map_err(|e| format!("metadata: register deny stubs: {}", e))?;
93
94    let pre = PluginWorldPre::new(
95        linker
96            .instantiate_pre(&component)
97            .map_err(|e| format!("metadata: instantiate_pre: {}", e))?,
98    )
99    .map_err(|e| format!("metadata: bindings pre-init: {}", e))?;
100
101    let mut store = Store::new(engine, MetadataCtx::default());
102    // Trip on the next epoch increment. The watchdog bumps after 5s.
103    store.set_epoch_deadline(1);
104
105    // Detached watchdog. We hold an Arc-clone of the engine so even if
106    // the parent function returns first, the thread can still call
107    // `increment_epoch` safely (no-op effect post-extraction).
108    let watchdog_engine: Engine = engine.clone();
109    let _watchdog = std::thread::Builder::new()
110        .name("yosh-plugin-metadata-watchdog".to_string())
111        .spawn(move || {
112            std::thread::sleep(Duration::from_secs(5));
113            // Engine::increment_epoch is cheap and idempotent. Calling it
114            // after the host call has finished is harmless.
115            watchdog_engine.increment_epoch();
116        });
117
118    let plugin_world: PluginWorld = pre
119        .instantiate(&mut store)
120        .map_err(|e| format!("metadata: instantiate: {}", e))?;
121
122    let info = plugin_world
123        .yosh_plugin_plugin()
124        .call_metadata(&mut store)
125        .map_err(|e| format!("metadata: call: {}", e))?;
126
127    Ok(ExtractedMetadata {
128        name: info.name,
129        version: info.version,
130        commands: info.commands,
131        required_capabilities: info.required_capabilities,
132        implemented_hooks: info
133            .implemented_hooks
134            .into_iter()
135            .map(hook_name_to_string)
136            .collect(),
137    })
138}
139
140fn hook_name_to_string(h: HookName) -> String {
141    match h {
142        HookName::PreExec => "pre-exec".into(),
143        HookName::PostExec => "post-exec".into(),
144        HookName::OnCd => "on-cd".into(),
145        HookName::PrePrompt => "pre-prompt".into(),
146    }
147}
148
149/// Register the same limited WASI surface the host allows: clocks +
150/// random. NO `wasi:cli`, `wasi:filesystem`, `wasi:sockets` โ€” a plugin
151/// importing them will fail to link, which is the desired sandbox
152/// behaviour.
153fn register_limited_wasi(linker: &mut Linker<MetadataCtx>) -> wasmtime::Result<()> {
154    use wasmtime_wasi::WasiImpl;
155    use wasmtime_wasi::bindings::{clocks, random};
156
157    let closure = type_annotate::<MetadataCtx, _>(|t| WasiImpl(t));
158    clocks::wall_clock::add_to_linker_get_host(linker, closure)?;
159    clocks::monotonic_clock::add_to_linker_get_host(linker, closure)?;
160    random::random::add_to_linker_get_host(linker, closure)?;
161    Ok(())
162}
163
164/// Pin the closure type for `add_to_linker_get_host`'s generic argument.
165/// Same pattern as `src/plugin/linker.rs::type_annotate` in the host.
166fn type_annotate<T, F>(val: F) -> F
167where
168    F: Fn(&mut T) -> wasmtime_wasi::WasiImpl<&mut T>,
169{
170    val
171}
172
173/// Register every `yosh:plugin/*` import as a stub returning
174/// `Err(Denied)`. The metadata contract forbids host calls during
175/// `metadata()`; this is the active enforcement vs. the host's "null env
176/// pointer" enforcement (both produce the same WIT result).
177fn register_all_deny_imports(linker: &mut Linker<MetadataCtx>) -> wasmtime::Result<()> {
178    let mut vars = linker.instance("yosh:plugin/variables@0.2.1")?;
179    vars.func_wrap(
180        "get",
181        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
182            Ok::<_, wasmtime::Error>((Err::<Option<String>, ErrorCode>(ErrorCode::Denied),))
183        },
184    )?;
185    vars.func_wrap(
186        "set",
187        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, String)| {
188            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
189        },
190    )?;
191    vars.func_wrap(
192        "export-env",
193        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, String)| {
194            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
195        },
196    )?;
197
198    let mut fs = linker.instance("yosh:plugin/filesystem@0.2.1")?;
199    fs.func_wrap(
200        "cwd",
201        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (): ()| {
202            Ok::<_, wasmtime::Error>((Err::<String, ErrorCode>(ErrorCode::Denied),))
203        },
204    )?;
205    fs.func_wrap(
206        "set-cwd",
207        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
208            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
209        },
210    )?;
211
212    let mut io = linker.instance("yosh:plugin/io@0.2.1")?;
213    io.func_wrap(
214        "write",
215        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (IoStream, Vec<u8>)| {
216            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
217        },
218    )?;
219    Ok(())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn metadata_ctx_default_constructs() {
228        let _c = MetadataCtx::default();
229    }
230
231    #[test]
232    fn linker_registration_smoke() {
233        let engine = crate::precompile::make_engine().unwrap();
234        let mut linker = Linker::<MetadataCtx>::new(&engine);
235        register_limited_wasi(&mut linker).expect("limited wasi");
236        register_all_deny_imports(&mut linker).expect("deny stubs");
237    }
238}