Skip to main content

ows_lib/
policy_engine.rs

1use ows_core::{Policy, PolicyContext, PolicyResult, PolicyRule};
2use std::io::Write as _;
3use std::process::Command;
4use std::time::Duration;
5
6/// Evaluate all policies against a context. AND semantics: short-circuits on
7/// first denial. Returns `PolicyResult::allowed()` if every policy passes.
8pub 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
18/// Evaluate a single policy: declarative rules first, then executable (if any).
19fn evaluate_one(policy: &Policy, context: &PolicyContext) -> PolicyResult {
20    // Declarative rules — fast, in-process
21    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    // Executable — only if declarative rules passed
29    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
36// ---------------------------------------------------------------------------
37// Declarative rule evaluation
38// ---------------------------------------------------------------------------
39
40fn 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    // Compare ISO-8601 timestamps lexicographically — works for UTC timestamps
60    if ctx.timestamp.as_str() > timestamp {
61        PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
62    } else {
63        PolicyResult::allowed()
64    }
65}
66
67// ---------------------------------------------------------------------------
68// Executable policy evaluation
69// ---------------------------------------------------------------------------
70
71fn evaluate_executable(
72    exe: &str,
73    config: Option<&serde_json::Value>,
74    policy_id: &str,
75    ctx: &PolicyContext,
76) -> PolicyResult {
77    // Build stdin payload: context + policy_config
78    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    // Write stdin
105    if let Some(mut stdin) = child.stdin.take() {
106        let _ = stdin.write_all(&stdin_bytes);
107    }
108
109    // Wait with timeout (5 seconds)
110    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    // Parse stdout as PolicyResult
128    match serde_json::from_slice::<PolicyResult>(&output.stdout) {
129        Ok(result) => {
130            if !result.allow {
131                // Ensure the policy_id is set even if the executable omitted it
132                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                // Process has exited — collect output.
155                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()), // 0.1 ETH
199                raw_hex: "0x02f8...".to_string(),
200                data: None,
201            },
202            spending: SpendingContext {
203                daily_total: "50000000000000000".to_string(), // 0.05 ETH already spent
204                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    // --- AllowedChains ---
224
225    #[test]
226    fn allowed_chains_passes_matching_chain() {
227        let ctx = base_context(); // chain_id = eip155:8453
228        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()], // mainnet only
246            }],
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    // --- ExpiresAt ---
255
256    #[test]
257    fn expires_at_allows_before_expiry() {
258        let ctx = base_context(); // timestamp = 2026-03-22T10:35:22Z
259        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(); // timestamp = 2026-03-22T10:35:22Z
273        let policy = policy_with_rules(
274            "exp",
275            vec![PolicyRule::ExpiresAt {
276                timestamp: "2026-03-01T00:00:00Z".to_string(), // already expired
277            }],
278        );
279
280        let result = evaluate_policies(&[policy], &ctx);
281        assert!(!result.allow);
282        assert!(result.reason.unwrap().contains("expired"));
283    }
284
285    // --- Multi-rule / multi-policy AND semantics ---
286
287    #[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()], // wrong chain
320                }],
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    // --- Executable policy ---
351
352    #[test]
353    fn executable_invalid_json_denies() {
354        let ctx = base_context();
355        // sh without args just reads stdin, won't produce valid JSON → denied
356        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        // Create a temp script that outputs {"allow": true}
371        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        // If declarative rules deny, executable should not run
433        let dir = tempfile::tempdir().unwrap();
434        // Create a marker file approach: if exe runs, it creates a file
435        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()], // wrong chain → deny
460            }],
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}