Skip to main content

ows_core/
policy.rs

1use serde::{Deserialize, Serialize};
2
3/// Action taken when a policy rule matches.
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum PolicyAction {
7    Deny,
8}
9
10/// A declarative policy rule evaluated in-process.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum PolicyRule {
14    /// Deny if `chain_id` is not in the allowlist.
15    AllowedChains { chain_ids: Vec<String> },
16
17    /// Deny if current time is past the timestamp.
18    ExpiresAt { timestamp: String },
19}
20
21/// A stored policy definition.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Policy {
24    pub id: String,
25    pub name: String,
26    pub version: u32,
27    pub created_at: String,
28    pub rules: Vec<PolicyRule>,
29    /// Path to a custom executable policy program (optional).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub executable: Option<String>,
32    /// Opaque configuration passed to the executable (optional).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub config: Option<serde_json::Value>,
35    pub action: PolicyAction,
36}
37
38/// Context passed to policy evaluation (and to executable policies via stdin).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PolicyContext {
41    pub chain_id: String,
42    pub wallet_id: String,
43    pub api_key_id: String,
44    pub transaction: TransactionContext,
45    pub spending: SpendingContext,
46    pub timestamp: String,
47}
48
49/// Transaction fields available for policy evaluation.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct TransactionContext {
52    /// Destination address (if applicable).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub to: Option<String>,
55    /// Native value in smallest unit (wei, lamports, etc).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub value: Option<String>,
58    /// Raw transaction hex.
59    pub raw_hex: String,
60    /// Calldata / input data (EVM).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub data: Option<String>,
63}
64
65/// Carried in [`PolicyContext`] for executable policies (opaque JSON). Not used by built-in rules.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SpendingContext {
68    /// Reserved for future use / custom tooling.
69    pub daily_total: String,
70    /// Date string (YYYY-MM-DD).
71    pub date: String,
72}
73
74/// Result of policy evaluation.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PolicyResult {
77    pub allow: bool,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub reason: Option<String>,
80    /// Which policy produced the denial (if denied).
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub policy_id: Option<String>,
83}
84
85impl PolicyResult {
86    pub fn allowed() -> Self {
87        Self {
88            allow: true,
89            reason: None,
90            policy_id: None,
91        }
92    }
93
94    pub fn denied(policy_id: impl Into<String>, reason: impl Into<String>) -> Self {
95        Self {
96            allow: false,
97            reason: Some(reason.into()),
98            policy_id: Some(policy_id.into()),
99        }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_policy_rule_serde_allowed_chains() {
109        let rule = PolicyRule::AllowedChains {
110            chain_ids: vec!["eip155:8453".into(), "eip155:84532".into()],
111        };
112        let json = serde_json::to_value(&rule).unwrap();
113        assert_eq!(json["type"], "allowed_chains");
114        assert_eq!(json["chain_ids"][0], "eip155:8453");
115
116        let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
117        assert_eq!(deserialized, rule);
118    }
119
120    #[test]
121    fn test_policy_rule_serde_expires_at() {
122        let rule = PolicyRule::ExpiresAt {
123            timestamp: "2026-04-01T00:00:00Z".to_string(),
124        };
125        let json = serde_json::to_value(&rule).unwrap();
126        assert_eq!(json["type"], "expires_at");
127        assert_eq!(json["timestamp"], "2026-04-01T00:00:00Z");
128
129        let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
130        assert_eq!(deserialized, rule);
131    }
132
133    #[test]
134    fn test_policy_serde_roundtrip() {
135        let policy = Policy {
136            id: "base-agent-limits".into(),
137            name: "Base Agent Safety Limits".into(),
138            version: 1,
139            created_at: "2026-03-22T10:00:00Z".into(),
140            rules: vec![
141                PolicyRule::AllowedChains {
142                    chain_ids: vec!["eip155:8453".into()],
143                },
144                PolicyRule::ExpiresAt {
145                    timestamp: "2026-12-31T23:59:59Z".into(),
146                },
147            ],
148            executable: None,
149            config: None,
150            action: PolicyAction::Deny,
151        };
152
153        let json = serde_json::to_string(&policy).unwrap();
154        let deserialized: Policy = serde_json::from_str(&json).unwrap();
155        assert_eq!(deserialized.id, "base-agent-limits");
156        assert_eq!(deserialized.rules.len(), 2);
157        assert!(deserialized.executable.is_none());
158    }
159
160    #[test]
161    fn test_policy_with_executable() {
162        let json = r#"{
163            "id": "sim-policy",
164            "name": "Simulation Policy",
165            "version": 1,
166            "created_at": "2026-03-22T10:00:00Z",
167            "rules": [],
168            "executable": "/usr/local/bin/simulate-tx",
169            "config": {"rpc": "https://mainnet.base.org"},
170            "action": "deny"
171        }"#;
172        let policy: Policy = serde_json::from_str(json).unwrap();
173        assert_eq!(policy.executable.unwrap(), "/usr/local/bin/simulate-tx");
174        assert!(policy.config.is_some());
175    }
176
177    #[test]
178    fn test_policy_context_serde() {
179        let ctx = PolicyContext {
180            chain_id: "eip155:8453".into(),
181            wallet_id: "3198bc9c-6672-5ab3-d995-4942343ae5b6".into(),
182            api_key_id: "7a2f1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c".into(),
183            transaction: TransactionContext {
184                to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".into()),
185                value: Some("100000000000000000".into()),
186                raw_hex: "0x02f8...".into(),
187                data: None,
188            },
189            spending: SpendingContext {
190                daily_total: "50000000000000000".into(),
191                date: "2026-03-22".into(),
192            },
193            timestamp: "2026-03-22T10:35:22Z".into(),
194        };
195
196        let json = serde_json::to_string(&ctx).unwrap();
197        let deserialized: PolicyContext = serde_json::from_str(&json).unwrap();
198        assert_eq!(deserialized.chain_id, "eip155:8453");
199        assert_eq!(
200            deserialized.transaction.to.unwrap(),
201            "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C"
202        );
203        // data was None, should be absent from serialized form
204        assert!(!json.contains("\"data\""));
205    }
206
207    #[test]
208    fn test_policy_result_allowed() {
209        let result = PolicyResult::allowed();
210        assert!(result.allow);
211        assert!(result.reason.is_none());
212        assert!(result.policy_id.is_none());
213
214        let json = serde_json::to_value(&result).unwrap();
215        assert_eq!(json["allow"], true);
216        assert!(!json.as_object().unwrap().contains_key("reason"));
217    }
218
219    #[test]
220    fn test_policy_result_denied() {
221        let result = PolicyResult::denied(
222            "spending-limit",
223            "Daily spending limit exceeded: 0.95 / 1.0 ETH",
224        );
225        assert!(!result.allow);
226        assert_eq!(
227            result.reason.as_deref(),
228            Some("Daily spending limit exceeded: 0.95 / 1.0 ETH")
229        );
230        assert_eq!(result.policy_id.as_deref(), Some("spending-limit"));
231    }
232
233    #[test]
234    fn test_policy_action_serde() {
235        let action = PolicyAction::Deny;
236        let json = serde_json::to_value(&action).unwrap();
237        assert_eq!(json, "deny");
238
239        let deserialized: PolicyAction = serde_json::from_value(json).unwrap();
240        assert_eq!(deserialized, PolicyAction::Deny);
241    }
242}