Skip to main content

aegis_delegate/
scope.rs

1// AEGIS Delegate — Scope Constraints
2//
3// Reference: AEGIS Specification v1.0.0 §9.4, §9.6
4//
5// Implements the no-amplification rule (§9.6 rule 1) and intersection
6// narrowing (§9.6 rule 2) for delegation scope hierarchies.
7
8use openagent_aegis_core::{DelegationError, DelegationScope, SpendingLimits, TemporalConstraints};
9
10/// Checks if `child` scope is a subset of `parent` scope (no-amplification rule §9.6).
11///
12/// A child scope is a subset when:
13/// - Every action in child is present in parent (or parent has no actions, meaning wildcard)
14/// - Every resource in child is present in parent (or parent has no restrictions)
15/// - Every chain in child is present in parent (or parent has no restrictions)
16/// - Child limits are at least as restrictive as parent limits
17pub fn is_scope_subset(child: &DelegationScope, parent: &DelegationScope) -> bool {
18    // Actions: if parent is non-empty, child must be a subset
19    if !parent.actions.is_empty() && !child.actions.iter().all(|a| parent.actions.contains(a)) {
20        return false;
21    }
22
23    // Resources: if parent is non-empty, child must be a subset
24    if !parent.resources.is_empty() && !child.resources.iter().all(|r| parent.resources.contains(r))
25    {
26        return false;
27    }
28
29    // Chains: if parent is non-empty, child must be a subset
30    if !parent.chains.is_empty() && !child.chains.iter().all(|c| parent.chains.contains(c)) {
31        return false;
32    }
33
34    // Limits: child must be more restrictive or equal
35    if let Some(ref parent_limits) = parent.limits {
36        match child.limits {
37            None => {
38                // No limits on child but parent has limits => child is less restrictive
39                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    // If parent has no limits, child may have any limits (more restrictive is fine)
49
50    true
51}
52
53/// Checks whether `child_limits` is at least as restrictive as `parent_limits`.
54fn is_limits_subset(child: &SpendingLimits, parent: &SpendingLimits) -> bool {
55    // max_amount: child must be <= parent
56    if let Some(ref parent_max) = parent.max_amount {
57        match child.max_amount {
58            None => return false, // no limit < parent limit
59            Some(ref child_max) => {
60                if !is_amount_leq(child_max, parent_max) {
61                    return false;
62                }
63            }
64        }
65    }
66
67    // daily_volume: child must be <= parent
68    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    // asset_allowlist: child must be subset of parent (if parent restricts)
80    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    // recipient_allowlist: child must be subset
90    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    // approval_threshold: child must be <= parent (lower threshold = more restrictive)
100    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
114/// Compare two decimal amount strings. Returns true if `a <= b`.
115///
116/// Both values are parsed as f64 for comparison. If parsing fails,
117/// falls back to lexicographic comparison.
118fn 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
125/// Computes the intersection of two scopes (intersection narrowing rule §9.6).
126///
127/// For each dimension:
128/// - If either side is empty (wildcard), use the other side's restriction
129/// - Otherwise, take the set intersection
130/// - For limits, take the most restrictive value
131pub 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
147/// Intersects two string lists. If either is empty (wildcard), returns the other.
148fn 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
158/// Intersects two optional spending limits, keeping the most restrictive values.
159fn 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
182/// Intersects two optional temporal constraints, keeping the most restrictive.
183fn 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            // valid_from: take the later one (more restrictive start)
192            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            // valid_until: take the earlier one (more restrictive end)
199            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            // active_hours: prefer more restrictive; if both present, take a's
206            // (true intersection of hour windows requires timezone math beyond scope)
207            let active_hours = a_t
208                .active_hours
209                .clone()
210                .or_else(|| b_t.active_hours.clone());
211
212            // cooldown: keep the longer cooldown (more restrictive)
213            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                    // ISO 8601 duration comparison is non-trivial;
218                    // for safety, keep the lexicographically larger one
219                    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
237/// Returns the minimum of two optional amount strings, or the one that is present.
238fn 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
252/// Validates that a delegation scope is well-formed.
253///
254/// A scope is well-formed if:
255/// - Actions, resources, and chains contain no empty strings
256/// - If limits are present, numeric fields parse as valid numbers
257pub fn validate_scope(scope: &DelegationScope) -> Result<(), DelegationError> {
258    // Check for empty strings in lists
259    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    // Validate limits if present
282    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    // Validate temporal constraints if present
289    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
309/// Validates that an optional amount string is parseable as a number.
310fn 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()), // exceeds parent's 1000
417                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        // Actions: intersection of [transfer,approve,stake] and [transfer]
445        assert_eq!(result.actions, vec!["transfer".to_string()]);
446        // Resources: intersection of [0xabc,0xdef] and [0xabc]
447        assert_eq!(result.resources, vec!["0xabc".to_string()]);
448        // Chains: intersection of [ethereum,polygon] and [ethereum]
449        assert_eq!(result.chains, vec!["ethereum".to_string()]);
450        // Limits: more restrictive
451        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}