1use crate::policy::{PolicyDecision, PolicyEngine};
15use serde_json::Value;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::Arc;
18use wasmtime::{Config, Engine, Instance, Module, Store, StoreLimits, StoreLimitsBuilder};
19
20const FUEL_PER_CALL: u64 = 50_000_000;
23
24const MAX_MEMORY_BYTES: usize = 16 * 1024 * 1024;
26
27const MAX_MEMORIES: usize = 1;
42const MAX_TABLES: usize = 4;
43const MAX_TABLE_ELEMENTS: usize = 65_536;
44const MAX_INSTANCES: usize = 4;
45
46const EPOCH_DEADLINE_TICKS: u64 = 2_000;
60
61pub struct WasmPolicy {
63 name: String,
64 engine: Engine,
65 module: Module,
66 epoch_stop: Arc<AtomicBool>,
67}
68
69impl WasmPolicy {
70 pub fn from_bytes(name: impl Into<String>, bytes: &[u8]) -> Result<Self, String> {
72 let mut config = Config::new();
73 config.consume_fuel(true);
74 config.epoch_interruption(true);
75 let engine = Engine::new(&config).map_err(|e| e.to_string())?;
76 let module = Module::new(&engine, bytes).map_err(|e| e.to_string())?;
77 let mut has_memory = false;
86 let mut alloc_ok = false;
87 let mut evaluate_ok = false;
88 let all_i32 = |f: &wasmtime::FuncType| {
95 f.params().all(|p| matches!(p, wasmtime::ValType::I32))
96 && f.results().all(|r| matches!(r, wasmtime::ValType::I32))
97 };
98 for export in module.exports() {
99 match (export.name(), export.ty()) {
100 ("memory", wasmtime::ExternType::Memory(_)) => has_memory = true,
101 ("alloc", wasmtime::ExternType::Func(f)) => {
102 alloc_ok = f.params().len() == 1 && f.results().len() == 1 && all_i32(&f);
103 }
104 ("evaluate", wasmtime::ExternType::Func(f)) => {
105 evaluate_ok = f.params().len() == 2 && f.results().len() == 1 && all_i32(&f);
106 }
107 _ => {}
108 }
109 }
110 if !has_memory || !alloc_ok || !evaluate_ok {
111 let mut missing = Vec::new();
112 if !has_memory {
113 missing.push("`memory` (exported linear memory)");
114 }
115 if !alloc_ok {
116 missing.push("`alloc(len: i32) -> i32`");
117 }
118 if !evaluate_ok {
119 missing.push("`evaluate(ptr: i32, len: i32) -> i32`");
120 }
121 return Err(format!(
122 "policy does not export the required ABI: missing {} — see crates/av-sandbox/src/wasm_policy.rs for the policy-author contract",
123 missing.join(", ")
124 ));
125 }
126 let epoch_stop = Arc::new(AtomicBool::new(false));
127 let ticker_stop = Arc::clone(&epoch_stop);
128 let ticker_engine = engine.clone();
129 std::thread::spawn(move || {
130 while !ticker_stop.load(Ordering::Acquire) {
131 std::thread::sleep(std::time::Duration::from_millis(1));
132 ticker_engine.increment_epoch();
133 }
134 });
135 Ok(Self {
136 name: name.into(),
137 engine,
138 module,
139 epoch_stop,
140 })
141 }
142
143 fn run(&self, payload: &[u8]) -> Result<i32, String> {
144 let limits = StoreLimitsBuilder::new()
145 .memory_size(MAX_MEMORY_BYTES)
146 .memories(MAX_MEMORIES)
147 .tables(MAX_TABLES)
148 .table_elements(MAX_TABLE_ELEMENTS)
149 .instances(MAX_INSTANCES)
150 .build();
151 let mut store: Store<StoreLimits> = Store::new(&self.engine, limits);
152 store.limiter(|l| l);
153 store.set_fuel(FUEL_PER_CALL).map_err(|e| e.to_string())?;
154 store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
155
156 let instance =
157 Instance::new(&mut store, &self.module, &[]).map_err(|e| format!("instantiate: {e}"))?;
158 let memory = instance
159 .get_memory(&mut store, "memory")
160 .ok_or_else(|| "policy exports no `memory`".to_owned())?;
161 let alloc = instance
162 .get_typed_func::<i32, i32>(&mut store, "alloc")
163 .map_err(|e| format!("missing alloc: {e}"))?;
164 let evaluate = instance
165 .get_typed_func::<(i32, i32), i32>(&mut store, "evaluate")
166 .map_err(|e| format!("missing evaluate: {e}"))?;
167
168 let len = i32::try_from(payload.len()).map_err(|_| "payload too large".to_owned())?;
169 let ptr = alloc
170 .call(&mut store, len)
171 .map_err(|e| format!("alloc trapped: {e}"))?;
172 let ptr_usize = usize::try_from(ptr).map_err(|_| "alloc returned negative ptr".to_owned())?;
173 memory
174 .write(&mut store, ptr_usize, payload)
175 .map_err(|e| format!("payload write out of bounds: {e}"))?;
176 evaluate
177 .call(&mut store, (ptr, len))
178 .map_err(|e| format!("evaluate trapped/exhausted: {e}"))
179 }
180}
181
182impl Drop for WasmPolicy {
183 fn drop(&mut self) {
184 self.epoch_stop.store(true, Ordering::Release);
185 }
186}
187
188impl PolicyEngine for WasmPolicy {
189 fn name(&self) -> &str {
190 &self.name
191 }
192
193 fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision {
194 let payload = serde_json::json!({ "tool": tool, "arguments": arguments });
195 let bytes = match serde_json::to_vec(&payload) {
196 Ok(b) => b,
197 Err(e) => {
198 return PolicyDecision::Deny {
199 reason: format!("policy input serialization failed: {e}"),
200 }
201 }
202 };
203 match self.run(&bytes) {
204 Ok(0) => PolicyDecision::Allow,
205 Ok(code) => PolicyDecision::Deny {
206 reason: format!("wasm policy {:?} denied (code {code})", self.name),
207 },
208 Err(e) => PolicyDecision::Deny {
210 reason: format!("wasm policy {:?} failed closed: {e}", self.name),
211 },
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
219
220 use super::*;
221 use serde_json::json;
222
223 const SIZE_CAP_POLICY: &str = r#"
225 (module
226 (memory (export "memory") 4)
227 (global $next (mut i32) (i32.const 1024))
228 (func (export "alloc") (param $len i32) (result i32)
229 (local $ptr i32)
230 (local.set $ptr (global.get $next))
231 (global.set $next (i32.add (global.get $next) (local.get $len)))
232 (local.get $ptr))
233 (func (export "evaluate") (param $ptr i32) (param $len i32) (result i32)
234 (if (result i32) (i32.gt_s (local.get $len) (i32.const 256))
235 (then (i32.const 1))
236 (else (i32.const 0)))))
237 "#;
238
239 const SUBSTRING_DENY_POLICY: &str = r#"
241 (module
242 (memory (export "memory") 4)
243 (global $next (mut i32) (i32.const 4096))
244 (func (export "alloc") (param $len i32) (result i32)
245 (local $ptr i32)
246 (local.set $ptr (global.get $next))
247 (global.set $next (i32.add (global.get $next) (local.get $len)))
248 (local.get $ptr))
249 (func (export "evaluate") (param $ptr i32) (param $len i32) (result i32)
250 (local $i i32)
251 (local $end i32)
252 (local.set $end (i32.sub (i32.add (local.get $ptr) (local.get $len)) (i32.const 5)))
253 (local.set $i (local.get $ptr))
254 (block $done
255 (loop $scan
256 (br_if $done (i32.gt_s (local.get $i) (local.get $end)))
257 (if (i32.and
258 (i32.and
259 (i32.eq (i32.load8_u (local.get $i)) (i32.const 100)) ;; d
260 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 114))) ;; r
261 (i32.and
262 (i32.and
263 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 2))) (i32.const 111)) ;; o
264 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 3))) (i32.const 112))) ;; p
265 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 4))) (i32.const 95)))) ;; _
266 (then (return (i32.const 2))))
267 (local.set $i (i32.add (local.get $i) (i32.const 1)))
268 (br $scan)))
269 (i32.const 0)))
270 "#;
271
272 const HOSTILE_LOOP_POLICY: &str = r#"
274 (module
275 (memory (export "memory") 1)
276 (func (export "alloc") (param i32) (result i32) (i32.const 64))
277 (func (export "evaluate") (param i32 i32) (result i32)
278 (loop $forever (br $forever))
279 (i32.const 0)))
280 "#;
281
282 #[test]
283 fn size_cap_policy_allows_and_denies() {
284 let p = WasmPolicy::from_bytes("size_cap", SIZE_CAP_POLICY.as_bytes()).unwrap();
285 assert_eq!(p.evaluate("t", &json!({"small": true})), PolicyDecision::Allow);
286 let big = json!({"blob": "x".repeat(500)});
287 assert!(matches!(p.evaluate("t", &big), PolicyDecision::Deny { .. }));
288 }
289
290 #[test]
291 fn substring_policy_blocks_dangerous_tools() {
292 let p = WasmPolicy::from_bytes("no_drop", SUBSTRING_DENY_POLICY.as_bytes()).unwrap();
293 assert_eq!(p.evaluate("search", &json!({"q": "cats"})), PolicyDecision::Allow);
294 let d = p.evaluate("drop_database", &json!({}));
295 assert!(matches!(d, PolicyDecision::Deny { .. }), "{d:?}");
296 let d = p.evaluate("sql", &json!({"stmt": "drop_table users"}));
298 assert!(matches!(d, PolicyDecision::Deny { .. }), "{d:?}");
299 }
300
301 #[test]
302 fn hostile_infinite_loop_fails_closed_via_fuel_and_epoch() {
303 let p = WasmPolicy::from_bytes("hostile", HOSTILE_LOOP_POLICY.as_bytes()).unwrap();
304 let started = std::time::Instant::now();
305 let d = p.evaluate("anything", &json!({}));
306 assert!(
316 started.elapsed() < std::time::Duration::from_secs(10),
317 "fuel/epoch deadline failed to bound the loop"
318 );
319 match d {
320 PolicyDecision::Deny { reason } => assert!(reason.contains("failed closed"), "{reason}"),
321 PolicyDecision::Allow => panic!("hostile policy allowed"),
322 }
323 }
324
325 #[test]
326 fn valid_policy_does_not_false_trip_under_parallel_load() {
327 let policy =
328 Arc::new(WasmPolicy::from_bytes("parallel_size_cap", SIZE_CAP_POLICY.as_bytes()).unwrap());
329 std::thread::scope(|scope| {
330 for _ in 0..16 {
331 let policy = Arc::clone(&policy);
332 scope.spawn(move || {
333 for _ in 0..50 {
334 assert_eq!(
335 policy.evaluate("chat/completions", &json!({"small": true})),
336 PolicyDecision::Allow
337 );
338 }
339 });
340 }
341 });
342 }
343
344 #[test]
345 fn missing_exports_fail_closed() {
346 assert!(WasmPolicy::from_bytes("empty", b"(module)").is_err());
352 }
353
354 #[test]
355 fn invalid_wasm_rejected_at_load() {
356 assert!(WasmPolicy::from_bytes("garbage", b"\x00asm garbage").is_err());
357 assert!(WasmPolicy::from_bytes("not wat", b"(module (broken").is_err());
358 }
359
360 #[test]
361 fn missing_abi_exports_rejected_at_load() {
362 let no_evaluate = r#"(module
366 (memory (export "memory") 1)
367 (func (export "alloc") (param i32) (result i32) (i32.const 2048)))"#;
368 let err = WasmPolicy::from_bytes("no-evaluate", no_evaluate.as_bytes())
369 .err()
370 .expect("load should fail");
371 assert!(
372 err.contains("evaluate"),
373 "error should name the missing export: {err}"
374 );
375 }
376
377 #[test]
378 fn wrong_arity_abi_exports_rejected_at_load() {
379 let alloc_wrong_arity = r#"(module
388 (memory (export "memory") 1)
389 (func (export "alloc") (param i32 i32) (result i32) (i32.const 2048))
390 (func (export "evaluate") (param i32 i32) (result i32) (i32.const 0)))"#;
391 let err = WasmPolicy::from_bytes("alloc-arity", alloc_wrong_arity.as_bytes())
392 .err()
393 .expect("load must fail on a 2-param alloc");
394 assert!(err.contains("alloc"), "error should name alloc: {err}");
395
396 let evaluate_wrong_arity = r#"(module
397 (memory (export "memory") 1)
398 (func (export "alloc") (param i32) (result i32) (i32.const 2048))
399 (func (export "evaluate") (param i32) (result i32) (i32.const 0)))"#;
400 let err = WasmPolicy::from_bytes("evaluate-arity", evaluate_wrong_arity.as_bytes())
401 .err()
402 .expect("load must fail on a 1-param evaluate");
403 assert!(err.contains("evaluate"), "error should name evaluate: {err}");
404
405 let alloc_wrong_types = r#"(module
411 (memory (export "memory") 1)
412 (func (export "alloc") (param i64) (result i64) (i64.const 2048))
413 (func (export "evaluate") (param i32 i32) (result i32) (i32.const 0)))"#;
414 let err = WasmPolicy::from_bytes("alloc-types", alloc_wrong_types.as_bytes())
415 .err()
416 .expect("load must fail on an i64 alloc signature");
417 assert!(err.contains("alloc"), "error should name alloc: {err}");
418
419 let evaluate_wrong_types = r#"(module
420 (memory (export "memory") 1)
421 (func (export "alloc") (param i32) (result i32) (i32.const 2048))
422 (func (export "evaluate") (param i64 i32) (result i32) (i32.const 0)))"#;
423 let err = WasmPolicy::from_bytes("evaluate-types", evaluate_wrong_types.as_bytes())
424 .err()
425 .expect("load must fail on an i64 evaluate param");
426 assert!(err.contains("evaluate"), "error should name evaluate: {err}");
427
428 let no_memory = r#"(module
430 (memory 1)
431 (func (export "alloc") (param i32) (result i32) (i32.const 2048))
432 (func (export "evaluate") (param i32 i32) (result i32) (i32.const 0)))"#;
433 let err = WasmPolicy::from_bytes("no-memory", no_memory.as_bytes())
434 .err()
435 .expect("load should fail");
436 assert!(
437 err.contains("memory"),
438 "error should name the missing memory: {err}"
439 );
440
441 let bad_arity = r#"(module
443 (memory (export "memory") 1)
444 (func (export "alloc") (param i32) (result i32) (i32.const 2048))
445 (func (export "evaluate") (param i32) (result i32) (i32.const 0)))"#;
446 let err = WasmPolicy::from_bytes("bad-arity", bad_arity.as_bytes())
447 .err()
448 .expect("load should fail");
449 assert!(
450 err.contains("evaluate"),
451 "error should name the bad-arity export: {err}"
452 );
453
454 let ok = r#"(module
456 (memory (export "memory") 1)
457 (func (export "alloc") (param i32) (result i32) (i32.const 2048))
458 (func (export "evaluate") (param i32 i32) (result i32) (i32.const 0)))"#;
459 assert!(WasmPolicy::from_bytes("ok", ok.as_bytes()).is_ok());
460 }
461
462 #[test]
468 fn memory_bomb_policy_fails_closed_via_store_limits() {
469 const MEMORY_BOMB: &str = r#"
472 (module
473 (memory (export "memory") 1)
474 (func (export "alloc") (param i32) (result i32)
475 (drop (memory.grow (i32.const 4096)))
476 (i32.const 0))
477 (func (export "evaluate") (param i32 i32) (result i32)
478 (i32.const 0)))
479 "#;
480 let p = WasmPolicy::from_bytes("memory-bomb", MEMORY_BOMB.as_bytes()).unwrap();
481 let decision = p.evaluate("anything", &json!({}));
484 match decision {
485 PolicyDecision::Allow => {
486 }
493 PolicyDecision::Deny { reason } => {
494 assert!(
495 reason.contains("out of bounds")
496 || reason.contains("trapped")
497 || reason.contains("failed closed"),
498 "expected memory-cap failure, got {reason}"
499 );
500 }
501 }
502 }
503
504 #[test]
512 fn memory_grow_past_cap_leaves_grown_region_inaccessible() {
513 const GROW_THEN_TOUCH: &str = r#"
514 (module
515 (memory (export "memory") 1)
516 (func (export "alloc") (param i32) (result i32)
517 (i32.const 0))
518 (func (export "evaluate") (param i32 i32) (result i32)
519 (drop (memory.grow (i32.const 4096)))
520 ;; store at 32 MiB — inside the grown region iff growth succeeded
521 (i32.store (i32.const 33554432) (i32.const 1))
522 (i32.const 0)))
523 "#;
524 let p = WasmPolicy::from_bytes("grow-then-touch", GROW_THEN_TOUCH.as_bytes()).unwrap();
525 match p.evaluate("anything", &json!({})) {
526 PolicyDecision::Deny { reason } => {
527 assert!(
528 reason.contains("out of bounds")
529 || reason.contains("trapped")
530 || reason.contains("failed closed"),
531 "beyond-cap store must fail closed with a containment reason, got: {reason}"
532 );
533 }
534 PolicyDecision::Allow => {
535 panic!("store at 32 MiB succeeded — the memory cap did not hold")
536 }
537 }
538 }
539
540 #[test]
544 fn hostile_return_codes_all_deny() {
545 for code in [i32::MIN, -1, 1, 42, i32::MAX] {
546 let wat = format!(
547 r#"(module
548 (memory (export "memory") 1)
549 (func (export "alloc") (param i32) (result i32) (i32.const 64))
550 (func (export "evaluate") (param i32 i32) (result i32) (i32.const {code})))"#
551 );
552 let p = WasmPolicy::from_bytes("hostile-code", wat.as_bytes()).unwrap();
553 let d = p.evaluate("t", &json!({}));
554 assert!(
555 matches!(d, PolicyDecision::Deny { .. }),
556 "code={code} should deny, got {d:?}"
557 );
558 }
559 }
560}