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