Skip to main content

clawft_kernel/wasm_runner/
runner.rs

1//! WASM tool runner: compilation, execution, module cache, and hashing.
2
3use std::path::PathBuf;
4#[cfg(feature = "exochain")]
5use std::sync::Arc;
6#[cfg(feature = "wasm-sandbox")]
7use std::time::Duration;
8
9use sha2::{Digest, Sha256};
10
11use super::types::*;
12
13// ---------------------------------------------------------------------------
14// WASM Tool Runner
15// ---------------------------------------------------------------------------
16
17/// WASM tool runner.
18///
19/// When the `wasm-sandbox` feature is enabled, this uses Wasmtime
20/// for actual WASM execution. Without the feature, all tool loads
21/// are rejected with [`WasmError::RuntimeUnavailable`].
22pub struct WasmToolRunner {
23    config: WasmSandboxConfig,
24    #[cfg(feature = "wasm-sandbox")]
25    engine: wasmtime::Engine,
26    #[cfg(feature = "exochain")]
27    chain_manager: Option<Arc<crate::chain::ChainManager>>,
28    #[cfg(feature = "exochain")]
29    governance_engine: Option<Arc<crate::governance::GovernanceEngine>>,
30}
31
32impl WasmToolRunner {
33    /// Create a new WASM tool runner with the given configuration.
34    pub fn new(config: WasmSandboxConfig) -> Self {
35        #[cfg(feature = "wasm-sandbox")]
36        {
37            let mut wt_config = wasmtime::Config::new();
38            wt_config.consume_fuel(true);
39            // Memory limit is enforced per-store, not per-engine.
40            // (async_support was removed as a Config option in wasmtime 45+;
41            // async instantiation is always available when the async feature
42            // is enabled on the crate.)
43            let engine =
44                wasmtime::Engine::new(&wt_config).expect("failed to create wasmtime engine");
45            Self {
46                config,
47                engine,
48                #[cfg(feature = "exochain")]
49                chain_manager: None,
50                #[cfg(feature = "exochain")]
51                governance_engine: None,
52            }
53        }
54        #[cfg(not(feature = "wasm-sandbox"))]
55        {
56            Self {
57                config,
58                #[cfg(feature = "exochain")]
59                chain_manager: None,
60                #[cfg(feature = "exochain")]
61                governance_engine: None,
62            }
63        }
64    }
65
66    /// Attach a chain manager for exochain event logging.
67    #[cfg(feature = "exochain")]
68    pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
69        self.chain_manager = Some(cm);
70    }
71
72    /// Attach a governance engine for pre-execution gating.
73    #[cfg(feature = "exochain")]
74    pub fn set_governance_engine(&mut self, ge: Arc<crate::governance::GovernanceEngine>) {
75        self.governance_engine = Some(ge);
76    }
77
78    /// Get the sandbox configuration.
79    pub fn config(&self) -> &WasmSandboxConfig {
80        &self.config
81    }
82
83    /// Validate a WASM module's bytes without loading it.
84    ///
85    /// Checks module size, magic bytes, and (when the runtime is
86    /// available) uses wasmtime::Module::validate() for full validation.
87    pub fn validate_wasm(&self, wasm_bytes: &[u8]) -> Result<WasmValidation, WasmError> {
88        // Check module size
89        if wasm_bytes.len() > self.config.max_module_size_bytes {
90            return Err(WasmError::ModuleTooLarge {
91                size: wasm_bytes.len(),
92                limit: self.config.max_module_size_bytes,
93            });
94        }
95
96        // Check WASM magic bytes (\0asm)
97        if wasm_bytes.len() < 8 || &wasm_bytes[0..4] != b"\0asm" {
98            return Err(WasmError::InvalidModule(
99                "missing WASM magic bytes (\\0asm)".into(),
100            ));
101        }
102
103        let mut warnings = Vec::new();
104
105        // Parse version (bytes 4-7 in little-endian)
106        let version =
107            u32::from_le_bytes([wasm_bytes[4], wasm_bytes[5], wasm_bytes[6], wasm_bytes[7]]);
108        if version != 1 {
109            warnings.push(format!("unexpected WASM version: {version} (expected 1)"));
110        }
111
112        #[cfg(not(feature = "wasm-sandbox"))]
113        {
114            Ok(WasmValidation {
115                valid: true,
116                exports: Vec::new(),
117                imports: Vec::new(),
118                estimated_memory: 0,
119                warnings,
120            })
121        }
122
123        #[cfg(feature = "wasm-sandbox")]
124        {
125            // Full validation via wasmtime
126            if let Err(e) = wasmtime::Module::validate(&self.engine, wasm_bytes) {
127                return Err(WasmError::InvalidModule(e.to_string()));
128            }
129
130            // Parse module to extract exports/imports
131            match wasmtime::Module::new(&self.engine, wasm_bytes) {
132                Ok(module) => {
133                    let exports: Vec<String> =
134                        module.exports().map(|e| e.name().to_string()).collect();
135                    let imports: Vec<String> = module
136                        .imports()
137                        .map(|i| format!("{}::{}", i.module(), i.name()))
138                        .collect();
139                    Ok(WasmValidation {
140                        valid: true,
141                        exports,
142                        imports,
143                        estimated_memory: 0,
144                        warnings,
145                    })
146                }
147                Err(e) => Err(WasmError::CompilationFailed(e.to_string())),
148            }
149        }
150    }
151
152    /// Load a WASM tool from bytes.
153    ///
154    /// Validates the module, computes a SHA-256 hash, and (with `wasm-sandbox`)
155    /// compiles it with the Wasmtime engine.
156    pub fn load_tool(&self, name: &str, wasm_bytes: &[u8]) -> Result<WasmTool, WasmError> {
157        let validation = self.validate_wasm(wasm_bytes)?;
158
159        if !validation.valid {
160            return Err(WasmError::InvalidModule(validation.warnings.join("; ")));
161        }
162
163        let module_hash = compute_module_hash(wasm_bytes);
164
165        #[cfg(not(feature = "wasm-sandbox"))]
166        {
167            let _ = name;
168            let _ = module_hash;
169            Err(WasmError::RuntimeUnavailable)
170        }
171
172        #[cfg(feature = "wasm-sandbox")]
173        {
174            Ok(WasmTool {
175                name: name.to_owned(),
176                module_size: wasm_bytes.len(),
177                module_hash,
178                schema: None,
179                exports: validation.exports,
180            })
181        }
182    }
183
184    /// Execute a loaded WASM tool synchronously.
185    ///
186    /// Creates an isolated store with fuel metering and memory limits,
187    /// compiles the tool's module bytes, and calls `_start` or `execute`.
188    /// No host filesystem access is provided -- the instance receives
189    /// an empty set of imports.
190    ///
191    /// For WASI-aware execution with stdio pipes, use [`execute_bytes`].
192    pub fn execute(
193        &self,
194        _tool: &WasmTool,
195        _input: serde_json::Value,
196    ) -> Result<WasmToolResult, WasmError> {
197        // Governance gate: check before execution
198        #[cfg(feature = "exochain")]
199        if let Some(ref ge) = self.governance_engine {
200            let request = crate::governance::GovernanceRequest::new("wasm", "wasm.execute")
201                .with_effect(crate::governance::EffectVector {
202                    risk: 0.4,
203                    security: 0.5,
204                    ..Default::default()
205                });
206            let result = ge.evaluate(&request);
207            if matches!(
208                result.decision,
209                crate::governance::GovernanceDecision::Deny(_)
210            ) {
211                return Err(WasmError::GovernanceDenied(result.decision.to_string()));
212            }
213        }
214
215        // Chain logging
216        #[cfg(feature = "exochain")]
217        if let Some(ref cm) = self.chain_manager {
218            cm.append(
219                "wasm",
220                crate::chain::EVENT_KIND_WASM_EXECUTE,
221                Some(serde_json::json!({
222                    "tool": &_tool.name,
223                    "module_size": _tool.module_size,
224                })),
225            );
226        }
227
228        #[cfg(not(feature = "wasm-sandbox"))]
229        {
230            Err(WasmError::RuntimeUnavailable)
231        }
232
233        #[cfg(feature = "wasm-sandbox")]
234        {
235            Err(WasmError::RuntimeUnavailable)
236        }
237    }
238
239    /// Execute raw WASM bytes synchronously without WASI.
240    ///
241    /// This is the sync K3 execution path. It creates a fresh Wasmtime
242    /// store with fuel metering and memory limits, instantiates the
243    /// module with **no imports** (no filesystem, no network), and
244    /// calls `_start` or `run`.
245    ///
246    /// Returns [`WasmToolResult`] on success or a typed [`WasmError`]
247    /// on fuel exhaustion, memory overflow, or compilation failure.
248    #[cfg(feature = "wasm-sandbox")]
249    pub fn execute_sync(
250        &self,
251        name: &str,
252        wasm_bytes: &[u8],
253        _input: serde_json::Value,
254    ) -> Result<WasmToolResult, WasmError> {
255        let started = std::time::Instant::now();
256
257        // Build a dedicated engine for the sync path so fuel metering and
258        // module compilation stay isolated from the shared async engine.
259        let mut sync_config = wasmtime::Config::new();
260        sync_config.consume_fuel(true);
261        let sync_engine = wasmtime::Engine::new(&sync_config)
262            .map_err(|e| WasmError::CompilationFailed(format!("sync engine: {e}")))?;
263
264        // Compile module (accepts binary .wasm or text .wat)
265        let module = wasmtime::Module::new(&sync_engine, wasm_bytes)
266            .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
267
268        // Create per-call store with embedded memory limiter
269        let limiter = MemoryLimiter {
270            max_bytes: self.config.max_memory_bytes,
271        };
272        let mut store = wasmtime::Store::new(&sync_engine, limiter);
273        store
274            .set_fuel(self.config.max_fuel)
275            .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
276        store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
277
278        // Instantiate with NO imports -- fully sandboxed, no host access
279        let instance = wasmtime::Instance::new(&mut store, &module, &[])
280            .map_err(|e| classify_trap_with_limiter(e, &self.config, &store))?;
281
282        // Find entry point: _start (WASI convention) or run
283        let entry = instance
284            .get_func(&mut store, "_start")
285            .or_else(|| instance.get_func(&mut store, "run"))
286            .ok_or_else(|| WasmError::WasmTrap(format!("{name}: no _start or run export")))?;
287
288        // Call the entry function
289        match entry.call(&mut store, &[], &mut []) {
290            Ok(_) => {
291                let fuel_remaining = store.get_fuel().unwrap_or(0);
292                let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
293                Ok(WasmToolResult {
294                    stdout: String::new(),
295                    stderr: String::new(),
296                    exit_code: 0,
297                    fuel_consumed,
298                    memory_peak: 0,
299                    execution_time: started.elapsed(),
300                })
301            }
302            Err(e) => Err(classify_trap_with_limiter(e, &self.config, &store)),
303        }
304    }
305
306    /// Compile and execute WASM bytes in one shot.
307    ///
308    /// This is the primary execution path for K3. It accepts raw WASM
309    /// bytes (binary or WAT text), compiles them with the engine, creates
310    /// an isolated WASI store with fuel metering, serializes `input` as
311    /// JSON to the module's stdin, calls `_start` (WASI preview1) or
312    /// `execute`, and reads stdout/stderr.
313    ///
314    /// For cached execution with pre-compiled modules, see K4.
315    #[cfg(feature = "wasm-sandbox")]
316    pub async fn execute_bytes(
317        &self,
318        name: &str,
319        wasm_bytes: &[u8],
320        input: serde_json::Value,
321    ) -> Result<WasmToolResult, WasmError> {
322        // WEFT-551 / wasmtime 45+: pipes live under p2::pipe; builder and
323        // WASIp1 linker types moved to the crate root / p1 module (preview1
324        // was renamed to p1).
325        use wasmtime_wasi::p1::{self, WasiP1Ctx};
326        use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe};
327        use wasmtime_wasi::WasiCtxBuilder;
328
329        let started = std::time::Instant::now();
330
331        // Serialize input to JSON bytes for stdin
332        let input_bytes = serde_json::to_vec(&input)
333            .map_err(|e| WasmError::WasmTrap(format!("input serialization: {e}")))?;
334
335        // Create pipes for stdio
336        let stdout_pipe = MemoryOutputPipe::new(65_536);
337        let stderr_pipe = MemoryOutputPipe::new(65_536);
338        let stdin_pipe = MemoryInputPipe::new(input_bytes);
339
340        // Build WASI preview1 context with stdio pipes (no FS preopens).
341        let wasi_ctx = WasiCtxBuilder::new()
342            .stdin(stdin_pipe)
343            .stdout(stdout_pipe.clone())
344            .stderr(stderr_pipe.clone())
345            .build_p1();
346
347        // Create per-call store with fuel budget
348        let mut store = wasmtime::Store::new(&self.engine, wasi_ctx);
349        store
350            .set_fuel(self.config.max_fuel)
351            .map_err(|e| WasmError::WasmTrap(format!("set fuel: {e}")))?;
352
353        // Link WASI preview1 functions (wasi_snapshot_preview1.*)
354        let mut linker = wasmtime::Linker::<WasiP1Ctx>::new(&self.engine);
355        p1::add_to_linker_async(&mut linker, |ctx| ctx)
356            .map_err(|e| WasmError::CompilationFailed(format!("WASI linker: {e}")))?;
357
358        // Compile the module (accepts both binary .wasm and text .wat)
359        let module = wasmtime::Module::new(&self.engine, wasm_bytes)
360            .map_err(|e| WasmError::CompilationFailed(format!("{name}: {e}")))?;
361
362        // Instantiate
363        let instance = linker
364            .instantiate_async(&mut store, &module)
365            .await
366            .map_err(|e| {
367                let is_fuel = e
368                    .downcast_ref::<wasmtime::Trap>()
369                    .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
370                    || e.to_string().contains("fuel");
371                if is_fuel {
372                    WasmError::FuelExhausted {
373                        consumed: self.config.max_fuel,
374                        limit: self.config.max_fuel,
375                    }
376                } else {
377                    WasmError::CompilationFailed(format!("instantiate {name}: {e}"))
378                }
379            })?;
380
381        // Execute with wall-clock timeout
382        let timeout = Duration::from_secs(self.config.max_execution_time_secs);
383        let exec_result = tokio::time::timeout(timeout, async {
384            // Try _start (WASI convention), then execute
385            if let Some(start_fn) = instance.get_func(&mut store, "_start") {
386                start_fn.call_async(&mut store, &[], &mut []).await
387            } else if let Some(exec_fn) = instance.get_func(&mut store, "execute") {
388                exec_fn.call_async(&mut store, &[], &mut []).await
389            } else {
390                Err(wasmtime::Error::msg("no _start or execute export"))
391            }
392        })
393        .await;
394
395        // Read captured output
396        let stdout = String::from_utf8_lossy(&stdout_pipe.contents()).to_string();
397        let stderr = String::from_utf8_lossy(&stderr_pipe.contents()).to_string();
398
399        let fuel_remaining = store.get_fuel().unwrap_or(0);
400        let fuel_consumed = self.config.max_fuel.saturating_sub(fuel_remaining);
401
402        match exec_result {
403            Ok(Ok(_)) => Ok(WasmToolResult {
404                stdout,
405                stderr,
406                exit_code: 0,
407                fuel_consumed,
408                memory_peak: 0,
409                execution_time: started.elapsed(),
410            }),
411            Ok(Err(trap)) => {
412                let msg = trap.to_string();
413                // Check for fuel exhaustion via downcast or message
414                let is_fuel = trap
415                    .downcast_ref::<wasmtime::Trap>()
416                    .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
417                    || msg.contains("fuel");
418                if is_fuel {
419                    Err(WasmError::FuelExhausted {
420                        consumed: fuel_consumed,
421                        limit: self.config.max_fuel,
422                    })
423                } else if msg.contains("memory") {
424                    Err(WasmError::MemoryLimitExceeded {
425                        allocated: self.config.max_memory_bytes,
426                        limit: self.config.max_memory_bytes,
427                    })
428                } else {
429                    // Non-zero exit or trap -- return result with stderr
430                    Ok(WasmToolResult {
431                        stdout,
432                        stderr: if stderr.is_empty() {
433                            format!("trap: {msg}")
434                        } else {
435                            format!("{stderr}\ntrap: {msg}")
436                        },
437                        exit_code: 1,
438                        fuel_consumed,
439                        memory_peak: 0,
440                        execution_time: started.elapsed(),
441                    })
442                }
443            }
444            Err(_timeout) => Err(WasmError::ExecutionTimeout(timeout)),
445        }
446    }
447
448    /// Get a reference to the Wasmtime engine.
449    #[cfg(feature = "wasm-sandbox")]
450    pub fn engine(&self) -> &wasmtime::Engine {
451        &self.engine
452    }
453}
454
455// ---------------------------------------------------------------------------
456// Wasmtime helpers (behind feature gate)
457// ---------------------------------------------------------------------------
458
459/// Resource limiter that caps linear memory growth.
460#[cfg(feature = "wasm-sandbox")]
461pub(crate) struct MemoryLimiter {
462    pub(crate) max_bytes: usize,
463}
464
465#[cfg(feature = "wasm-sandbox")]
466impl wasmtime::ResourceLimiter for MemoryLimiter {
467    fn memory_growing(
468        &mut self,
469        _current: usize,
470        desired: usize,
471        _maximum: Option<usize>,
472    ) -> Result<bool, wasmtime::Error> {
473        if desired > self.max_bytes {
474            // Deny the growth -- Wasmtime will trap
475            Ok(false)
476        } else {
477            Ok(true)
478        }
479    }
480
481    fn table_growing(
482        &mut self,
483        _current: usize,
484        _desired: usize,
485        _maximum: Option<usize>,
486    ) -> Result<bool, wasmtime::Error> {
487        Ok(true)
488    }
489}
490
491/// Classify a Wasmtime error into a typed [`WasmError`].
492///
493/// Inspects the error for fuel exhaustion or memory-related traps
494/// and returns the corresponding `WasmError` variant.
495#[cfg(feature = "wasm-sandbox")]
496fn classify_trap_impl(
497    err: wasmtime::Error,
498    config: &WasmSandboxConfig,
499    fuel_remaining: u64,
500) -> WasmError {
501    let msg = err.to_string();
502
503    // Check for fuel exhaustion
504    let is_fuel = err
505        .downcast_ref::<wasmtime::Trap>()
506        .is_some_and(|t| *t == wasmtime::Trap::OutOfFuel)
507        || msg.contains("fuel");
508    if is_fuel {
509        return WasmError::FuelExhausted {
510            consumed: config.max_fuel.saturating_sub(fuel_remaining),
511            limit: config.max_fuel,
512        };
513    }
514
515    // Check for memory limit
516    if msg.contains("memory") {
517        return WasmError::MemoryLimitExceeded {
518            allocated: config.max_memory_bytes,
519            limit: config.max_memory_bytes,
520        };
521    }
522
523    WasmError::WasmTrap(msg)
524}
525
526/// Classify trap from a `Store<MemoryLimiter>` (used by execute_sync).
527#[cfg(feature = "wasm-sandbox")]
528fn classify_trap_with_limiter(
529    err: wasmtime::Error,
530    config: &WasmSandboxConfig,
531    store: &wasmtime::Store<MemoryLimiter>,
532) -> WasmError {
533    classify_trap_impl(err, config, store.get_fuel().unwrap_or(0))
534}
535
536// ---------------------------------------------------------------------------
537// Module hashing
538// ---------------------------------------------------------------------------
539
540/// Compute SHA-256 hash of WASM module bytes.
541pub fn compute_module_hash(bytes: &[u8]) -> [u8; 32] {
542    let mut hasher = Sha256::new();
543    hasher.update(bytes);
544    let result = hasher.finalize();
545    let mut hash = [0u8; 32];
546    hash.copy_from_slice(&result);
547    hash
548}
549
550// ---------------------------------------------------------------------------
551// Disk-persisted module cache
552// ---------------------------------------------------------------------------
553
554/// Compiled module cache with LRU eviction.
555///
556/// Stores compiled WASM modules on disk keyed by SHA-256 hash.
557/// When the cache exceeds `max_size`, the oldest entries are evicted.
558pub struct CompiledModuleCache {
559    cache_dir: PathBuf,
560    max_size: u64,
561}
562
563impl CompiledModuleCache {
564    /// Create a new module cache at the given directory.
565    pub fn new(cache_dir: PathBuf, max_size: u64) -> Self {
566        let _ = std::fs::create_dir_all(&cache_dir);
567        Self {
568            cache_dir,
569            max_size,
570        }
571    }
572
573    /// Get a cached compiled module by its hash.
574    pub fn get(&self, hash: &[u8; 32]) -> Option<Vec<u8>> {
575        let path = self.cache_path(hash);
576        std::fs::read(&path).ok()
577    }
578
579    /// Store a compiled module in the cache.
580    pub fn put(&self, hash: &[u8; 32], bytes: &[u8]) {
581        let path = self.cache_path(hash);
582        let _ = std::fs::write(&path, bytes);
583        self.evict_lru();
584    }
585
586    /// Evict oldest entries until cache is under `max_size`.
587    fn evict_lru(&self) {
588        let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
589        if let Ok(dir) = std::fs::read_dir(&self.cache_dir) {
590            for entry in dir.flatten() {
591                if let Ok(meta) = entry.metadata() {
592                    let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
593                    entries.push((entry.path(), meta.len(), modified));
594                }
595            }
596        }
597        let total: u64 = entries.iter().map(|(_, s, _)| s).sum();
598        if total <= self.max_size {
599            return;
600        }
601        // Sort by modification time (oldest first)
602        entries.sort_by_key(|(_, _, t)| *t);
603        let mut remaining = total;
604        for (path, size, _) in &entries {
605            if remaining <= self.max_size {
606                break;
607            }
608            let _ = std::fs::remove_file(path);
609            remaining -= size;
610        }
611    }
612
613    fn cache_path(&self, hash: &[u8; 32]) -> PathBuf {
614        let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
615        self.cache_dir.join(format!("{hex}.wasm"))
616    }
617}