1use ows_core::{Policy, PolicyContext, PolicyResult, PolicyRule};
2use std::io::Write as _;
3use std::process::Command;
4use std::time::Duration;
5
6pub fn evaluate_policies(policies: &[Policy], context: &PolicyContext) -> PolicyResult {
9 for policy in policies {
10 let result = evaluate_one(policy, context);
11 if !result.allow {
12 return result;
13 }
14 }
15 PolicyResult::allowed()
16}
17
18fn evaluate_one(policy: &Policy, context: &PolicyContext) -> PolicyResult {
20 for rule in &policy.rules {
22 let result = evaluate_rule(rule, &policy.id, context);
23 if !result.allow {
24 return result;
25 }
26 }
27
28 if let Some(ref exe) = policy.executable {
30 return evaluate_executable(exe, policy.config.as_ref(), &policy.id, context);
31 }
32
33 PolicyResult::allowed()
34}
35
36fn evaluate_rule(rule: &PolicyRule, policy_id: &str, ctx: &PolicyContext) -> PolicyResult {
41 match rule {
42 PolicyRule::AllowedChains { chain_ids } => eval_allowed_chains(policy_id, chain_ids, ctx),
43 PolicyRule::ExpiresAt { timestamp } => eval_expires_at(policy_id, timestamp, ctx),
44 }
45}
46
47fn eval_allowed_chains(policy_id: &str, chain_ids: &[String], ctx: &PolicyContext) -> PolicyResult {
48 if chain_ids.iter().any(|c| c == &ctx.chain_id) {
49 PolicyResult::allowed()
50 } else {
51 PolicyResult::denied(
52 policy_id,
53 format!("chain {} not in allowlist", ctx.chain_id),
54 )
55 }
56}
57
58fn eval_expires_at(policy_id: &str, timestamp: &str, ctx: &PolicyContext) -> PolicyResult {
59 if ctx.timestamp.as_str() > timestamp {
61 PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
62 } else {
63 PolicyResult::allowed()
64 }
65}
66
67fn evaluate_executable(
72 exe: &str,
73 config: Option<&serde_json::Value>,
74 policy_id: &str,
75 ctx: &PolicyContext,
76) -> PolicyResult {
77 let mut payload = serde_json::to_value(ctx).unwrap_or_default();
79 if let Some(cfg) = config {
80 payload
81 .as_object_mut()
82 .map(|m| m.insert("policy_config".to_string(), cfg.clone()));
83 }
84
85 let stdin_bytes = match serde_json::to_vec(&payload) {
86 Ok(b) => b,
87 Err(e) => {
88 return PolicyResult::denied(policy_id, format!("failed to serialize context: {e}"))
89 }
90 };
91
92 let mut child = match Command::new(exe)
93 .stdin(std::process::Stdio::piped())
94 .stdout(std::process::Stdio::piped())
95 .stderr(std::process::Stdio::piped())
96 .spawn()
97 {
98 Ok(c) => c,
99 Err(e) => {
100 return PolicyResult::denied(policy_id, format!("failed to start executable: {e}"))
101 }
102 };
103
104 if let Some(mut stdin) = child.stdin.take() {
106 let _ = stdin.write_all(&stdin_bytes);
107 }
108
109 let output = match wait_with_timeout(&mut child, Duration::from_secs(5)) {
111 Ok(output) => output,
112 Err(reason) => return PolicyResult::denied(policy_id, reason),
113 };
114
115 if !output.status.success() {
116 let stderr = String::from_utf8_lossy(&output.stderr);
117 return PolicyResult::denied(
118 policy_id,
119 format!(
120 "executable exited with {}: {}",
121 output.status,
122 stderr.trim()
123 ),
124 );
125 }
126
127 match serde_json::from_slice::<PolicyResult>(&output.stdout) {
129 Ok(result) => {
130 if !result.allow {
131 PolicyResult::denied(
133 policy_id,
134 result
135 .reason
136 .unwrap_or_else(|| "denied by executable".into()),
137 )
138 } else {
139 PolicyResult::allowed()
140 }
141 }
142 Err(e) => PolicyResult::denied(policy_id, format!("invalid JSON from executable: {e}")),
143 }
144}
145
146fn wait_with_timeout(
147 child: &mut std::process::Child,
148 timeout: Duration,
149) -> Result<std::process::Output, String> {
150 let start = std::time::Instant::now();
151 loop {
152 match child.try_wait() {
153 Ok(Some(_status)) => {
154 let mut stdout = Vec::new();
156 let mut stderr = Vec::new();
157 if let Some(mut out) = child.stdout.take() {
158 use std::io::Read;
159 let _ = out.read_to_end(&mut stdout);
160 }
161 if let Some(mut err) = child.stderr.take() {
162 use std::io::Read;
163 let _ = err.read_to_end(&mut stderr);
164 }
165 let status = child.wait().map_err(|e| e.to_string())?;
166 return Ok(std::process::Output {
167 status,
168 stdout,
169 stderr,
170 });
171 }
172 Ok(None) => {
173 if start.elapsed() > timeout {
174 let _ = child.kill();
175 let _ = child.wait();
176 return Err(format!("executable timed out after {}s", timeout.as_secs()));
177 }
178 std::thread::sleep(Duration::from_millis(50));
179 }
180 Err(e) => return Err(format!("failed to wait on executable: {e}")),
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use ows_core::policy::{SpendingContext, TransactionContext};
189 use ows_core::PolicyAction;
190
191 fn base_context() -> PolicyContext {
192 PolicyContext {
193 chain_id: "eip155:8453".to_string(),
194 wallet_id: "wallet-1".to_string(),
195 api_key_id: "key-1".to_string(),
196 transaction: TransactionContext {
197 to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".to_string()),
198 value: Some("100000000000000000".to_string()), raw_hex: "0x02f8...".to_string(),
200 data: None,
201 },
202 spending: SpendingContext {
203 daily_total: "50000000000000000".to_string(), date: "2026-03-22".to_string(),
205 },
206 timestamp: "2026-03-22T10:35:22Z".to_string(),
207 }
208 }
209
210 fn policy_with_rules(id: &str, rules: Vec<PolicyRule>) -> Policy {
211 Policy {
212 id: id.to_string(),
213 name: id.to_string(),
214 version: 1,
215 created_at: "2026-03-22T10:00:00Z".to_string(),
216 rules,
217 executable: None,
218 config: None,
219 action: PolicyAction::Deny,
220 }
221 }
222
223 #[test]
226 fn allowed_chains_passes_matching_chain() {
227 let ctx = base_context(); let policy = policy_with_rules(
229 "chains",
230 vec![PolicyRule::AllowedChains {
231 chain_ids: vec!["eip155:8453".to_string(), "eip155:84532".to_string()],
232 }],
233 );
234
235 let result = evaluate_policies(&[policy], &ctx);
236 assert!(result.allow);
237 }
238
239 #[test]
240 fn allowed_chains_denies_non_matching() {
241 let ctx = base_context();
242 let policy = policy_with_rules(
243 "chains",
244 vec![PolicyRule::AllowedChains {
245 chain_ids: vec!["eip155:1".to_string()], }],
247 );
248
249 let result = evaluate_policies(&[policy], &ctx);
250 assert!(!result.allow);
251 assert!(result.reason.unwrap().contains("not in allowlist"));
252 }
253
254 #[test]
257 fn expires_at_allows_before_expiry() {
258 let ctx = base_context(); let policy = policy_with_rules(
260 "exp",
261 vec![PolicyRule::ExpiresAt {
262 timestamp: "2026-04-01T00:00:00Z".to_string(),
263 }],
264 );
265
266 let result = evaluate_policies(&[policy], &ctx);
267 assert!(result.allow);
268 }
269
270 #[test]
271 fn expires_at_denies_after_expiry() {
272 let ctx = base_context(); let policy = policy_with_rules(
274 "exp",
275 vec![PolicyRule::ExpiresAt {
276 timestamp: "2026-03-01T00:00:00Z".to_string(), }],
278 );
279
280 let result = evaluate_policies(&[policy], &ctx);
281 assert!(!result.allow);
282 assert!(result.reason.unwrap().contains("expired"));
283 }
284
285 #[test]
288 fn multiple_rules_all_must_pass() {
289 let ctx = base_context();
290 let policy = policy_with_rules(
291 "multi",
292 vec![
293 PolicyRule::AllowedChains {
294 chain_ids: vec!["eip155:8453".to_string()],
295 },
296 PolicyRule::ExpiresAt {
297 timestamp: "2026-04-01T00:00:00Z".to_string(),
298 },
299 ],
300 );
301
302 let result = evaluate_policies(&[policy], &ctx);
303 assert!(result.allow);
304 }
305
306 #[test]
307 fn short_circuits_on_first_denial() {
308 let ctx = base_context();
309 let policies = vec![
310 policy_with_rules(
311 "pass",
312 vec![PolicyRule::AllowedChains {
313 chain_ids: vec!["eip155:8453".to_string()],
314 }],
315 ),
316 policy_with_rules(
317 "fail",
318 vec![PolicyRule::AllowedChains {
319 chain_ids: vec!["eip155:1".to_string()], }],
321 ),
322 policy_with_rules(
323 "never-reached",
324 vec![PolicyRule::ExpiresAt {
325 timestamp: "2020-01-01T00:00:00Z".to_string(),
326 }],
327 ),
328 ];
329
330 let result = evaluate_policies(&policies, &ctx);
331 assert!(!result.allow);
332 assert_eq!(result.policy_id.unwrap(), "fail");
333 }
334
335 #[test]
336 fn empty_policies_allows() {
337 let ctx = base_context();
338 let result = evaluate_policies(&[], &ctx);
339 assert!(result.allow);
340 }
341
342 #[test]
343 fn policy_with_no_rules_and_no_executable_allows() {
344 let ctx = base_context();
345 let policy = policy_with_rules("empty", vec![]);
346 let result = evaluate_policies(&[policy], &ctx);
347 assert!(result.allow);
348 }
349
350 #[test]
353 fn executable_invalid_json_denies() {
354 let ctx = base_context();
355 let result = evaluate_executable("sh", None, "exe-invalid", &ctx);
357 assert!(!result.allow);
358 }
359
360 #[test]
361 fn executable_nonexistent_binary_denies() {
362 let ctx = base_context();
363 let result = evaluate_executable("/nonexistent/binary", None, "bad-exe", &ctx);
364 assert!(!result.allow);
365 assert!(result.reason.unwrap().contains("failed to start"));
366 }
367
368 #[test]
369 fn executable_with_script() {
370 let dir = tempfile::tempdir().unwrap();
372 let script = dir.path().join("allow.sh");
373 std::fs::write(
374 &script,
375 "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n",
376 )
377 .unwrap();
378
379 #[cfg(unix)]
380 {
381 use std::os::unix::fs::PermissionsExt;
382 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
383 }
384
385 let ctx = base_context();
386 let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx);
387 assert!(result.allow);
388 }
389
390 #[test]
391 fn executable_deny_script() {
392 let dir = tempfile::tempdir().unwrap();
393 let script = dir.path().join("deny.sh");
394 std::fs::write(
395 &script,
396 "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n",
397 )
398 .unwrap();
399
400 #[cfg(unix)]
401 {
402 use std::os::unix::fs::PermissionsExt;
403 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
404 }
405
406 let ctx = base_context();
407 let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx);
408 assert!(!result.allow);
409 assert_eq!(result.reason.as_deref(), Some("nope"));
410 assert_eq!(result.policy_id.as_deref(), Some("script-deny"));
411 }
412
413 #[test]
414 fn executable_nonzero_exit_denies() {
415 let dir = tempfile::tempdir().unwrap();
416 let script = dir.path().join("fail.sh");
417 std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
418
419 #[cfg(unix)]
420 {
421 use std::os::unix::fs::PermissionsExt;
422 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
423 }
424
425 let ctx = base_context();
426 let result = evaluate_executable(script.to_str().unwrap(), None, "exit-fail", &ctx);
427 assert!(!result.allow);
428 }
429
430 #[test]
431 fn rules_prefilter_before_executable() {
432 let dir = tempfile::tempdir().unwrap();
434 let marker = dir.path().join("ran");
436 let script = dir.path().join("marker.sh");
437 std::fs::write(
438 &script,
439 format!(
440 "#!/bin/sh\ntouch {}\necho '{{\"allow\": true}}'\n",
441 marker.display()
442 ),
443 )
444 .unwrap();
445
446 #[cfg(unix)]
447 {
448 use std::os::unix::fs::PermissionsExt;
449 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
450 }
451
452 let ctx = base_context();
453 let policy = Policy {
454 id: "prefilter".to_string(),
455 name: "prefilter".to_string(),
456 version: 1,
457 created_at: "2026-03-22T10:00:00Z".to_string(),
458 rules: vec![PolicyRule::AllowedChains {
459 chain_ids: vec!["eip155:1".to_string()], }],
461 executable: Some(script.to_str().unwrap().to_string()),
462 config: None,
463 action: PolicyAction::Deny,
464 };
465
466 let result = evaluate_policies(&[policy], &ctx);
467 assert!(!result.allow);
468 assert!(!marker.exists(), "executable should not have run");
469 }
470}