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.
19//!
20//! For WASI we register the full Preview 2 sync surface (matching the
21//! host's `build_linker`). Cargo-component-built plugins pull in
22//! `wasi:io/*` and `wasi:cli/*` transitively through the Preview 1
23//! adapter, regardless of whether their Rust source uses stdio. The
24//! sandbox boundary is the empty `WasiCtx` (no preopens, no stdio, no
25//! env, no args) โ€” every WASI probe returns empty rather than failing
26//! at link time. Selectively linking only clocks + random caused
27//! issue #3: real plugins failed `instantiate_pre` and were silently
28//! dropped from `plugins.lock`.
29//!
30//! ## Watchdog
31//!
32//! The engine returned by `precompile::make_engine()` has
33//! `epoch_interruption(true)`. We bump the epoch from a detached thread
34//! after 5 seconds to interrupt a hung `metadata()` call. A well-behaved
35//! plugin runs `metadata` in microseconds.
36
37use std::time::Duration;
38
39use wasmtime::component::{Component, Linker, ResourceTable};
40use wasmtime::{Engine, Store};
41use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
42
43use crate::generated::yosh::plugin::commands::ExecOutput;
44use crate::generated::yosh::plugin::files::{DirEntry, FileStat};
45use crate::generated::yosh::plugin::types::{ErrorCode, HookName, IoStream};
46use crate::generated::{PluginWorld, PluginWorldPre};
47
48/// Per-store data for the metadata extraction sandbox. Carries a
49/// fully-empty `WasiCtx` (no preopens, no env, no stdio mapping) so
50/// every WASI probe returns empty data โ€” the empty context, not
51/// import-time link failure, is what isolates the plugin.
52pub struct MetadataCtx {
53    table: ResourceTable,
54    wasi: WasiCtx,
55}
56
57impl Default for MetadataCtx {
58    fn default() -> Self {
59        // Defaults: no preopens, no env vars, no stdin/stdout/stderr, no
60        // args. The full WASI Preview 2 surface is linked (see
61        // `register_wasi`), but `wasi:cli/environment` returns an empty
62        // list, `wasi:filesystem/preopens` is empty, `wasi:io` reads/
63        // writes operate on no streams, etc. Only `wasi:clocks` and
64        // `wasi:random` yield real values, which is harmless for a
65        // metadata read.
66        let wasi = WasiCtxBuilder::new().build();
67        MetadataCtx {
68            table: ResourceTable::new(),
69            wasi,
70        }
71    }
72}
73
74impl WasiView for MetadataCtx {
75    fn ctx(&mut self) -> &mut WasiCtx {
76        &mut self.wasi
77    }
78
79    fn table(&mut self) -> &mut ResourceTable {
80        &mut self.table
81    }
82}
83
84/// What the manager extracts from each plugin during sync.
85#[derive(Debug, Clone)]
86pub struct ExtractedMetadata {
87    /// Plugin self-reported name. Useful sanity-check vs `plugins.toml`.
88    pub name: String,
89    pub version: String,
90    pub commands: Vec<String>,
91    pub required_capabilities: Vec<String>,
92    pub implemented_hooks: Vec<String>,
93}
94
95/// Extract plugin metadata. Compiles the wasm with the given engine,
96/// builds an all-deny linker, instantiates, calls `metadata()`, returns
97/// the result. A 5-second epoch watchdog interrupts the call if the
98/// plugin hangs.
99pub fn extract(engine: &Engine, wasm_bytes: &[u8]) -> Result<ExtractedMetadata, String> {
100    let component = Component::new(engine, wasm_bytes)
101        .map_err(|e| format!("metadata: compile component: {}", e))?;
102
103    let mut linker = Linker::<MetadataCtx>::new(engine);
104    register_wasi(&mut linker).map_err(|e| format!("metadata: register WASI: {}", e))?;
105    register_all_deny_imports(&mut linker)
106        .map_err(|e| format!("metadata: register deny stubs: {}", e))?;
107
108    let pre = PluginWorldPre::new(
109        linker
110            .instantiate_pre(&component)
111            .map_err(|e| format!("metadata: instantiate_pre: {}", e))?,
112    )
113    .map_err(|e| format!("metadata: bindings pre-init: {}", e))?;
114
115    let mut store = Store::new(engine, MetadataCtx::default());
116    // Trip on the next epoch increment. The watchdog bumps after 5s.
117    store.set_epoch_deadline(1);
118
119    // Detached watchdog. We hold an Arc-clone of the engine so even if
120    // the parent function returns first, the thread can still call
121    // `increment_epoch` safely (no-op effect post-extraction).
122    let watchdog_engine: Engine = engine.clone();
123    let _watchdog = std::thread::Builder::new()
124        .name("yosh-plugin-metadata-watchdog".to_string())
125        .spawn(move || {
126            std::thread::sleep(Duration::from_secs(5));
127            // Engine::increment_epoch is cheap and idempotent. Calling it
128            // after the host call has finished is harmless.
129            watchdog_engine.increment_epoch();
130        });
131
132    let plugin_world: PluginWorld = pre
133        .instantiate(&mut store)
134        .map_err(|e| format!("metadata: instantiate: {}", e))?;
135
136    let info = plugin_world
137        .yosh_plugin_plugin()
138        .call_metadata(&mut store)
139        .map_err(|e| format!("metadata: call: {}", e))?;
140
141    Ok(ExtractedMetadata {
142        name: info.name,
143        version: info.version,
144        commands: info.commands,
145        required_capabilities: info.required_capabilities,
146        implemented_hooks: info
147            .implemented_hooks
148            .into_iter()
149            .map(hook_name_to_string)
150            .collect(),
151    })
152}
153
154fn hook_name_to_string(h: HookName) -> String {
155    match h {
156        HookName::PreExec => "pre-exec".into(),
157        HookName::PostExec => "post-exec".into(),
158        HookName::OnCd => "on-cd".into(),
159        HookName::PrePrompt => "pre-prompt".into(),
160    }
161}
162
163/// Register the full WASI Preview 2 sync surface, matching the host's
164/// `build_linker`. Cargo-component-built plugins transitively import
165/// `wasi:io/*` and `wasi:cli/*` through the Preview 1 adapter, so a
166/// narrower allowlist breaks `instantiate_pre`. Isolation is provided
167/// by the empty `WasiCtx` constructed in `MetadataCtx::default`: every
168/// probe returns empty data instead of failing at link time.
169fn register_wasi(linker: &mut Linker<MetadataCtx>) -> wasmtime::Result<()> {
170    wasmtime_wasi::add_to_linker_sync(linker)
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
220    let mut files = linker.instance("yosh:plugin/files@0.2.1")?;
221    files.func_wrap(
222        "read-file",
223        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
224            Ok::<_, wasmtime::Error>((Err::<Vec<u8>, ErrorCode>(ErrorCode::Denied),))
225        },
226    )?;
227    files.func_wrap(
228        "read-dir",
229        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
230            Ok::<_, wasmtime::Error>((Err::<Vec<DirEntry>, ErrorCode>(ErrorCode::Denied),))
231        },
232    )?;
233    files.func_wrap(
234        "metadata",
235        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
236            Ok::<_, wasmtime::Error>((Err::<FileStat, ErrorCode>(ErrorCode::Denied),))
237        },
238    )?;
239    files.func_wrap(
240        "write-file",
241        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, Vec<u8>)| {
242            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
243        },
244    )?;
245    files.func_wrap(
246        "append-file",
247        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, Vec<u8>)| {
248            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
249        },
250    )?;
251    files.func_wrap(
252        "create-dir",
253        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, bool)| {
254            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
255        },
256    )?;
257    files.func_wrap(
258        "remove-file",
259        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_,): (String,)| {
260            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
261        },
262    )?;
263    files.func_wrap(
264        "remove-dir",
265        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, bool)| {
266            Ok::<_, wasmtime::Error>((Err::<(), ErrorCode>(ErrorCode::Denied),))
267        },
268    )?;
269
270    let mut commands = linker.instance("yosh:plugin/commands@0.2.1")?;
271    commands.func_wrap(
272        "exec",
273        |_store: wasmtime::StoreContextMut<'_, MetadataCtx>, (_, _): (String, Vec<String>)| {
274            Ok::<_, wasmtime::Error>((Err::<ExecOutput, ErrorCode>(ErrorCode::Denied),))
275        },
276    )?;
277    Ok(())
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn metadata_ctx_default_constructs() {
286        let _c = MetadataCtx::default();
287    }
288
289    #[test]
290    fn linker_registration_smoke() {
291        let engine = crate::precompile::make_engine().unwrap();
292        let mut linker = Linker::<MetadataCtx>::new(&engine);
293        register_wasi(&mut linker).expect("wasi");
294        register_all_deny_imports(&mut linker).expect("deny stubs");
295    }
296}