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    /// Deny typed data signing if `domain.verifyingContract` is not in the allowlist.
21    /// Passes through for non-typed-data signing operations.
22    AllowedTypedDataContracts { contracts: Vec<String> },
23}
24
25/// A stored policy definition.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Policy {
28    pub id: String,
29    pub name: String,
30    pub version: u32,
31    pub created_at: String,
32    pub rules: Vec<PolicyRule>,
33    /// Path to a custom executable policy program (optional).
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub executable: Option<String>,
36    /// Opaque configuration passed to the executable (optional).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub config: Option<serde_json::Value>,
39    pub action: PolicyAction,
40}
41
42/// Context passed to policy evaluation (and to executable policies via stdin).
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PolicyContext {
45    pub chain_id: String,
46    pub wallet_id: String,
47    pub api_key_id: String,
48    pub transaction: TransactionContext,
49    pub spending: SpendingContext,
50    pub timestamp: String,
51    /// EIP-712 typed data context (only present for `sign_typed_data` calls).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub typed_data: Option<TypedDataContext>,
54}
55
56/// Signing-request fields available for policy evaluation.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct TransactionContext {
59    /// Destination address (if applicable).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub to: Option<String>,
62    /// Native value in smallest unit (wei, lamports, etc).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub value: Option<String>,
65    /// Raw transaction hex. Empty for non-transaction signing requests such as
66    /// typed data, which is instead exposed via [`TypedDataContext::raw_json`].
67    pub raw_hex: String,
68    /// Calldata / input data (EVM).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub data: Option<String>,
71}
72
73/// Carried in [`PolicyContext`] for executable policies (opaque JSON). Not used by built-in rules.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SpendingContext {
76    /// Reserved for future use / custom tooling.
77    pub daily_total: String,
78    /// Date string (YYYY-MM-DD).
79    pub date: String,
80}
81
82/// EIP-712 typed data context for policy evaluation.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct TypedDataContext {
85    /// The `domain.verifyingContract` address (if present in the EIP-712 domain).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub verifying_contract: Option<String>,
88    /// The `domain.chainId` (if present in the EIP-712 domain).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub domain_chain_id: Option<u64>,
91    /// The EIP-712 `primaryType` (e.g. "Permit", "Order").
92    pub primary_type: String,
93    /// The `domain.name` (if present).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub domain_name: Option<String>,
96    /// The `domain.version` (if present).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub domain_version: Option<String>,
99    /// The full typed data JSON for executable policies to inspect.
100    pub raw_json: String,
101}
102
103/// Result of policy evaluation.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct PolicyResult {
106    pub allow: bool,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub reason: Option<String>,
109    /// Which policy produced the denial (if denied).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub policy_id: Option<String>,
112}
113
114impl PolicyResult {
115    pub fn allowed() -> Self {
116        Self {
117            allow: true,
118            reason: None,
119            policy_id: None,
120        }
121    }
122
123    pub fn denied(policy_id: impl Into<String>, reason: impl Into<String>) -> Self {
124        Self {
125            allow: false,
126            reason: Some(reason.into()),
127            policy_id: Some(policy_id.into()),
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_policy_rule_serde_allowed_chains() {
138        let rule = PolicyRule::AllowedChains {
139            chain_ids: vec!["eip155:8453".into(), "eip155:84532".into()],
140        };
141        let json = serde_json::to_value(&rule).unwrap();
142        assert_eq!(json["type"], "allowed_chains");
143        assert_eq!(json["chain_ids"][0], "eip155:8453");
144
145        let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
146        assert_eq!(deserialized, rule);
147    }
148
149    #[test]
150    fn test_policy_rule_serde_expires_at() {
151        let rule = PolicyRule::ExpiresAt {
152            timestamp: "2026-04-01T00:00:00Z".to_string(),
153        };
154        let json = serde_json::to_value(&rule).unwrap();
155        assert_eq!(json["type"], "expires_at");
156        assert_eq!(json["timestamp"], "2026-04-01T00:00:00Z");
157
158        let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
159        assert_eq!(deserialized, rule);
160    }
161
162    #[test]
163    fn test_policy_serde_roundtrip() {
164        let policy = Policy {
165            id: "base-agent-limits".into(),
166            name: "Base Agent Safety Limits".into(),
167            version: 1,
168            created_at: "2026-03-22T10:00:00Z".into(),
169            rules: vec![
170                PolicyRule::AllowedChains {
171                    chain_ids: vec!["eip155:8453".into()],
172                },
173                PolicyRule::ExpiresAt {
174                    timestamp: "2026-12-31T23:59:59Z".into(),
175                },
176            ],
177            executable: None,
178            config: None,
179            action: PolicyAction::Deny,
180        };
181
182        let json = serde_json::to_string(&policy).unwrap();
183        let deserialized: Policy = serde_json::from_str(&json).unwrap();
184        assert_eq!(deserialized.id, "base-agent-limits");
185        assert_eq!(deserialized.rules.len(), 2);
186        assert!(deserialized.executable.is_none());
187    }
188
189    #[test]
190    fn test_policy_with_executable() {
191        let json = r#"{
192            "id": "sim-policy",
193            "name": "Simulation Policy",
194            "version": 1,
195            "created_at": "2026-03-22T10:00:00Z",
196            "rules": [],
197            "executable": "/usr/local/bin/simulate-tx",
198            "config": {"rpc": "https://mainnet.base.org"},
199            "action": "deny"
200        }"#;
201        let policy: Policy = serde_json::from_str(json).unwrap();
202        assert_eq!(policy.executable.unwrap(), "/usr/local/bin/simulate-tx");
203        assert!(policy.config.is_some());
204    }
205
206    #[test]
207    fn test_policy_context_serde() {
208        let ctx = PolicyContext {
209            chain_id: "eip155:8453".into(),
210            wallet_id: "3198bc9c-6672-5ab3-d995-4942343ae5b6".into(),
211            api_key_id: "7a2f1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c".into(),
212            transaction: TransactionContext {
213                to: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C".into()),
214                value: Some("100000000000000000".into()),
215                raw_hex: "0x02f8...".into(),
216                data: None,
217            },
218            spending: SpendingContext {
219                daily_total: "50000000000000000".into(),
220                date: "2026-03-22".into(),
221            },
222            timestamp: "2026-03-22T10:35:22Z".into(),
223            typed_data: None,
224        };
225
226        let json = serde_json::to_string(&ctx).unwrap();
227        let deserialized: PolicyContext = serde_json::from_str(&json).unwrap();
228        assert_eq!(deserialized.chain_id, "eip155:8453");
229        assert_eq!(
230            deserialized.transaction.to.unwrap(),
231            "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD0C"
232        );
233        // data was None, should be absent from serialized form
234        assert!(!json.contains("\"data\""));
235    }
236
237    #[test]
238    fn test_policy_result_allowed() {
239        let result = PolicyResult::allowed();
240        assert!(result.allow);
241        assert!(result.reason.is_none());
242        assert!(result.policy_id.is_none());
243
244        let json = serde_json::to_value(&result).unwrap();
245        assert_eq!(json["allow"], true);
246        assert!(!json.as_object().unwrap().contains_key("reason"));
247    }
248
249    #[test]
250    fn test_policy_result_denied() {
251        let result = PolicyResult::denied(
252            "spending-limit",
253            "Daily spending limit exceeded: 0.95 / 1.0 ETH",
254        );
255        assert!(!result.allow);
256        assert_eq!(
257            result.reason.as_deref(),
258            Some("Daily spending limit exceeded: 0.95 / 1.0 ETH")
259        );
260        assert_eq!(result.policy_id.as_deref(), Some("spending-limit"));
261    }
262
263    #[test]
264    fn test_policy_action_serde() {
265        let action = PolicyAction::Deny;
266        let json = serde_json::to_value(&action).unwrap();
267        assert_eq!(json, "deny");
268
269        let deserialized: PolicyAction = serde_json::from_value(json).unwrap();
270        assert_eq!(deserialized, PolicyAction::Deny);
271    }
272
273    #[test]
274    fn test_typed_data_context_serde() {
275        let ctx = TypedDataContext {
276            verifying_contract: Some("0x000000000022D473030F116dDEE9F6B43aC78BA3".into()),
277            domain_chain_id: Some(8453),
278            primary_type: "PermitSingle".into(),
279            domain_name: Some("Permit2".into()),
280            domain_version: Some("1".into()),
281            raw_json: r#"{"types":{},"primaryType":"PermitSingle","domain":{},"message":{}}"#
282                .into(),
283        };
284
285        let json = serde_json::to_string(&ctx).unwrap();
286        let deserialized: TypedDataContext = serde_json::from_str(&json).unwrap();
287        assert_eq!(deserialized.primary_type, "PermitSingle");
288        assert_eq!(
289            deserialized.verifying_contract.as_deref(),
290            Some("0x000000000022D473030F116dDEE9F6B43aC78BA3")
291        );
292        assert_eq!(deserialized.domain_chain_id, Some(8453));
293    }
294
295    #[test]
296    fn test_typed_data_context_optional_fields_omitted() {
297        let ctx = TypedDataContext {
298            verifying_contract: None,
299            domain_chain_id: None,
300            primary_type: "Mail".into(),
301            domain_name: None,
302            domain_version: None,
303            raw_json: "{}".into(),
304        };
305
306        let json = serde_json::to_string(&ctx).unwrap();
307        assert!(!json.contains("verifying_contract"));
308        assert!(!json.contains("domain_chain_id"));
309        assert!(!json.contains("domain_name"));
310        assert!(!json.contains("domain_version"));
311    }
312
313    #[test]
314    fn test_policy_rule_serde_allowed_typed_data_contracts() {
315        let rule = PolicyRule::AllowedTypedDataContracts {
316            contracts: vec!["0x000000000022D473030F116dDEE9F6B43aC78BA3".into()],
317        };
318        let json = serde_json::to_value(&rule).unwrap();
319        assert_eq!(json["type"], "allowed_typed_data_contracts");
320        assert_eq!(
321            json["contracts"][0],
322            "0x000000000022D473030F116dDEE9F6B43aC78BA3"
323        );
324
325        let deserialized: PolicyRule = serde_json::from_value(json).unwrap();
326        assert_eq!(deserialized, rule);
327    }
328
329    #[test]
330    fn test_policy_context_typed_data_none_omitted() {
331        let ctx = PolicyContext {
332            chain_id: "eip155:8453".into(),
333            wallet_id: "w".into(),
334            api_key_id: "k".into(),
335            transaction: TransactionContext {
336                to: None,
337                value: None,
338                raw_hex: "0x00".into(),
339                data: None,
340            },
341            spending: SpendingContext {
342                daily_total: "0".into(),
343                date: "2026-03-30".into(),
344            },
345            timestamp: "2026-03-30T12:00:00Z".into(),
346            typed_data: None,
347        };
348
349        let json = serde_json::to_string(&ctx).unwrap();
350        assert!(!json.contains("typed_data"));
351    }
352}