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 EPOCH_DEADLINE_TICKS: u64 = 25;
29
30pub struct WasmPolicy {
32 name: String,
33 engine: Engine,
34 module: Module,
35 epoch_stop: Arc<AtomicBool>,
36}
37
38impl WasmPolicy {
39 pub fn from_bytes(name: impl Into<String>, bytes: &[u8]) -> Result<Self, String> {
41 let mut config = Config::new();
42 config.consume_fuel(true);
43 config.epoch_interruption(true);
44 let engine = Engine::new(&config).map_err(|e| e.to_string())?;
45 let module = Module::new(&engine, bytes).map_err(|e| e.to_string())?;
46 let epoch_stop = Arc::new(AtomicBool::new(false));
47 let ticker_stop = Arc::clone(&epoch_stop);
48 let ticker_engine = engine.clone();
49 std::thread::spawn(move || {
50 while !ticker_stop.load(Ordering::Acquire) {
51 std::thread::sleep(std::time::Duration::from_millis(1));
52 ticker_engine.increment_epoch();
53 }
54 });
55 Ok(Self {
56 name: name.into(),
57 engine,
58 module,
59 epoch_stop,
60 })
61 }
62
63 fn run(&self, payload: &[u8]) -> Result<i32, String> {
64 let limits = StoreLimitsBuilder::new().memory_size(MAX_MEMORY_BYTES).build();
65 let mut store: Store<StoreLimits> = Store::new(&self.engine, limits);
66 store.limiter(|l| l);
67 store.set_fuel(FUEL_PER_CALL).map_err(|e| e.to_string())?;
68 store.set_epoch_deadline(EPOCH_DEADLINE_TICKS);
69
70 let instance =
71 Instance::new(&mut store, &self.module, &[]).map_err(|e| format!("instantiate: {e}"))?;
72 let memory = instance
73 .get_memory(&mut store, "memory")
74 .ok_or_else(|| "policy exports no `memory`".to_owned())?;
75 let alloc = instance
76 .get_typed_func::<i32, i32>(&mut store, "alloc")
77 .map_err(|e| format!("missing alloc: {e}"))?;
78 let evaluate = instance
79 .get_typed_func::<(i32, i32), i32>(&mut store, "evaluate")
80 .map_err(|e| format!("missing evaluate: {e}"))?;
81
82 let len = i32::try_from(payload.len()).map_err(|_| "payload too large".to_owned())?;
83 let ptr = alloc
84 .call(&mut store, len)
85 .map_err(|e| format!("alloc trapped: {e}"))?;
86 let ptr_usize = usize::try_from(ptr).map_err(|_| "alloc returned negative ptr".to_owned())?;
87 memory
88 .write(&mut store, ptr_usize, payload)
89 .map_err(|e| format!("payload write out of bounds: {e}"))?;
90 evaluate
91 .call(&mut store, (ptr, len))
92 .map_err(|e| format!("evaluate trapped/exhausted: {e}"))
93 }
94}
95
96impl Drop for WasmPolicy {
97 fn drop(&mut self) {
98 self.epoch_stop.store(true, Ordering::Release);
99 }
100}
101
102impl PolicyEngine for WasmPolicy {
103 fn name(&self) -> &str {
104 &self.name
105 }
106
107 fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision {
108 let payload = serde_json::json!({ "tool": tool, "arguments": arguments });
109 let bytes = match serde_json::to_vec(&payload) {
110 Ok(b) => b,
111 Err(e) => {
112 return PolicyDecision::Deny {
113 reason: format!("policy input serialization failed: {e}"),
114 }
115 }
116 };
117 match self.run(&bytes) {
118 Ok(0) => PolicyDecision::Allow,
119 Ok(code) => PolicyDecision::Deny {
120 reason: format!("wasm policy {:?} denied (code {code})", self.name),
121 },
122 Err(e) => PolicyDecision::Deny {
124 reason: format!("wasm policy {:?} failed closed: {e}", self.name),
125 },
126 }
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
133
134 use super::*;
135 use serde_json::json;
136
137 const SIZE_CAP_POLICY: &str = r#"
139 (module
140 (memory (export "memory") 4)
141 (global $next (mut i32) (i32.const 1024))
142 (func (export "alloc") (param $len i32) (result i32)
143 (local $ptr i32)
144 (local.set $ptr (global.get $next))
145 (global.set $next (i32.add (global.get $next) (local.get $len)))
146 (local.get $ptr))
147 (func (export "evaluate") (param $ptr i32) (param $len i32) (result i32)
148 (if (result i32) (i32.gt_s (local.get $len) (i32.const 256))
149 (then (i32.const 1))
150 (else (i32.const 0)))))
151 "#;
152
153 const SUBSTRING_DENY_POLICY: &str = r#"
155 (module
156 (memory (export "memory") 4)
157 (global $next (mut i32) (i32.const 4096))
158 (func (export "alloc") (param $len i32) (result i32)
159 (local $ptr i32)
160 (local.set $ptr (global.get $next))
161 (global.set $next (i32.add (global.get $next) (local.get $len)))
162 (local.get $ptr))
163 (func (export "evaluate") (param $ptr i32) (param $len i32) (result i32)
164 (local $i i32)
165 (local $end i32)
166 (local.set $end (i32.sub (i32.add (local.get $ptr) (local.get $len)) (i32.const 5)))
167 (local.set $i (local.get $ptr))
168 (block $done
169 (loop $scan
170 (br_if $done (i32.gt_s (local.get $i) (local.get $end)))
171 (if (i32.and
172 (i32.and
173 (i32.eq (i32.load8_u (local.get $i)) (i32.const 100)) ;; d
174 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 114))) ;; r
175 (i32.and
176 (i32.and
177 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 2))) (i32.const 111)) ;; o
178 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 3))) (i32.const 112))) ;; p
179 (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 4))) (i32.const 95)))) ;; _
180 (then (return (i32.const 2))))
181 (local.set $i (i32.add (local.get $i) (i32.const 1)))
182 (br $scan)))
183 (i32.const 0)))
184 "#;
185
186 const HOSTILE_LOOP_POLICY: &str = r#"
188 (module
189 (memory (export "memory") 1)
190 (func (export "alloc") (param i32) (result i32) (i32.const 64))
191 (func (export "evaluate") (param i32 i32) (result i32)
192 (loop $forever (br $forever))
193 (i32.const 0)))
194 "#;
195
196 #[test]
197 fn size_cap_policy_allows_and_denies() {
198 let p = WasmPolicy::from_bytes("size_cap", SIZE_CAP_POLICY.as_bytes()).unwrap();
199 assert_eq!(p.evaluate("t", &json!({"small": true})), PolicyDecision::Allow);
200 let big = json!({"blob": "x".repeat(500)});
201 assert!(matches!(p.evaluate("t", &big), PolicyDecision::Deny { .. }));
202 }
203
204 #[test]
205 fn substring_policy_blocks_dangerous_tools() {
206 let p = WasmPolicy::from_bytes("no_drop", SUBSTRING_DENY_POLICY.as_bytes()).unwrap();
207 assert_eq!(p.evaluate("search", &json!({"q": "cats"})), PolicyDecision::Allow);
208 let d = p.evaluate("drop_database", &json!({}));
209 assert!(matches!(d, PolicyDecision::Deny { .. }), "{d:?}");
210 let d = p.evaluate("sql", &json!({"stmt": "drop_table users"}));
212 assert!(matches!(d, PolicyDecision::Deny { .. }), "{d:?}");
213 }
214
215 #[test]
216 fn hostile_infinite_loop_fails_closed_via_fuel_and_epoch() {
217 let p = WasmPolicy::from_bytes("hostile", HOSTILE_LOOP_POLICY.as_bytes()).unwrap();
218 let started = std::time::Instant::now();
219 let d = p.evaluate("anything", &json!({}));
220 assert!(
221 started.elapsed() < std::time::Duration::from_millis(100),
222 "fuel/epoch deadline failed to bound the loop"
223 );
224 match d {
225 PolicyDecision::Deny { reason } => assert!(reason.contains("failed closed"), "{reason}"),
226 PolicyDecision::Allow => panic!("hostile policy allowed"),
227 }
228 }
229
230 #[test]
231 fn valid_policy_does_not_false_trip_under_parallel_load() {
232 let policy =
233 Arc::new(WasmPolicy::from_bytes("parallel_size_cap", SIZE_CAP_POLICY.as_bytes()).unwrap());
234 std::thread::scope(|scope| {
235 for _ in 0..16 {
236 let policy = Arc::clone(&policy);
237 scope.spawn(move || {
238 for _ in 0..50 {
239 assert_eq!(
240 policy.evaluate("chat/completions", &json!({"small": true})),
241 PolicyDecision::Allow
242 );
243 }
244 });
245 }
246 });
247 }
248
249 #[test]
250 fn missing_exports_fail_closed() {
251 let p = WasmPolicy::from_bytes("empty", b"(module)").unwrap();
252 assert!(matches!(p.evaluate("t", &json!({})), PolicyDecision::Deny { .. }));
253 }
254
255 #[test]
256 fn invalid_wasm_rejected_at_load() {
257 assert!(WasmPolicy::from_bytes("garbage", b"\x00asm garbage").is_err());
258 assert!(WasmPolicy::from_bytes("not wat", b"(module (broken").is_err());
259 }
260
261 #[test]
267 fn memory_bomb_policy_fails_closed_via_store_limits() {
268 const MEMORY_BOMB: &str = r#"
271 (module
272 (memory (export "memory") 1)
273 (func (export "alloc") (param i32) (result i32)
274 (drop (memory.grow (i32.const 4096)))
275 (i32.const 0))
276 (func (export "evaluate") (param i32 i32) (result i32)
277 (i32.const 0)))
278 "#;
279 let p = WasmPolicy::from_bytes("memory-bomb", MEMORY_BOMB.as_bytes()).unwrap();
280 let decision = p.evaluate("anything", &json!({}));
283 match decision {
284 PolicyDecision::Allow => {
285 }
291 PolicyDecision::Deny { reason } => {
292 assert!(
293 reason.contains("out of bounds")
294 || reason.contains("trapped")
295 || reason.contains("failed closed"),
296 "expected memory-cap failure, got {reason}"
297 );
298 }
299 }
300 }
301
302 #[test]
306 fn hostile_return_codes_all_deny() {
307 for code in [i32::MIN, -1, 1, 42, i32::MAX] {
308 let wat = format!(
309 r#"(module
310 (memory (export "memory") 1)
311 (func (export "alloc") (param i32) (result i32) (i32.const 64))
312 (func (export "evaluate") (param i32 i32) (result i32) (i32.const {code})))"#
313 );
314 let p = WasmPolicy::from_bytes("hostile-code", wat.as_bytes()).unwrap();
315 let d = p.evaluate("t", &json!({}));
316 assert!(
317 matches!(d, PolicyDecision::Deny { .. }),
318 "code={code} should deny, got {d:?}"
319 );
320 }
321 }
322}