Skip to main content

rain_engine_wasm/
lib.rs

1//! Wasmtime-backed executor for untrusted RainEngine skills.
2//!
3//! WASM skills receive explicit JSON inputs and only the host capabilities
4//! declared in their manifest.
5
6use async_trait::async_trait;
7use rain_engine_core::{
8    SkillCapability, SkillExecutionError, SkillExecutor, SkillFailureKind, SkillInvocation,
9    SkillManifest,
10};
11use reqwest::blocking::Client;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17use thiserror::Error;
18use tokio::task;
19use url::Url;
20use wasmtime::{
21    AsContextMut, Caller, Config, Engine, Extern, Instance, Linker, Memory, Module, Store,
22    StoreLimits, StoreLimitsBuilder,
23};
24
25#[derive(Clone)]
26pub struct WasmSkillConfig {
27    pub manifest: SkillManifest,
28    pub wasm_bytes: Arc<Vec<u8>>,
29    pub capabilities: Arc<dyn WasmCapabilityHost>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct WasmSkillRequest {
34    pub invocation: SkillInvocation,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct WasmSkillResponse {
39    pub ok: bool,
40    pub value: Value,
41    pub error: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
45pub struct HttpCapabilityRequest {
46    pub url: String,
47    #[serde(default = "default_method")]
48    pub method: String,
49    #[serde(default)]
50    pub headers: HashMap<String, String>,
51    #[serde(default)]
52    pub body: Option<String>,
53}
54
55fn default_method() -> String {
56    "GET".to_string()
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct HttpCapabilityResponse {
61    pub status: u16,
62    pub body: String,
63    pub headers: HashMap<String, String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67pub struct KvCapabilityRequest {
68    pub namespace: String,
69    pub key: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct KvCapabilityResponse {
74    pub found: bool,
75    pub value: Option<Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct StructuredLogEntry {
80    pub level: String,
81    pub message: String,
82    #[serde(default)]
83    pub fields: HashMap<String, Value>,
84}
85
86#[derive(Debug, Error)]
87pub enum WasmError {
88    #[error("module error: {0}")]
89    Module(String),
90}
91
92#[derive(Debug, Error, Clone, PartialEq)]
93#[error("{message}")]
94pub struct CapabilityError {
95    pub kind: SkillFailureKind,
96    pub message: String,
97}
98
99impl CapabilityError {
100    fn denied(message: impl Into<String>) -> Self {
101        Self {
102            kind: SkillFailureKind::CapabilityDenied,
103            message: message.into(),
104        }
105    }
106}
107
108pub trait WasmCapabilityHost: Send + Sync {
109    fn kv_get(
110        &self,
111        _request: KvCapabilityRequest,
112    ) -> Result<KvCapabilityResponse, CapabilityError> {
113        Err(CapabilityError::denied(
114            "key/value capability host is not configured",
115        ))
116    }
117
118    fn http_fetch(
119        &self,
120        _request: HttpCapabilityRequest,
121    ) -> Result<HttpCapabilityResponse, CapabilityError> {
122        Err(CapabilityError::denied(
123            "http capability host is not configured",
124        ))
125    }
126
127    fn log(&self, _entry: StructuredLogEntry) -> Result<(), CapabilityError> {
128        Err(CapabilityError::denied(
129            "structured log capability host is not configured",
130        ))
131    }
132}
133
134#[derive(Default)]
135pub struct NoopCapabilityHost;
136
137impl WasmCapabilityHost for NoopCapabilityHost {}
138
139#[derive(Default)]
140pub struct InMemoryCapabilityHost {
141    values: HashMap<(String, String), Value>,
142    logs: Mutex<Vec<StructuredLogEntry>>,
143    http_enabled: bool,
144}
145
146impl InMemoryCapabilityHost {
147    pub fn new() -> Self {
148        Self {
149            values: HashMap::new(),
150            logs: Mutex::new(Vec::new()),
151            http_enabled: false,
152        }
153    }
154
155    pub fn with_value(mut self, namespace: &str, key: &str, value: Value) -> Self {
156        self.values
157            .insert((namespace.to_string(), key.to_string()), value);
158        self
159    }
160
161    pub fn with_http_client(mut self) -> Self {
162        self.http_enabled = true;
163        self
164    }
165
166    pub fn logs(&self) -> Vec<StructuredLogEntry> {
167        self.logs.lock().expect("logs lock").clone()
168    }
169}
170
171impl WasmCapabilityHost for InMemoryCapabilityHost {
172    fn kv_get(
173        &self,
174        request: KvCapabilityRequest,
175    ) -> Result<KvCapabilityResponse, CapabilityError> {
176        Ok(KvCapabilityResponse {
177            found: self
178                .values
179                .contains_key(&(request.namespace.clone(), request.key.clone())),
180            value: self.values.get(&(request.namespace, request.key)).cloned(),
181        })
182    }
183
184    fn http_fetch(
185        &self,
186        request: HttpCapabilityRequest,
187    ) -> Result<HttpCapabilityResponse, CapabilityError> {
188        if !self.http_enabled {
189            return Err(CapabilityError::denied("http client is disabled"));
190        }
191        let client = Client::new();
192        let method = request
193            .method
194            .parse::<reqwest::Method>()
195            .map_err(|err| CapabilityError {
196                kind: SkillFailureKind::InvalidResponse,
197                message: err.to_string(),
198            })?;
199        let mut builder = client.request(method, &request.url);
200        for (name, value) in &request.headers {
201            builder = builder.header(name, value);
202        }
203        if let Some(body) = request.body {
204            builder = builder.body(body);
205        }
206        let response = builder.send().map_err(|err| CapabilityError {
207            kind: SkillFailureKind::Internal,
208            message: err.to_string(),
209        })?;
210        let status = response.status().as_u16();
211        let headers = response
212            .headers()
213            .iter()
214            .map(|(name, value)| {
215                (
216                    name.to_string(),
217                    value.to_str().unwrap_or_default().to_string(),
218                )
219            })
220            .collect::<HashMap<_, _>>();
221        let body = response.text().map_err(|err| CapabilityError {
222            kind: SkillFailureKind::Internal,
223            message: err.to_string(),
224        })?;
225        Ok(HttpCapabilityResponse {
226            status,
227            body,
228            headers,
229        })
230    }
231
232    fn log(&self, entry: StructuredLogEntry) -> Result<(), CapabilityError> {
233        self.logs.lock().expect("logs lock").push(entry);
234        Ok(())
235    }
236}
237
238struct StoreState {
239    limits: StoreLimits,
240    manifest: SkillManifest,
241    capabilities: Arc<dyn WasmCapabilityHost>,
242}
243
244pub struct WasmSkillExecutor {
245    engine: Engine,
246    module: Module,
247    manifest: SkillManifest,
248    capabilities: Arc<dyn WasmCapabilityHost>,
249}
250
251impl WasmSkillExecutor {
252    pub fn new(config: WasmSkillConfig) -> Result<Self, WasmError> {
253        let mut wasmtime_config = Config::new();
254        wasmtime_config.consume_fuel(true);
255        wasmtime_config.epoch_interruption(true);
256
257        let engine =
258            Engine::new(&wasmtime_config).map_err(|err| WasmError::Module(err.to_string()))?;
259        let module = Module::from_binary(&engine, &config.wasm_bytes)
260            .map_err(|err| WasmError::Module(err.to_string()))?;
261
262        Ok(Self {
263            engine,
264            module,
265            manifest: config.manifest,
266            capabilities: config.capabilities,
267        })
268    }
269
270    pub fn manifest(&self) -> &SkillManifest {
271        &self.manifest
272    }
273
274    fn build_store(&self) -> Result<Store<StoreState>, SkillExecutionError> {
275        let mut store = Store::new(
276            &self.engine,
277            StoreState {
278                limits: StoreLimitsBuilder::new()
279                    .memory_size(self.manifest.resource_policy.max_memory_bytes)
280                    .build(),
281                manifest: self.manifest.clone(),
282                capabilities: self.capabilities.clone(),
283            },
284        );
285        store.limiter(|state| &mut state.limits);
286        if let Some(fuel) = self.manifest.resource_policy.max_fuel {
287            store.set_fuel(fuel).map_err(|err| {
288                SkillExecutionError::new(SkillFailureKind::Internal, err.to_string())
289            })?;
290        }
291        Ok(store)
292    }
293}
294
295#[async_trait]
296impl SkillExecutor for WasmSkillExecutor {
297    async fn execute(&self, invocation: SkillInvocation) -> Result<Value, SkillExecutionError> {
298        let timeout = Duration::from_millis(self.manifest.resource_policy.timeout_ms);
299        let engine = self.engine.clone();
300        let module = self.module.clone();
301        let manifest = self.manifest.clone();
302        let capabilities = self.capabilities.clone();
303        let encoded = serde_json::to_vec(&WasmSkillRequest { invocation }).map_err(|err| {
304            SkillExecutionError::new(SkillFailureKind::InvalidResponse, err.to_string())
305        })?;
306
307        let join = task::spawn_blocking(move || {
308            let executor = WasmSkillExecutor {
309                engine,
310                module,
311                manifest,
312                capabilities,
313            };
314            executor.execute_blocking(encoded)
315        });
316
317        match tokio::time::timeout(timeout + Duration::from_millis(50), join).await {
318            Ok(join_result) => join_result.map_err(|err| {
319                SkillExecutionError::new(SkillFailureKind::Internal, err.to_string())
320            })?,
321            Err(_) => {
322                self.engine.increment_epoch();
323                Err(SkillExecutionError::new(
324                    SkillFailureKind::Timeout,
325                    "skill execution exceeded timeout",
326                ))
327            }
328        }
329    }
330
331    fn executor_kind(&self) -> &'static str {
332        "wasm"
333    }
334}
335
336impl WasmSkillExecutor {
337    fn execute_blocking(&self, encoded: Vec<u8>) -> Result<Value, SkillExecutionError> {
338        let mut store = self.build_store()?;
339        store.set_epoch_deadline(1);
340
341        let mut linker = Linker::new(&self.engine);
342        register_capabilities(&mut linker)?;
343
344        let instance = linker
345            .instantiate(&mut store, &self.module)
346            .map_err(|err| classify_trap(err.to_string()))?;
347
348        let memory = extract_memory(&mut store, &instance)?;
349        let alloc = instance
350            .get_typed_func::<i32, i32>(&mut store, "alloc")
351            .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
352        let run = instance
353            .get_typed_func::<(i32, i32), i64>(&mut store, "run")
354            .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
355        let dealloc = instance
356            .get_typed_func::<(i32, i32), ()>(&mut store, "dealloc")
357            .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
358
359        let input_ptr = alloc
360            .call(&mut store, i32::try_from(encoded.len()).unwrap_or(i32::MAX))
361            .map_err(|err| classify_trap(err.to_string()))?;
362        memory
363            .write(&mut store, input_ptr as usize, &encoded)
364            .map_err(|err| classify_memory(err.to_string()))?;
365
366        let packed = run
367            .call(
368                &mut store,
369                (input_ptr, i32::try_from(encoded.len()).unwrap_or(i32::MAX)),
370            )
371            .map_err(|err| classify_trap(err.to_string()))?;
372        let output_ptr = packed as u32;
373        let output_len = (packed >> 32) as u32;
374
375        let mut output = vec![0u8; output_len as usize];
376        memory
377            .read(&store, output_ptr as usize, &mut output)
378            .map_err(|err| classify_memory(err.to_string()))?;
379        let _ = dealloc.call(&mut store, (input_ptr, encoded.len() as i32));
380        let _ = dealloc.call(&mut store, (output_ptr as i32, output_len as i32));
381
382        if let Ok(decoded) = serde_json::from_slice::<WasmSkillResponse>(&output) {
383            if decoded.ok {
384                return Ok(decoded.value);
385            }
386            let message = decoded
387                .error
388                .unwrap_or_else(|| "wasm module returned failure".to_string());
389            let kind = if message.contains("capability") {
390                SkillFailureKind::CapabilityDenied
391            } else {
392                SkillFailureKind::Internal
393            };
394            return Err(SkillExecutionError::new(kind, message));
395        }
396
397        serde_json::from_slice::<Value>(&output).map_err(|err| {
398            SkillExecutionError::new(SkillFailureKind::InvalidResponse, err.to_string())
399        })
400    }
401}
402
403fn register_capabilities(linker: &mut Linker<StoreState>) -> Result<(), SkillExecutionError> {
404    linker
405        .func_wrap(
406            "env",
407            "host_log",
408            |mut caller: Caller<'_, StoreState>,
409             ptr: i32,
410             len: i32|
411             -> Result<i32, anyhow::Error> {
412                ensure_capability(&caller.data().manifest, CapabilityKind::Log)
413                    .map_err(anyhow::Error::msg)?;
414                let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
415                let entry: StructuredLogEntry =
416                    serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
417                caller
418                    .data()
419                    .capabilities
420                    .log(entry)
421                    .map_err(|err| anyhow::Error::msg(err.message))?;
422                Ok(0)
423            },
424        )
425        .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
426
427    linker
428        .func_wrap(
429            "env",
430            "host_kv_get",
431            |mut caller: Caller<'_, StoreState>,
432             ptr: i32,
433             len: i32|
434             -> Result<i64, anyhow::Error> {
435                let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
436                let request: KvCapabilityRequest =
437                    serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
438                let response_bytes = match ensure_capability(
439                    &caller.data().manifest,
440                    CapabilityKind::Kv(&request.namespace),
441                ) {
442                    Ok(()) => {
443                        let response = caller
444                            .data()
445                            .capabilities
446                            .kv_get(request)
447                            .map_err(|err| anyhow::Error::msg(err.message))?;
448                        serde_json::to_vec(&response).map_err(anyhow::Error::msg)?
449                    }
450                    Err(err) => {
451                        serialize_error_response(err.message).map_err(anyhow::Error::msg)?
452                    }
453                };
454                let (ptr, len) =
455                    write_guest_bytes(&mut caller, &response_bytes).map_err(anyhow::Error::msg)?;
456                Ok(pack_ptr_len(ptr, len))
457            },
458        )
459        .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
460
461    linker
462        .func_wrap(
463            "env",
464            "host_http_fetch",
465            |mut caller: Caller<'_, StoreState>,
466             ptr: i32,
467             len: i32|
468             -> Result<i64, anyhow::Error> {
469                let bytes = read_guest_bytes(&mut caller, ptr, len).map_err(anyhow::Error::msg)?;
470                let request: HttpCapabilityRequest =
471                    serde_json::from_slice(&bytes).map_err(anyhow::Error::msg)?;
472                let response_bytes = match ensure_capability(
473                    &caller.data().manifest,
474                    CapabilityKind::Http(&request.url),
475                ) {
476                    Ok(()) => {
477                        let response = caller
478                            .data()
479                            .capabilities
480                            .http_fetch(request)
481                            .map_err(|err| anyhow::Error::msg(err.message))?;
482                        serde_json::to_vec(&response).map_err(anyhow::Error::msg)?
483                    }
484                    Err(err) => {
485                        serialize_error_response(err.message).map_err(anyhow::Error::msg)?
486                    }
487                };
488                let (ptr, len) =
489                    write_guest_bytes(&mut caller, &response_bytes).map_err(anyhow::Error::msg)?;
490                Ok(pack_ptr_len(ptr, len))
491            },
492        )
493        .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
494
495    Ok(())
496}
497
498enum CapabilityKind<'a> {
499    Log,
500    Kv(&'a str),
501    Http(&'a str),
502}
503
504fn ensure_capability(
505    manifest: &SkillManifest,
506    requested: CapabilityKind<'_>,
507) -> Result<(), CapabilityError> {
508    match requested {
509        CapabilityKind::Log => manifest
510            .capability_grants
511            .iter()
512            .any(|capability| matches!(capability, SkillCapability::StructuredLog))
513            .then_some(())
514            .ok_or_else(|| CapabilityError::denied("structured log capability not granted")),
515        CapabilityKind::Kv(namespace) => manifest
516            .capability_grants
517            .iter()
518            .find_map(|capability| match capability {
519                SkillCapability::KeyValueRead { namespaces }
520                    if namespaces.iter().any(|allowed| allowed == namespace) =>
521                {
522                    Some(())
523                }
524                _ => None,
525            })
526            .ok_or_else(|| {
527                CapabilityError::denied(format!(
528                    "key/value capability not granted for namespace `{namespace}`"
529                ))
530            }),
531        CapabilityKind::Http(url) => {
532            let parsed = Url::parse(url).map_err(|err| CapabilityError {
533                kind: SkillFailureKind::InvalidResponse,
534                message: err.to_string(),
535            })?;
536            let host = parsed.host_str().unwrap_or_default();
537            manifest
538                .capability_grants
539                .iter()
540                .find_map(|capability| match capability {
541                    SkillCapability::HttpOutbound { allow_hosts }
542                        if allow_hosts.iter().any(|allowed| allowed == host) =>
543                    {
544                        Some(())
545                    }
546                    _ => None,
547                })
548                .ok_or_else(|| {
549                    CapabilityError::denied(format!(
550                        "http capability not granted for host `{host}`"
551                    ))
552                })
553        }
554    }
555}
556
557fn pack_ptr_len(ptr: i32, len: i32) -> i64 {
558    ((len as i64) << 32) | (ptr as u32 as i64)
559}
560
561fn serialize_error_response(message: String) -> Result<Vec<u8>, serde_json::Error> {
562    serde_json::to_vec(&WasmSkillResponse {
563        ok: false,
564        value: Value::Null,
565        error: Some(message),
566    })
567}
568
569fn read_guest_bytes(
570    caller: &mut Caller<'_, StoreState>,
571    ptr: i32,
572    len: i32,
573) -> Result<Vec<u8>, SkillExecutionError> {
574    let memory = match caller.get_export("memory") {
575        Some(Extern::Memory(memory)) => memory,
576        _ => {
577            return Err(SkillExecutionError::new(
578                SkillFailureKind::Internal,
579                "wasm module must export memory",
580            ));
581        }
582    };
583    let mut output = vec![0u8; len as usize];
584    memory
585        .read(caller.as_context_mut(), ptr as usize, &mut output)
586        .map_err(|err| classify_memory(err.to_string()))?;
587    Ok(output)
588}
589
590fn write_guest_bytes(
591    caller: &mut Caller<'_, StoreState>,
592    bytes: &[u8],
593) -> Result<(i32, i32), SkillExecutionError> {
594    let alloc = caller
595        .get_export("alloc")
596        .and_then(|export| export.into_func())
597        .ok_or_else(|| {
598            SkillExecutionError::new(SkillFailureKind::Internal, "wasm module must export alloc")
599        })?;
600    let alloc = alloc
601        .typed::<i32, i32>(caller.as_context_mut())
602        .map_err(|err| SkillExecutionError::new(SkillFailureKind::Internal, err.to_string()))?;
603    let memory = match caller.get_export("memory") {
604        Some(Extern::Memory(memory)) => memory,
605        _ => {
606            return Err(SkillExecutionError::new(
607                SkillFailureKind::Internal,
608                "wasm module must export memory",
609            ));
610        }
611    };
612    let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
613    let ptr = alloc
614        .call(caller.as_context_mut(), len)
615        .map_err(|err| classify_trap(err.to_string()))?;
616    memory
617        .write(caller.as_context_mut(), ptr as usize, bytes)
618        .map_err(|err| classify_memory(err.to_string()))?;
619    Ok((ptr, len))
620}
621
622fn extract_memory(
623    store: &mut Store<StoreState>,
624    instance: &Instance,
625) -> Result<Memory, SkillExecutionError> {
626    match instance.get_export(store.as_context_mut(), "memory") {
627        Some(Extern::Memory(memory)) => Ok(memory),
628        _ => Err(SkillExecutionError::new(
629            SkillFailureKind::Internal,
630            "wasm module must export memory",
631        )),
632    }
633}
634
635fn classify_memory(message: String) -> SkillExecutionError {
636    SkillExecutionError::new(SkillFailureKind::MemoryLimitExceeded, message)
637}
638
639fn classify_trap(message: String) -> SkillExecutionError {
640    let kind = if message.contains("all fuel consumed") {
641        SkillFailureKind::Timeout
642    } else if message.contains("out of bounds memory access")
643        || message.contains("memory")
644        || message.contains("limit")
645    {
646        SkillFailureKind::MemoryLimitExceeded
647    } else if message.contains("capability") {
648        SkillFailureKind::CapabilityDenied
649    } else {
650        SkillFailureKind::Trap
651    };
652    SkillExecutionError::new(kind, message)
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use axum::{Json, Router, routing::get};
659    use rain_engine_core::{
660        AgentContextSnapshot, AgentId, AgentStateSnapshot, EnginePolicy, ResourcePolicy,
661        RetryPolicy,
662    };
663    use serde_json::json;
664
665    fn manifest(timeout_ms: u64, max_memory_bytes: usize) -> SkillManifest {
666        SkillManifest {
667            name: "echo".to_string(),
668            description: "echo".to_string(),
669            input_schema: json!({"type": "object"}),
670            required_scopes: vec!["tool:run".to_string()],
671            capability_grants: vec![SkillCapability::StructuredLog],
672            resource_policy: ResourcePolicy {
673                timeout_ms,
674                max_memory_bytes,
675                max_fuel: Some(1_000_000),
676                priority_class: 0,
677                retry_policy: RetryPolicy::default(),
678                dry_run_supported: false,
679            },
680            approval_required: false,
681            circuit_breaker_threshold: 0.5,
682        }
683    }
684
685    fn invocation(manifest: SkillManifest) -> SkillInvocation {
686        SkillInvocation {
687            call_id: "call-1".to_string(),
688            manifest,
689            args: json!({"value": 1}),
690            dry_run: false,
691            context: AgentContextSnapshot {
692                session_id: "session".to_string(),
693                granted_scopes: vec!["tool:run".to_string()],
694                trigger_id: "trigger".to_string(),
695                idempotency_key: None,
696                current_step: 0,
697                max_steps: 4,
698                history: Vec::new(),
699                prior_tool_results: Vec::new(),
700                session_cost_usd: 0.0,
701                state: AgentStateSnapshot {
702                    agent_id: AgentId("session".to_string()),
703                    profile: None,
704                    goals: Vec::new(),
705                    tasks: Vec::new(),
706                    observations: Vec::new(),
707                    artifacts: Vec::new(),
708                    resources: Vec::new(),
709                    relationships: Vec::new(),
710                    pending_wake: None,
711                },
712                policy: EnginePolicy::default(),
713                active_execution_plan: None,
714            },
715        }
716    }
717
718    #[tokio::test]
719    async fn executes_successful_wasm_skill() {
720        let module = wat::parse_str(
721            r#"
722            (module
723              (memory (export "memory") 1)
724              (global $heap (mut i32) (i32.const 4096))
725              (func (export "alloc") (param $len i32) (result i32)
726                (local $ptr i32)
727                global.get $heap
728                local.set $ptr
729                global.get $heap
730                local.get $len
731                i32.add
732                global.set $heap
733                local.get $ptr)
734              (func (export "dealloc") (param i32 i32))
735              (data (i32.const 0) "{\"ok\":true,\"value\":{\"status\":\"ok\"},\"error\":null}")
736              (func (export "run") (param i32 i32) (result i64)
737                i64.const 206158430208)
738            )
739            "#,
740        )
741        .expect("wat");
742
743        let manifest = manifest(1_000, 65_536);
744        let executor = WasmSkillExecutor::new(WasmSkillConfig {
745            manifest: manifest.clone(),
746            wasm_bytes: Arc::new(module),
747            capabilities: Arc::new(NoopCapabilityHost),
748        })
749        .expect("executor");
750
751        let output = executor.execute(invocation(manifest)).await.expect("value");
752
753        assert_eq!(output, json!({"status": "ok"}));
754    }
755
756    #[tokio::test]
757    async fn allowed_http_capability_only_reaches_allowlisted_hosts() {
758        let app = Router::new().route("/ok", get(|| async { Json(json!({"ok": true})) }));
759        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
760            .await
761            .expect("bind");
762        let addr = listener.local_addr().expect("addr");
763        tokio::spawn(async move {
764            axum::serve(listener, app).await.expect("server");
765        });
766
767        let request_json = format!(
768            "{{\"url\":\"http://{}/ok\",\"method\":\"GET\",\"headers\":{{}},\"body\":null}}",
769            addr
770        );
771        let wat_request_json = request_json.replace('\\', "\\\\").replace('"', "\\\"");
772        let module = wat::parse_str(format!(
773            r#"
774            (module
775              (import "env" "host_http_fetch" (func $host_http_fetch (param i32 i32) (result i64)))
776              (memory (export "memory") 1)
777              (global $heap (mut i32) (i32.const 4096))
778              (func (export "alloc") (param $len i32) (result i32)
779                (local $ptr i32)
780                global.get $heap
781                local.set $ptr
782                global.get $heap
783                local.get $len
784                i32.add
785                global.set $heap
786                local.get $ptr)
787              (func (export "dealloc") (param i32 i32))
788              (data (i32.const 0) "{wat_request_json}")
789              (func (export "run") (param i32 i32) (result i64)
790                i32.const 0
791                i32.const {len}
792                call $host_http_fetch)
793            )
794            "#,
795            len = request_json.len()
796        ))
797        .expect("wat");
798
799        let allowed_manifest = SkillManifest {
800            capability_grants: vec![
801                SkillCapability::HttpOutbound {
802                    allow_hosts: vec!["127.0.0.1".to_string()],
803                },
804                SkillCapability::StructuredLog,
805            ],
806            ..manifest(1_000, 65_536)
807        };
808        let executor = WasmSkillExecutor::new(WasmSkillConfig {
809            manifest: allowed_manifest.clone(),
810            wasm_bytes: Arc::new(module.clone()),
811            capabilities: Arc::new(InMemoryCapabilityHost::default().with_http_client()),
812        })
813        .expect("executor");
814        let output = executor
815            .execute(invocation(allowed_manifest))
816            .await
817            .expect("http output");
818        assert_eq!(output["status"], json!(200));
819
820        let denied_manifest = SkillManifest {
821            capability_grants: vec![SkillCapability::StructuredLog],
822            ..manifest(1_000, 65_536)
823        };
824        let denied = WasmSkillExecutor::new(WasmSkillConfig {
825            manifest: denied_manifest.clone(),
826            wasm_bytes: Arc::new(module),
827            capabilities: Arc::new(InMemoryCapabilityHost::default().with_http_client()),
828        })
829        .expect("executor");
830        let error = denied
831            .execute(invocation(denied_manifest))
832            .await
833            .expect_err("capability denied");
834        assert_eq!(error.kind, SkillFailureKind::CapabilityDenied);
835    }
836
837    #[tokio::test]
838    async fn wasm_traps_are_contained() {
839        let module = wat::parse_str(
840            r#"
841            (module
842              (memory (export "memory") 1)
843              (func (export "alloc") (param i32) (result i32) (i32.const 0))
844              (func (export "dealloc") (param i32 i32))
845              (func (export "run") (param i32 i32) (result i64)
846                unreachable)
847            )
848            "#,
849        )
850        .expect("wat");
851
852        let manifest = manifest(1_000, 65_536);
853        let executor = WasmSkillExecutor::new(WasmSkillConfig {
854            manifest: manifest.clone(),
855            wasm_bytes: Arc::new(module),
856            capabilities: Arc::new(NoopCapabilityHost),
857        })
858        .expect("executor");
859
860        let err = executor
861            .execute(invocation(manifest))
862            .await
863            .expect_err("trap");
864
865        assert_eq!(err.kind, SkillFailureKind::Trap);
866    }
867
868    #[tokio::test]
869    async fn wasm_timeout_is_reported() {
870        let module = wat::parse_str(
871            r#"
872            (module
873              (memory (export "memory") 1)
874              (func (export "alloc") (param i32) (result i32) (i32.const 0))
875              (func (export "dealloc") (param i32 i32))
876              (func (export "run") (param i32 i32) (result i64)
877                (loop br 0)
878                i64.const 0)
879            )
880            "#,
881        )
882        .expect("wat");
883
884        let manifest = manifest(10, 65_536);
885        let executor = WasmSkillExecutor::new(WasmSkillConfig {
886            manifest: manifest.clone(),
887            wasm_bytes: Arc::new(module),
888            capabilities: Arc::new(NoopCapabilityHost),
889        })
890        .expect("executor");
891
892        let err = executor
893            .execute(invocation(manifest))
894            .await
895            .expect_err("timeout");
896
897        assert!(matches!(
898            err.kind,
899            SkillFailureKind::Timeout | SkillFailureKind::Trap
900        ));
901    }
902}