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 PolicyRule::AllowedTypedDataContracts { contracts } => {
45 eval_allowed_typed_data_contracts(policy_id, contracts, ctx)
46 }
47 }
48}
49
50fn eval_allowed_chains(policy_id: &str, chain_ids: &[String], ctx: &PolicyContext) -> PolicyResult {
51 if chain_ids.iter().any(|c| c == &ctx.chain_id) {
52 PolicyResult::allowed()
53 } else {
54 PolicyResult::denied(
55 policy_id,
56 format!("chain {} not in allowlist", ctx.chain_id),
57 )
58 }
59}
60
61fn eval_expires_at(policy_id: &str, timestamp: &str, ctx: &PolicyContext) -> PolicyResult {
62 let now = chrono::DateTime::parse_from_rfc3339(&ctx.timestamp);
63 let exp = chrono::DateTime::parse_from_rfc3339(timestamp);
64 match (now, exp) {
65 (Ok(now), Ok(exp)) if now > exp => {
66 PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
67 }
68 (Ok(_), Ok(_)) => PolicyResult::allowed(),
69 _ => PolicyResult::denied(
70 policy_id,
71 format!(
72 "invalid timestamp in expiry check: ctx={}, rule={}",
73 ctx.timestamp, timestamp
74 ),
75 ),
76 }
77}
78
79fn eval_allowed_typed_data_contracts(
80 policy_id: &str,
81 contracts: &[String],
82 ctx: &PolicyContext,
83) -> PolicyResult {
84 let td = match &ctx.typed_data {
85 None => return PolicyResult::allowed(),
86 Some(td) => td,
87 };
88
89 let contract = match &td.verifying_contract {
90 None => {
91 return PolicyResult::denied(
92 policy_id,
93 "typed data has no verifyingContract but policy requires one",
94 );
95 }
96 Some(c) => c,
97 };
98
99 let contract_lower = contract.to_lowercase();
100 if contracts.iter().any(|c| c.to_lowercase() == contract_lower) {
101 PolicyResult::allowed()
102 } else {
103 PolicyResult::denied(
104 policy_id,
105 format!("verifyingContract {contract} not in allowed list"),
106 )
107 }
108}
109
110fn evaluate_executable(
115 exe: &str,
116 config: Option<&serde_json::Value>,
117 policy_id: &str,
118 ctx: &PolicyContext,
119) -> PolicyResult {
120 let mut payload = serde_json::to_value(ctx).unwrap_or_default();
122 if let Some(cfg) = config {
123 payload
124 .as_object_mut()
125 .map(|m| m.insert("policy_config".to_string(), cfg.clone()));
126 }
127
128 let stdin_bytes = match serde_json::to_vec(&payload) {
129 Ok(b) => b,
130 Err(e) => {
131 return PolicyResult::denied(policy_id, format!("failed to serialize context: {e}"))
132 }
133 };
134
135 let mut child = match Command::new(exe)
136 .stdin(std::process::Stdio::piped())
137 .stdout(std::process::Stdio::piped())
138 .stderr(std::process::Stdio::piped())
139 .spawn()
140 {
141 Ok(c) => c,
142 Err(e) => {
143 return PolicyResult::denied(policy_id, format!("failed to start executable: {e}"))
144 }
145 };
146
147 if let Some(mut stdin) = child.stdin.take() {
149 let _ = stdin.write_all(&stdin_bytes);
150 }
151
152 let output = match wait_with_timeout(&mut child, Duration::from_secs(5)) {
154 Ok(output) => output,
155 Err(reason) => return PolicyResult::denied(policy_id, reason),
156 };
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 return PolicyResult::denied(
161 policy_id,
162 format!(
163 "executable exited with {}: {}",
164 output.status,
165 stderr.trim()
166 ),
167 );
168 }
169
170 match serde_json::from_slice::<PolicyResult>(&output.stdout) {
172 Ok(result) => {
173 if !result.allow {
174 PolicyResult::denied(
176 policy_id,
177 result
178 .reason
179 .unwrap_or_else(|| "denied by executable".into()),
180 )
181 } else {
182 PolicyResult::allowed()
183 }
184 }
185 Err(e) => PolicyResult::denied(policy_id, format!("invalid JSON from executable: {e}")),
186 }
187}
188
189fn wait_with_timeout(
190 child: &mut std::process::Child,
191 timeout: Duration,
192) -> Result<std::process::Output, String> {
193 let start = std::time::Instant::now();
194 loop {
195 match child.try_wait() {
196 Ok(Some(_status)) => {
197 let mut stdout = Vec::new();
199 let mut stderr = Vec::new();
200 if let Some(mut out) = child.stdout.take() {
201 use std::io::Read;
202 let _ = out.read_to_end(&mut stdout);
203 }
204 if let Some(mut err) = child.stderr.take() {
205 use std::io::Read;
206 let _ = err.read_to_end(&mut stderr);
207 }
208 let status = child.wait().map_err(|e| e.to_string())?;
209 return Ok(std::process::Output {
210 status,
211 stdout,
212 stderr,
213 });
214 }
215 Ok(None) => {
216 if start.elapsed() > timeout {
217 let _ = child.kill();
218 let _ = child.wait();
219 return Err(format!("executable timed out after {}s", timeout.as_secs()));
220 }
221 std::thread::sleep(Duration::from_millis(50));
222 }
223 Err(e) => return Err(format!("failed to wait on executable: {e}")),
224 }
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use ows_core::policy::{SpendingContext, TransactionContext, TypedDataContext};
232 use ows_core::PolicyAction;
233
234 fn base_context() -> PolicyContext {
235 PolicyContext {
236 chain_id: "eip155:8453".to_string(),
237 wallet_id: "wallet-1".to_string(),
238 api_key_id: "key-1".to_string(),
239 transaction: TransactionContext {
240 to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".to_string()),
241 value: Some("100000000000000000".to_string()), raw_hex: "0x02f8...".to_string(),
243 data: None,
244 },
245 spending: SpendingContext {
246 daily_total: "50000000000000000".to_string(), date: "2026-03-22".to_string(),
248 },
249 timestamp: "2026-03-22T10:35:22Z".to_string(),
250 typed_data: None,
251 }
252 }
253
254 fn policy_with_rules(id: &str, rules: Vec<PolicyRule>) -> Policy {
255 Policy {
256 id: id.to_string(),
257 name: id.to_string(),
258 version: 1,
259 created_at: "2026-03-22T10:00:00Z".to_string(),
260 rules,
261 executable: None,
262 config: None,
263 action: PolicyAction::Deny,
264 }
265 }
266
267 #[test]
270 fn allowed_chains_passes_matching_chain() {
271 let ctx = base_context(); let policy = policy_with_rules(
273 "chains",
274 vec![PolicyRule::AllowedChains {
275 chain_ids: vec!["eip155:8453".to_string(), "eip155:84532".to_string()],
276 }],
277 );
278
279 let result = evaluate_policies(&[policy], &ctx);
280 assert!(result.allow);
281 }
282
283 #[test]
284 fn allowed_chains_denies_non_matching() {
285 let ctx = base_context();
286 let policy = policy_with_rules(
287 "chains",
288 vec![PolicyRule::AllowedChains {
289 chain_ids: vec!["eip155:1".to_string()], }],
291 );
292
293 let result = evaluate_policies(&[policy], &ctx);
294 assert!(!result.allow);
295 assert!(result.reason.unwrap().contains("not in allowlist"));
296 }
297
298 #[test]
301 fn expires_at_allows_before_expiry() {
302 let ctx = base_context(); let policy = policy_with_rules(
304 "exp",
305 vec![PolicyRule::ExpiresAt {
306 timestamp: "2026-04-01T00:00:00Z".to_string(),
307 }],
308 );
309
310 let result = evaluate_policies(&[policy], &ctx);
311 assert!(result.allow);
312 }
313
314 #[test]
315 fn expires_at_denies_after_expiry() {
316 let ctx = base_context(); let policy = policy_with_rules(
318 "exp",
319 vec![PolicyRule::ExpiresAt {
320 timestamp: "2026-03-01T00:00:00Z".to_string(), }],
322 );
323
324 let result = evaluate_policies(&[policy], &ctx);
325 assert!(!result.allow);
326 assert!(result.reason.unwrap().contains("expired"));
327 }
328
329 #[test]
332 fn multiple_rules_all_must_pass() {
333 let ctx = base_context();
334 let policy = policy_with_rules(
335 "multi",
336 vec![
337 PolicyRule::AllowedChains {
338 chain_ids: vec!["eip155:8453".to_string()],
339 },
340 PolicyRule::ExpiresAt {
341 timestamp: "2026-04-01T00:00:00Z".to_string(),
342 },
343 ],
344 );
345
346 let result = evaluate_policies(&[policy], &ctx);
347 assert!(result.allow);
348 }
349
350 #[test]
351 fn short_circuits_on_first_denial() {
352 let ctx = base_context();
353 let policies = vec![
354 policy_with_rules(
355 "pass",
356 vec![PolicyRule::AllowedChains {
357 chain_ids: vec!["eip155:8453".to_string()],
358 }],
359 ),
360 policy_with_rules(
361 "fail",
362 vec![PolicyRule::AllowedChains {
363 chain_ids: vec!["eip155:1".to_string()], }],
365 ),
366 policy_with_rules(
367 "never-reached",
368 vec![PolicyRule::ExpiresAt {
369 timestamp: "2020-01-01T00:00:00Z".to_string(),
370 }],
371 ),
372 ];
373
374 let result = evaluate_policies(&policies, &ctx);
375 assert!(!result.allow);
376 assert_eq!(result.policy_id.unwrap(), "fail");
377 }
378
379 #[test]
380 fn empty_policies_allows() {
381 let ctx = base_context();
382 let result = evaluate_policies(&[], &ctx);
383 assert!(result.allow);
384 }
385
386 #[test]
387 fn policy_with_no_rules_and_no_executable_allows() {
388 let ctx = base_context();
389 let policy = policy_with_rules("empty", vec![]);
390 let result = evaluate_policies(&[policy], &ctx);
391 assert!(result.allow);
392 }
393
394 #[test]
397 fn executable_invalid_json_denies() {
398 let ctx = base_context();
399 let result = evaluate_executable("sh", None, "exe-invalid", &ctx);
401 assert!(!result.allow);
402 }
403
404 #[test]
405 fn executable_nonexistent_binary_denies() {
406 let ctx = base_context();
407 let result = evaluate_executable("/nonexistent/binary", None, "bad-exe", &ctx);
408 assert!(!result.allow);
409 assert!(result.reason.unwrap().contains("failed to start"));
410 }
411
412 #[test]
413 fn executable_with_script() {
414 let dir = tempfile::tempdir().unwrap();
416 let script = dir.path().join("allow.sh");
417 std::fs::write(
418 &script,
419 "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n",
420 )
421 .unwrap();
422
423 #[cfg(unix)]
424 {
425 use std::os::unix::fs::PermissionsExt;
426 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
427 }
428
429 let ctx = base_context();
430 let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx);
431 assert!(result.allow);
432 }
433
434 #[test]
435 fn executable_deny_script() {
436 let dir = tempfile::tempdir().unwrap();
437 let script = dir.path().join("deny.sh");
438 std::fs::write(
439 &script,
440 "#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n",
441 )
442 .unwrap();
443
444 #[cfg(unix)]
445 {
446 use std::os::unix::fs::PermissionsExt;
447 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
448 }
449
450 let ctx = base_context();
451 let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx);
452 assert!(!result.allow);
453 assert_eq!(result.reason.as_deref(), Some("nope"));
454 assert_eq!(result.policy_id.as_deref(), Some("script-deny"));
455 }
456
457 #[test]
458 fn executable_nonzero_exit_denies() {
459 let dir = tempfile::tempdir().unwrap();
460 let script = dir.path().join("fail.sh");
461 std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
462
463 #[cfg(unix)]
464 {
465 use std::os::unix::fs::PermissionsExt;
466 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
467 }
468
469 let ctx = base_context();
470 let result = evaluate_executable(script.to_str().unwrap(), None, "exit-fail", &ctx);
471 assert!(!result.allow);
472 }
473
474 #[test]
475 fn rules_prefilter_before_executable() {
476 let dir = tempfile::tempdir().unwrap();
478 let marker = dir.path().join("ran");
480 let script = dir.path().join("marker.sh");
481 std::fs::write(
482 &script,
483 format!(
484 "#!/bin/sh\ntouch {}\necho '{{\"allow\": true}}'\n",
485 marker.display()
486 ),
487 )
488 .unwrap();
489
490 #[cfg(unix)]
491 {
492 use std::os::unix::fs::PermissionsExt;
493 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
494 }
495
496 let ctx = base_context();
497 let policy = Policy {
498 id: "prefilter".to_string(),
499 name: "prefilter".to_string(),
500 version: 1,
501 created_at: "2026-03-22T10:00:00Z".to_string(),
502 rules: vec![PolicyRule::AllowedChains {
503 chain_ids: vec!["eip155:1".to_string()], }],
505 executable: Some(script.to_str().unwrap().to_string()),
506 config: None,
507 action: PolicyAction::Deny,
508 };
509
510 let result = evaluate_policies(&[policy], &ctx);
511 assert!(!result.allow);
512 assert!(!marker.exists(), "executable should not have run");
513 }
514
515 fn typed_data_context(verifying_contract: Option<&str>) -> TypedDataContext {
518 TypedDataContext {
519 verifying_contract: verifying_contract.map(String::from),
520 domain_chain_id: Some(8453),
521 primary_type: "PermitSingle".into(),
522 domain_name: Some("Permit2".into()),
523 domain_version: Some("1".into()),
524 raw_json: "{}".into(),
525 }
526 }
527
528 #[test]
529 fn allowed_typed_data_contracts_matching_allows() {
530 let mut ctx = base_context();
531 ctx.typed_data = Some(typed_data_context(Some(
532 "0x000000000022D473030F116dDEE9F6B43aC78BA3",
533 )));
534 let policy = policy_with_rules(
535 "td",
536 vec![PolicyRule::AllowedTypedDataContracts {
537 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
538 }],
539 );
540 let result = evaluate_policies(&[policy], &ctx);
541 assert!(result.allow);
542 }
543
544 #[test]
545 fn allowed_typed_data_contracts_non_matching_denies() {
546 let mut ctx = base_context();
547 ctx.typed_data = Some(typed_data_context(Some("0xDEAD")));
548 let policy = policy_with_rules(
549 "td",
550 vec![PolicyRule::AllowedTypedDataContracts {
551 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
552 }],
553 );
554 let result = evaluate_policies(&[policy], &ctx);
555 assert!(!result.allow);
556 assert!(result.reason.unwrap().contains("not in allowed list"));
557 }
558
559 #[test]
560 fn allowed_typed_data_contracts_case_insensitive() {
561 let mut ctx = base_context();
562 ctx.typed_data = Some(typed_data_context(Some(
563 "0x000000000022d473030f116ddee9f6b43ac78ba3",
564 )));
565 let policy = policy_with_rules(
566 "td",
567 vec![PolicyRule::AllowedTypedDataContracts {
568 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
569 }],
570 );
571 let result = evaluate_policies(&[policy], &ctx);
572 assert!(result.allow);
573 }
574
575 #[test]
576 fn allowed_typed_data_contracts_no_typed_data_passes() {
577 let ctx = base_context();
578 let policy = policy_with_rules(
579 "td",
580 vec![PolicyRule::AllowedTypedDataContracts {
581 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
582 }],
583 );
584 let result = evaluate_policies(&[policy], &ctx);
585 assert!(result.allow);
586 }
587
588 #[test]
589 fn allowed_typed_data_contracts_no_verifying_contract_denies() {
590 let mut ctx = base_context();
591 ctx.typed_data = Some(typed_data_context(None));
592 let policy = policy_with_rules(
593 "td",
594 vec![PolicyRule::AllowedTypedDataContracts {
595 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
596 }],
597 );
598 let result = evaluate_policies(&[policy], &ctx);
599 assert!(!result.allow);
600 assert!(result.reason.unwrap().contains("no verifyingContract"));
601 }
602
603 #[test]
604 fn allowed_typed_data_contracts_empty_list_denies_everything() {
605 let mut ctx = base_context();
606 ctx.typed_data = Some(typed_data_context(Some(
607 "0x000000000022D473030F116dDEE9F6B43aC78BA3",
608 )));
609 let policy = policy_with_rules(
610 "td",
611 vec![PolicyRule::AllowedTypedDataContracts { contracts: vec![] }],
612 );
613 let result = evaluate_policies(&[policy], &ctx);
614 assert!(!result.allow);
615 assert!(result.reason.unwrap().contains("not in allowed list"));
616 }
617
618 #[test]
619 fn combined_rules_with_typed_data_contracts() {
620 let mut ctx = base_context();
621 ctx.typed_data = Some(typed_data_context(Some(
622 "0x000000000022D473030F116dDEE9F6B43aC78BA3",
623 )));
624 let policy = policy_with_rules(
625 "combined",
626 vec![
627 PolicyRule::AllowedChains {
628 chain_ids: vec!["eip155:8453".into()],
629 },
630 PolicyRule::AllowedTypedDataContracts {
631 contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
632 },
633 PolicyRule::ExpiresAt {
634 timestamp: "2027-01-01T00:00:00Z".into(),
635 },
636 ],
637 );
638 let result = evaluate_policies(&[policy], &ctx);
639 assert!(result.allow);
640 }
641}