Skip to main content

av_sandbox/
wasm_policy.rs

1//! WebAssembly policy modules via wasmtime (the sandboxing engine).
2//!
3//! ABI (documented for policy authors):
4//! - export `memory` (linear memory) and `alloc(len: i32) -> ptr: i32`;
5//! - export `evaluate(ptr: i32, len: i32) -> code: i32`;
6//! - the host writes the UTF-8 JSON `{"tool": …, "arguments": …}` at `ptr`;
7//! - return `0` to allow, any other code to deny.
8//!
9//! Containment: every evaluation runs in a fresh `Store` with a fuel budget
10//! and a linear-memory cap. Traps, missing exports, fuel exhaustion, and
11//! memory overruns all fail **closed** (deny) — a hostile or buggy policy can
12//! neither hang the pipeline nor allow by accident.
13
14use 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
20/// Fuel budget per evaluation (~millions of instructions; a policy should use
21/// a tiny fraction of this).
22const FUEL_PER_CALL: u64 = 50_000_000;
23
24/// Linear memory cap per evaluation.
25const MAX_MEMORY_BYTES: usize = 16 * 1024 * 1024;
26
27/// Host-memory DoS caps for guest-visible
28/// resource growth. `StoreLimitsBuilder::memory_size` bounds one
29/// `Memory`, but a hostile policy could:
30///
31/// * declare many `(memory ...)` sections and reach ~10k×16 MiB of
32///   allocation before fuel meaningfully activates;
33/// * `table.grow` a table by millions of function-reference slots
34///   (each `Option<Func>` ≈ 16 bytes on 64-bit), forcing a huge host
35///   allocation on the growth step;
36/// * declare many tables per module to multiply the above.
37///
38/// Fuel/epoch help only against runtime-bounded exploits — allocation
39/// pressure lands at instantiation or on a single `memory.grow` /
40/// `table.grow` instruction. Cap every dimension explicitly.
41const MAX_MEMORIES: usize = 1;
42const MAX_TABLES: usize = 4;
43const MAX_TABLE_ELEMENTS: usize = 65_536;
44const MAX_INSTANCES: usize = 4;
45
46/// Maximum wall time is approximately this many 1 ms epoch ticks.
47///
48/// This was 25 — a WALL-CLOCK deadline on a
49/// work-bounded computation. On a busy 2-core box, 16 concurrent
50/// evaluations were descheduled past 25 ms and legitimately-tiny
51/// policies failed closed (`valid_policy_does_not_false_trip_under_
52/// parallel_load` failed 10/10 with 4 busy-loop competitors). Fuel
53/// (`FUEL_PER_CALL`) is the deterministic work bound and remains
54/// authoritative; the epoch deadline exists only to interrupt fuel
55/// escapes (host-call stalls, wasmtime bugs), so it can be generous —
56/// 2 s of wall time never fires on a healthy evaluation regardless of
57/// scheduler pressure, while still bounding a stuck guest to seconds
58/// instead of forever.
59const EPOCH_DEADLINE_TICKS: u64 = 2_000;
60
61/// A compiled WASM policy.
62pub struct WasmPolicy {
63    name: String,
64    engine: Engine,
65    module: Module,
66    epoch_stop: Arc<AtomicBool>,
67}
68
69impl WasmPolicy {
70    /// Compile a policy from `.wasm` bytes or WAT text.
71    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        // Validate the ABI statically at LOAD time. `run()` re-checks at
78        // instantiation, but that check only fires on the first tool
79        // call — a policy missing `evaluate` (typo'd export name, wrong
80        // build target) booted fine and then silently denied every tool
81        // call as "failed closed: missing evaluate". Correct posture,
82        // terrible operability: the operator sees a deny-storm instead
83        // of a boot refusal naming the broken file. Fail fast like the
84        // empty-wasm_policy_paths boot guard in main.rs.
85        let mut has_memory = false;
86        let mut alloc_ok = false;
87        let mut evaluate_ok = false;
88        // Check TYPES, not just arity: the runtime calls
89        // `get_typed_func::<(i32, i32), i32>` (and `<i32, i32>` for
90        // alloc), so a module exporting the right arity with i64/f32
91        // params passed this load-time gate and then failed EVERY
92        // evaluation — the startup-success/runtime-denial split this
93        // validation exists to prevent.
94        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            // Fail closed: any trap/fuel/memory/ABI failure is a deny.
209            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    /// Bump allocator + "deny when payload longer than 256 bytes".
224    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    /// Scans the payload for the byte sequence "drop_" and denies on match.
240    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    /// Infinite loop: must be stopped by fuel, not hang the test suite.
273    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        // Also catches it inside arguments.
297        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        // The bound proven here is "terminates by fuel
307        // exhaustion, promptly" — not a wall-clock SLA. The previous
308        // `< 100 ms` assertion was the same wall-clock fragility as
309        // the parallel-load flake in the other direction: burning 50M
310        // fuel under coverage instrumentation or noisy neighbours can
311        // legitimately exceed 100 ms. Ten seconds distinguishes
312        // "bounded by fuel" from "hung until the epoch backstop"
313        // (2s epoch × 1ms ticks would also have returned by now, so a
314        // pass proves SOME interrupt mechanism bounded the guest).
315        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        // Load-time ABI validation now refuses an export-less module
347        // outright (see missing_abi_exports_rejected_at_load). The
348        // fail-closed guarantee this test used to pin still holds for
349        // modules that PASS load validation but misbehave at run time —
350        // covered by the trap/fuel tests below.
351        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        // Compiles fine but exports no `evaluate` — previously booted and
363        // then denied every tool call at runtime; now refused at load with
364        // the missing export named.
365        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        // Round-18 mutation finding: the arity halves of the load-time
380        // ABI checks (`params == 1 && results == 1`, and evaluate's
381        // `2 && 1`) were unpinned — `&&` → `||` mutants survived, so a
382        // refactor could silently start accepting a policy whose
383        // `alloc`/`evaluate` exist under the right NAMES but the wrong
384        // SHAPES. Such a module loads, then every instantiation-time
385        // call fails closed — the deny-storm operability failure the
386        // load gate exists to prevent. Pin both wrong-arity refusals.
387        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        // Right ARITY, wrong TYPES: `get_typed_func::<(i32, i32), i32>`
406        // refuses i64 params at instantiation, so an arity-only load
407        // gate accepted a module that then failed every evaluation —
408        // the same startup-success/runtime-denial split, one lint
409        // deeper. Pin the type halves too.
410        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        // No exported memory.
429        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        // Wrong `evaluate` arity.
442        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        // The full correct ABI still loads.
455        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    /// Adversarial: a policy module that tries to grow linear memory past the
463    /// StoreLimits cap must fail closed. This exercises the same enforcement
464    /// path targeted by the RUSTSEC-2026-0088 class ("data leakage between
465    /// pooling allocator instances") — we don't use the pooling allocator,
466    /// and StoreLimits caps memory before growth can escape.
467    #[test]
468    fn memory_bomb_policy_fails_closed_via_store_limits() {
469        // Attempts to grow the guest memory 4096 pages (256 MiB) — well above
470        // MAX_MEMORY_BYTES = 16 MiB. StoreLimits must refuse.
471        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        // Ensure the module loads (parse succeeds) but the evaluation fails
482        // closed once memory.grow is denied.
483        let decision = p.evaluate("anything", &json!({}));
484        match decision {
485            PolicyDecision::Allow => {
486                // The memory.grow can legally return -1 (denied) without
487                // trapping. That's fine — the module correctly declined to
488                // exceed the limit. The proof that the guest can NOT
489                // actually use memory past the cap is the companion test
490                // `memory_grow_past_cap_leaves_grown_region_inaccessible`,
491                // which stores at a beyond-cap address and must fail closed.
492            }
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    /// The companion proof the memory-bomb
505    /// test's Allow arm defers to. After a beyond-cap `memory.grow`, the
506    /// guest tries to STORE at an address inside the would-be grown
507    /// region (page 512 = 32 MiB, past the 16 MiB cap). StoreLimits must
508    /// have refused the growth, so the store is out of bounds and the
509    /// evaluation must fail closed (Deny) — the grown region is
510    /// inaccessible, not silently usable.
511    #[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    /// Adversarial: a policy module whose evaluate returns a wildly-negative
541    /// or wildly-positive code must be treated as Deny (fail closed on any
542    /// non-zero output), never Allow.
543    #[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}