use ows_core::{Policy, PolicyContext, PolicyResult, PolicyRule};
use std::io::Write as _;
use std::process::Command;
use std::time::Duration;
pub fn evaluate_policies(policies: &[Policy], context: &PolicyContext) -> PolicyResult {
for policy in policies {
let result = evaluate_one(policy, context);
if !result.allow {
return result;
}
}
PolicyResult::allowed()
}
fn evaluate_one(policy: &Policy, context: &PolicyContext) -> PolicyResult {
for rule in &policy.rules {
let result = evaluate_rule(rule, &policy.id, context);
if !result.allow {
return result;
}
}
if let Some(ref exe) = policy.executable {
return evaluate_executable(exe, policy.config.as_ref(), &policy.id, context);
}
PolicyResult::allowed()
}
fn evaluate_rule(rule: &PolicyRule, policy_id: &str, ctx: &PolicyContext) -> PolicyResult {
match rule {
PolicyRule::AllowedChains { chain_ids } => eval_allowed_chains(policy_id, chain_ids, ctx),
PolicyRule::ExpiresAt { timestamp } => eval_expires_at(policy_id, timestamp, ctx),
}
}
fn eval_allowed_chains(policy_id: &str, chain_ids: &[String], ctx: &PolicyContext) -> PolicyResult {
if chain_ids.iter().any(|c| c == &ctx.chain_id) {
PolicyResult::allowed()
} else {
PolicyResult::denied(
policy_id,
format!("chain {} not in allowlist", ctx.chain_id),
)
}
}
fn eval_expires_at(policy_id: &str, timestamp: &str, ctx: &PolicyContext) -> PolicyResult {
let now = chrono::DateTime::parse_from_rfc3339(&ctx.timestamp);
let exp = chrono::DateTime::parse_from_rfc3339(timestamp);
match (now, exp) {
(Ok(now), Ok(exp)) if now > exp => {
PolicyResult::denied(policy_id, format!("policy expired at {timestamp}"))
}
(Ok(_), Ok(_)) => PolicyResult::allowed(),
_ => PolicyResult::denied(
policy_id,
format!(
"invalid timestamp in expiry check: ctx={}, rule={}",
ctx.timestamp, timestamp
),
),
}
}
fn evaluate_executable(
exe: &str,
config: Option<&serde_json::Value>,
policy_id: &str,
ctx: &PolicyContext,
) -> PolicyResult {
let mut payload = serde_json::to_value(ctx).unwrap_or_default();
if let Some(cfg) = config {
payload
.as_object_mut()
.map(|m| m.insert("policy_config".to_string(), cfg.clone()));
}
let stdin_bytes = match serde_json::to_vec(&payload) {
Ok(b) => b,
Err(e) => {
return PolicyResult::denied(policy_id, format!("failed to serialize context: {e}"))
}
};
let mut child = match Command::new(exe)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
return PolicyResult::denied(policy_id, format!("failed to start executable: {e}"))
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(&stdin_bytes);
}
let output = match wait_with_timeout(&mut child, Duration::from_secs(5)) {
Ok(output) => output,
Err(reason) => return PolicyResult::denied(policy_id, reason),
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return PolicyResult::denied(
policy_id,
format!(
"executable exited with {}: {}",
output.status,
stderr.trim()
),
);
}
match serde_json::from_slice::<PolicyResult>(&output.stdout) {
Ok(result) => {
if !result.allow {
PolicyResult::denied(
policy_id,
result
.reason
.unwrap_or_else(|| "denied by executable".into()),
)
} else {
PolicyResult::allowed()
}
}
Err(e) => PolicyResult::denied(policy_id, format!("invalid JSON from executable: {e}")),
}
}
fn wait_with_timeout(
child: &mut std::process::Child,
timeout: Duration,
) -> Result<std::process::Output, String> {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(mut out) = child.stdout.take() {
use std::io::Read;
let _ = out.read_to_end(&mut stdout);
}
if let Some(mut err) = child.stderr.take() {
use std::io::Read;
let _ = err.read_to_end(&mut stderr);
}
let status = child.wait().map_err(|e| e.to_string())?;
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return Err(format!("executable timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(format!("failed to wait on executable: {e}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ows_core::policy::{SpendingContext, TransactionContext};
use ows_core::PolicyAction;
fn base_context() -> PolicyContext {
PolicyContext {
chain_id: "eip155:8453".to_string(),
wallet_id: "wallet-1".to_string(),
api_key_id: "key-1".to_string(),
transaction: TransactionContext {
to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".to_string()),
value: Some("100000000000000000".to_string()), raw_hex: "0x02f8...".to_string(),
data: None,
},
spending: SpendingContext {
daily_total: "50000000000000000".to_string(), date: "2026-03-22".to_string(),
},
timestamp: "2026-03-22T10:35:22Z".to_string(),
}
}
fn policy_with_rules(id: &str, rules: Vec<PolicyRule>) -> Policy {
Policy {
id: id.to_string(),
name: id.to_string(),
version: 1,
created_at: "2026-03-22T10:00:00Z".to_string(),
rules,
executable: None,
config: None,
action: PolicyAction::Deny,
}
}
#[test]
fn allowed_chains_passes_matching_chain() {
let ctx = base_context(); let policy = policy_with_rules(
"chains",
vec![PolicyRule::AllowedChains {
chain_ids: vec!["eip155:8453".to_string(), "eip155:84532".to_string()],
}],
);
let result = evaluate_policies(&[policy], &ctx);
assert!(result.allow);
}
#[test]
fn allowed_chains_denies_non_matching() {
let ctx = base_context();
let policy = policy_with_rules(
"chains",
vec![PolicyRule::AllowedChains {
chain_ids: vec!["eip155:1".to_string()], }],
);
let result = evaluate_policies(&[policy], &ctx);
assert!(!result.allow);
assert!(result.reason.unwrap().contains("not in allowlist"));
}
#[test]
fn expires_at_allows_before_expiry() {
let ctx = base_context(); let policy = policy_with_rules(
"exp",
vec![PolicyRule::ExpiresAt {
timestamp: "2026-04-01T00:00:00Z".to_string(),
}],
);
let result = evaluate_policies(&[policy], &ctx);
assert!(result.allow);
}
#[test]
fn expires_at_denies_after_expiry() {
let ctx = base_context(); let policy = policy_with_rules(
"exp",
vec![PolicyRule::ExpiresAt {
timestamp: "2026-03-01T00:00:00Z".to_string(), }],
);
let result = evaluate_policies(&[policy], &ctx);
assert!(!result.allow);
assert!(result.reason.unwrap().contains("expired"));
}
#[test]
fn multiple_rules_all_must_pass() {
let ctx = base_context();
let policy = policy_with_rules(
"multi",
vec![
PolicyRule::AllowedChains {
chain_ids: vec!["eip155:8453".to_string()],
},
PolicyRule::ExpiresAt {
timestamp: "2026-04-01T00:00:00Z".to_string(),
},
],
);
let result = evaluate_policies(&[policy], &ctx);
assert!(result.allow);
}
#[test]
fn short_circuits_on_first_denial() {
let ctx = base_context();
let policies = vec![
policy_with_rules(
"pass",
vec![PolicyRule::AllowedChains {
chain_ids: vec!["eip155:8453".to_string()],
}],
),
policy_with_rules(
"fail",
vec![PolicyRule::AllowedChains {
chain_ids: vec!["eip155:1".to_string()], }],
),
policy_with_rules(
"never-reached",
vec![PolicyRule::ExpiresAt {
timestamp: "2020-01-01T00:00:00Z".to_string(),
}],
),
];
let result = evaluate_policies(&policies, &ctx);
assert!(!result.allow);
assert_eq!(result.policy_id.unwrap(), "fail");
}
#[test]
fn empty_policies_allows() {
let ctx = base_context();
let result = evaluate_policies(&[], &ctx);
assert!(result.allow);
}
#[test]
fn policy_with_no_rules_and_no_executable_allows() {
let ctx = base_context();
let policy = policy_with_rules("empty", vec![]);
let result = evaluate_policies(&[policy], &ctx);
assert!(result.allow);
}
#[test]
fn executable_invalid_json_denies() {
let ctx = base_context();
let result = evaluate_executable("sh", None, "exe-invalid", &ctx);
assert!(!result.allow);
}
#[test]
fn executable_nonexistent_binary_denies() {
let ctx = base_context();
let result = evaluate_executable("/nonexistent/binary", None, "bad-exe", &ctx);
assert!(!result.allow);
assert!(result.reason.unwrap().contains("failed to start"));
}
#[test]
fn executable_with_script() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("allow.sh");
std::fs::write(
&script,
"#!/bin/sh\ncat > /dev/null\necho '{\"allow\": true}'\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = base_context();
let result = evaluate_executable(script.to_str().unwrap(), None, "script-allow", &ctx);
assert!(result.allow);
}
#[test]
fn executable_deny_script() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("deny.sh");
std::fs::write(
&script,
"#!/bin/sh\ncat > /dev/null\necho '{\"allow\": false, \"reason\": \"nope\"}'\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = base_context();
let result = evaluate_executable(script.to_str().unwrap(), None, "script-deny", &ctx);
assert!(!result.allow);
assert_eq!(result.reason.as_deref(), Some("nope"));
assert_eq!(result.policy_id.as_deref(), Some("script-deny"));
}
#[test]
fn executable_nonzero_exit_denies() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("fail.sh");
std::fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = base_context();
let result = evaluate_executable(script.to_str().unwrap(), None, "exit-fail", &ctx);
assert!(!result.allow);
}
#[test]
fn rules_prefilter_before_executable() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("ran");
let script = dir.path().join("marker.sh");
std::fs::write(
&script,
format!(
"#!/bin/sh\ntouch {}\necho '{{\"allow\": true}}'\n",
marker.display()
),
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = base_context();
let policy = Policy {
id: "prefilter".to_string(),
name: "prefilter".to_string(),
version: 1,
created_at: "2026-03-22T10:00:00Z".to_string(),
rules: vec![PolicyRule::AllowedChains {
chain_ids: vec!["eip155:1".to_string()], }],
executable: Some(script.to_str().unwrap().to_string()),
config: None,
action: PolicyAction::Deny,
};
let result = evaluate_policies(&[policy], &ctx);
assert!(!result.allow);
assert!(!marker.exists(), "executable should not have run");
}
}