1use openagent_aegis_core::{DelegationError, DelegationScope, SpendingLimits, TemporalConstraints};
9
10pub fn is_scope_subset(child: &DelegationScope, parent: &DelegationScope) -> bool {
18 if !parent.actions.is_empty() && !child.actions.iter().all(|a| parent.actions.contains(a)) {
20 return false;
21 }
22
23 if !parent.resources.is_empty() && !child.resources.iter().all(|r| parent.resources.contains(r))
25 {
26 return false;
27 }
28
29 if !parent.chains.is_empty() && !child.chains.iter().all(|c| parent.chains.contains(c)) {
31 return false;
32 }
33
34 if let Some(ref parent_limits) = parent.limits {
36 match child.limits {
37 None => {
38 return false;
40 }
41 Some(ref child_limits) => {
42 if !is_limits_subset(child_limits, parent_limits) {
43 return false;
44 }
45 }
46 }
47 }
48 true
51}
52
53fn is_limits_subset(child: &SpendingLimits, parent: &SpendingLimits) -> bool {
55 if let Some(ref parent_max) = parent.max_amount {
57 match child.max_amount {
58 None => return false, Some(ref child_max) => {
60 if !is_amount_leq(child_max, parent_max) {
61 return false;
62 }
63 }
64 }
65 }
66
67 if let Some(ref parent_daily) = parent.daily_volume {
69 match child.daily_volume {
70 None => return false,
71 Some(ref child_daily) => {
72 if !is_amount_leq(child_daily, parent_daily) {
73 return false;
74 }
75 }
76 }
77 }
78
79 if !parent.asset_allowlist.is_empty()
81 && !child
82 .asset_allowlist
83 .iter()
84 .all(|a| parent.asset_allowlist.contains(a))
85 {
86 return false;
87 }
88
89 if !parent.recipient_allowlist.is_empty()
91 && !child
92 .recipient_allowlist
93 .iter()
94 .all(|r| parent.recipient_allowlist.contains(r))
95 {
96 return false;
97 }
98
99 if let Some(ref parent_thresh) = parent.approval_threshold {
101 match child.approval_threshold {
102 None => return false,
103 Some(ref child_thresh) => {
104 if !is_amount_leq(child_thresh, parent_thresh) {
105 return false;
106 }
107 }
108 }
109 }
110
111 true
112}
113
114fn is_amount_leq(a: &str, b: &str) -> bool {
119 match (a.parse::<f64>(), b.parse::<f64>()) {
120 (Ok(av), Ok(bv)) => av <= bv,
121 _ => a <= b,
122 }
123}
124
125pub fn intersect_scopes(a: &DelegationScope, b: &DelegationScope) -> DelegationScope {
132 let actions = intersect_lists(&a.actions, &b.actions);
133 let resources = intersect_lists(&a.resources, &b.resources);
134 let chains = intersect_lists(&a.chains, &b.chains);
135 let limits = intersect_limits(&a.limits, &b.limits);
136 let temporal = intersect_temporal(&a.temporal, &b.temporal);
137
138 DelegationScope {
139 actions,
140 resources,
141 chains,
142 limits,
143 temporal,
144 }
145}
146
147fn intersect_lists(a: &[String], b: &[String]) -> Vec<String> {
149 if a.is_empty() {
150 return b.to_vec();
151 }
152 if b.is_empty() {
153 return a.to_vec();
154 }
155 a.iter().filter(|x| b.contains(x)).cloned().collect()
156}
157
158fn intersect_limits(
160 a: &Option<SpendingLimits>,
161 b: &Option<SpendingLimits>,
162) -> Option<SpendingLimits> {
163 match (a, b) {
164 (None, None) => None,
165 (Some(limits), None) | (None, Some(limits)) => Some(limits.clone()),
166 (Some(a_lim), Some(b_lim)) => Some(SpendingLimits {
167 max_amount: min_amount_opt(&a_lim.max_amount, &b_lim.max_amount),
168 daily_volume: min_amount_opt(&a_lim.daily_volume, &b_lim.daily_volume),
169 asset_allowlist: intersect_lists(&a_lim.asset_allowlist, &b_lim.asset_allowlist),
170 recipient_allowlist: intersect_lists(
171 &a_lim.recipient_allowlist,
172 &b_lim.recipient_allowlist,
173 ),
174 approval_threshold: min_amount_opt(
175 &a_lim.approval_threshold,
176 &b_lim.approval_threshold,
177 ),
178 }),
179 }
180}
181
182fn intersect_temporal(
184 a: &Option<TemporalConstraints>,
185 b: &Option<TemporalConstraints>,
186) -> Option<TemporalConstraints> {
187 match (a, b) {
188 (None, None) => None,
189 (Some(t), None) | (None, Some(t)) => Some(t.clone()),
190 (Some(a_t), Some(b_t)) => {
191 let valid_from = match (&a_t.valid_from, &b_t.valid_from) {
193 (None, None) => None,
194 (Some(t), None) | (None, Some(t)) => Some(*t),
195 (Some(at), Some(bt)) => Some((*at).max(*bt)),
196 };
197
198 let valid_until = match (&a_t.valid_until, &b_t.valid_until) {
200 (None, None) => None,
201 (Some(t), None) | (None, Some(t)) => Some(*t),
202 (Some(at), Some(bt)) => Some((*at).min(*bt)),
203 };
204
205 let active_hours = a_t
208 .active_hours
209 .clone()
210 .or_else(|| b_t.active_hours.clone());
211
212 let cooldown = match (&a_t.cooldown, &b_t.cooldown) {
214 (None, None) => None,
215 (Some(c), None) | (None, Some(c)) => Some(c.clone()),
216 (Some(ac), Some(bc)) => {
217 if ac >= bc {
220 Some(ac.clone())
221 } else {
222 Some(bc.clone())
223 }
224 }
225 };
226
227 Some(TemporalConstraints {
228 valid_from,
229 valid_until,
230 active_hours,
231 cooldown,
232 })
233 }
234 }
235}
236
237fn min_amount_opt(a: &Option<String>, b: &Option<String>) -> Option<String> {
239 match (a, b) {
240 (None, None) => None,
241 (Some(v), None) | (None, Some(v)) => Some(v.clone()),
242 (Some(av), Some(bv)) => {
243 if is_amount_leq(av, bv) {
244 Some(av.clone())
245 } else {
246 Some(bv.clone())
247 }
248 }
249 }
250}
251
252pub fn validate_scope(scope: &DelegationScope) -> Result<(), DelegationError> {
258 for action in &scope.actions {
260 if action.is_empty() {
261 return Err(DelegationError::ScopeAmplification {
262 reason: "action must not be empty".to_string(),
263 });
264 }
265 }
266 for resource in &scope.resources {
267 if resource.is_empty() {
268 return Err(DelegationError::ScopeAmplification {
269 reason: "resource must not be empty".to_string(),
270 });
271 }
272 }
273 for chain in &scope.chains {
274 if chain.is_empty() {
275 return Err(DelegationError::ScopeAmplification {
276 reason: "chain must not be empty".to_string(),
277 });
278 }
279 }
280
281 if let Some(ref limits) = scope.limits {
283 validate_amount_field(&limits.max_amount, "max_amount")?;
284 validate_amount_field(&limits.daily_volume, "daily_volume")?;
285 validate_amount_field(&limits.approval_threshold, "approval_threshold")?;
286 }
287
288 if let Some(ref temporal) = scope.temporal {
290 if let (Some(from), Some(until)) = (&temporal.valid_from, &temporal.valid_until) {
291 if from >= until {
292 return Err(DelegationError::ScopeAmplification {
293 reason: "valid_from must be before valid_until".to_string(),
294 });
295 }
296 }
297 if let Some(ref hours) = temporal.active_hours {
298 if hours.start_hour > 23 || hours.end_hour > 23 {
299 return Err(DelegationError::ScopeAmplification {
300 reason: "active hours must be 0-23".to_string(),
301 });
302 }
303 }
304 }
305
306 Ok(())
307}
308
309fn validate_amount_field(field: &Option<String>, name: &str) -> Result<(), DelegationError> {
311 if let Some(ref val) = field {
312 if val.parse::<f64>().is_err() {
313 return Err(DelegationError::ScopeAmplification {
314 reason: format!("{name} is not a valid number: {val}"),
315 });
316 }
317 }
318 Ok(())
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use openagent_aegis_core::SpendingLimits;
325
326 fn full_scope() -> DelegationScope {
327 DelegationScope {
328 actions: vec![
329 "transfer".to_string(),
330 "approve".to_string(),
331 "stake".to_string(),
332 ],
333 resources: vec!["0xabc".to_string(), "0xdef".to_string()],
334 chains: vec!["ethereum".to_string(), "polygon".to_string()],
335 limits: Some(SpendingLimits {
336 max_amount: Some("1000".to_string()),
337 daily_volume: Some("5000".to_string()),
338 asset_allowlist: vec!["ETH".to_string(), "USDC".to_string()],
339 recipient_allowlist: vec!["0x111".to_string()],
340 approval_threshold: Some("500".to_string()),
341 }),
342 temporal: None,
343 }
344 }
345
346 fn narrow_scope() -> DelegationScope {
347 DelegationScope {
348 actions: vec!["transfer".to_string()],
349 resources: vec!["0xabc".to_string()],
350 chains: vec!["ethereum".to_string()],
351 limits: Some(SpendingLimits {
352 max_amount: Some("100".to_string()),
353 daily_volume: Some("500".to_string()),
354 asset_allowlist: vec!["ETH".to_string()],
355 recipient_allowlist: vec!["0x111".to_string()],
356 approval_threshold: Some("50".to_string()),
357 }),
358 temporal: None,
359 }
360 }
361
362 #[test]
363 fn narrow_is_subset_of_full() {
364 assert!(is_scope_subset(&narrow_scope(), &full_scope()));
365 }
366
367 #[test]
368 fn full_is_not_subset_of_narrow() {
369 assert!(!is_scope_subset(&full_scope(), &narrow_scope()));
370 }
371
372 #[test]
373 fn scope_is_subset_of_itself() {
374 let s = full_scope();
375 assert!(is_scope_subset(&s, &s));
376 }
377
378 #[test]
379 fn empty_parent_accepts_anything() {
380 let parent = DelegationScope {
381 actions: vec![],
382 resources: vec![],
383 chains: vec![],
384 limits: None,
385 temporal: None,
386 };
387 assert!(is_scope_subset(&full_scope(), &parent));
388 }
389
390 #[test]
391 fn amplification_in_actions_rejected() {
392 let child = DelegationScope {
393 actions: vec!["transfer".to_string(), "destroy".to_string()],
394 resources: vec![],
395 chains: vec![],
396 limits: None,
397 temporal: None,
398 };
399 let parent = DelegationScope {
400 actions: vec!["transfer".to_string()],
401 resources: vec![],
402 chains: vec![],
403 limits: None,
404 temporal: None,
405 };
406 assert!(!is_scope_subset(&child, &parent));
407 }
408
409 #[test]
410 fn amplification_in_limits_rejected() {
411 let child = DelegationScope {
412 actions: vec!["transfer".to_string()],
413 resources: vec![],
414 chains: vec![],
415 limits: Some(SpendingLimits {
416 max_amount: Some("2000".to_string()), daily_volume: None,
418 asset_allowlist: vec![],
419 recipient_allowlist: vec![],
420 approval_threshold: None,
421 }),
422 temporal: None,
423 };
424 let parent = DelegationScope {
425 actions: vec!["transfer".to_string()],
426 resources: vec![],
427 chains: vec![],
428 limits: Some(SpendingLimits {
429 max_amount: Some("1000".to_string()),
430 daily_volume: None,
431 asset_allowlist: vec![],
432 recipient_allowlist: vec![],
433 approval_threshold: None,
434 }),
435 temporal: None,
436 };
437 assert!(!is_scope_subset(&child, &parent));
438 }
439
440 #[test]
441 fn intersection_narrows_correctly() {
442 let result = intersect_scopes(&full_scope(), &narrow_scope());
443
444 assert_eq!(result.actions, vec!["transfer".to_string()]);
446 assert_eq!(result.resources, vec!["0xabc".to_string()]);
448 assert_eq!(result.chains, vec!["ethereum".to_string()]);
450 let limits = result.limits.as_ref();
452 assert!(limits.is_some());
453 if let Some(l) = limits {
454 assert_eq!(l.max_amount.as_deref(), Some("100"));
455 assert_eq!(l.daily_volume.as_deref(), Some("500"));
456 assert_eq!(l.approval_threshold.as_deref(), Some("50"));
457 }
458 }
459
460 #[test]
461 fn intersection_with_empty_is_identity() {
462 let empty = DelegationScope {
463 actions: vec![],
464 resources: vec![],
465 chains: vec![],
466 limits: None,
467 temporal: None,
468 };
469 let result = intersect_scopes(&full_scope(), &empty);
470 assert_eq!(result.actions, full_scope().actions);
471 assert_eq!(result.resources, full_scope().resources);
472 }
473
474 #[test]
475 fn validate_scope_rejects_empty_action() {
476 let scope = DelegationScope {
477 actions: vec!["".to_string()],
478 resources: vec![],
479 chains: vec![],
480 limits: None,
481 temporal: None,
482 };
483 assert!(validate_scope(&scope).is_err());
484 }
485
486 #[test]
487 fn validate_scope_rejects_invalid_amount() {
488 let scope = DelegationScope {
489 actions: vec!["transfer".to_string()],
490 resources: vec![],
491 chains: vec![],
492 limits: Some(SpendingLimits {
493 max_amount: Some("not-a-number".to_string()),
494 daily_volume: None,
495 asset_allowlist: vec![],
496 recipient_allowlist: vec![],
497 approval_threshold: None,
498 }),
499 temporal: None,
500 };
501 assert!(validate_scope(&scope).is_err());
502 }
503
504 #[test]
505 fn validate_scope_accepts_valid() {
506 assert!(validate_scope(&full_scope()).is_ok());
507 }
508}