Skip to main content

hessra_cap_policy/
policy.rs

1//! CList policy backend implementation.
2
3use std::collections::HashMap;
4
5use hessra_cap_engine::{
6    CapabilityGrant, ExposureLabel, ObjectId, Operation, PolicyBackend, PolicyDecision,
7};
8
9use crate::config::{ExposureRuleConfig, PolicyConfig};
10use crate::matching::matches_pattern;
11
12/// CList (Capability List) policy backend.
13///
14/// Each object has a capability space listing the targets it can access
15/// and the operations it can perform. Data classifications map targets
16/// to exposure labels. Exposure rules define which targets are blocked when
17/// specific exposure labels are present.
18pub struct CListPolicy {
19    /// Object capability spaces, keyed by object ID.
20    objects: HashMap<String, ObjectEntry>,
21    /// Data classifications: target -> exposure labels.
22    classifications: HashMap<String, Vec<String>>,
23    /// Exposure restriction rules.
24    exposure_rules: Vec<ExposureRuleConfig>,
25}
26
27struct ObjectEntry {
28    can_delegate: bool,
29    capabilities: Vec<CapEntry>,
30}
31
32struct CapEntry {
33    target: String,
34    operations: Vec<String>,
35}
36
37impl CListPolicy {
38    /// Create a CList policy from a parsed configuration.
39    pub fn from_config(config: PolicyConfig) -> Self {
40        let mut objects = HashMap::new();
41
42        for obj in config.objects {
43            let entry = ObjectEntry {
44                can_delegate: obj.can_delegate,
45                capabilities: obj
46                    .capabilities
47                    .into_iter()
48                    .map(|c| CapEntry {
49                        target: c.target,
50                        operations: c.operations,
51                    })
52                    .collect(),
53            };
54            objects.insert(obj.id, entry);
55        }
56
57        Self {
58            objects,
59            classifications: config.classifications,
60            exposure_rules: config.exposure_rules,
61        }
62    }
63
64    /// Create a CList policy from a TOML string.
65    pub fn from_toml(content: &str) -> Result<Self, crate::config::PolicyConfigError> {
66        let config = PolicyConfig::parse(content)?;
67        Ok(Self::from_config(config))
68    }
69
70    /// Create a CList policy from a TOML file.
71    pub fn from_file(path: &std::path::Path) -> Result<Self, crate::config::PolicyConfigError> {
72        let config = PolicyConfig::from_file(path)?;
73        Ok(Self::from_config(config))
74    }
75
76    /// Create an empty policy (useful for testing).
77    pub fn empty() -> Self {
78        Self {
79            objects: HashMap::new(),
80            classifications: HashMap::new(),
81            exposure_rules: Vec::new(),
82        }
83    }
84
85    /// Check if any exposure rule blocks access to a target given the current exposure labels.
86    fn check_exposure_restrictions(
87        &self,
88        target: &str,
89        exposure_labels: &[ExposureLabel],
90    ) -> Option<(ExposureLabel, ObjectId)> {
91        if exposure_labels.is_empty() {
92            return None;
93        }
94
95        for rule in &self.exposure_rules {
96            let rule_matches = if rule.r#match == "all" {
97                // All label patterns must match at least one exposure label
98                rule.labels.iter().all(|pattern| {
99                    exposure_labels
100                        .iter()
101                        .any(|label| matches_pattern(pattern, label.as_str()))
102                })
103            } else {
104                // Any label pattern matches any exposure label
105                rule.labels.iter().any(|pattern| {
106                    exposure_labels
107                        .iter()
108                        .any(|label| matches_pattern(pattern, label.as_str()))
109                })
110            };
111
112            if rule_matches {
113                // Check if the target is in the blocked list
114                for blocked in &rule.blocks {
115                    if matches_pattern(blocked, target) {
116                        // Find the first matching exposure label for the error
117                        let matching_label = exposure_labels
118                            .iter()
119                            .find(|label| {
120                                rule.labels
121                                    .iter()
122                                    .any(|pattern| matches_pattern(pattern, label.as_str()))
123                            })
124                            .cloned()
125                            .unwrap_or_else(|| ExposureLabel::new("unknown"));
126
127                        return Some((matching_label, ObjectId::new(target)));
128                    }
129                }
130            }
131        }
132
133        None
134    }
135}
136
137impl PolicyBackend for CListPolicy {
138    fn evaluate(
139        &self,
140        subject: &ObjectId,
141        target: &ObjectId,
142        operation: &Operation,
143        exposure_labels: &[ExposureLabel],
144    ) -> PolicyDecision {
145        // Step 1: Check exposure restrictions first
146        if let Some((label, blocked_target)) =
147            self.check_exposure_restrictions(target.as_str(), exposure_labels)
148        {
149            return PolicyDecision::DeniedByExposure {
150                label,
151                blocked_target,
152            };
153        }
154
155        // Step 2: Check capability space
156        let Some(object) = self.objects.get(subject.as_str()) else {
157            return PolicyDecision::Denied {
158                reason: format!("object '{subject}' not found in policy"),
159            };
160        };
161
162        for cap in &object.capabilities {
163            if cap.target == target.as_str()
164                && cap.operations.iter().any(|op| op == operation.as_str())
165            {
166                return PolicyDecision::Granted;
167            }
168        }
169
170        PolicyDecision::Denied {
171            reason: format!("'{subject}' does not have capability for '{target}'/'{operation}'"),
172        }
173    }
174
175    fn classification(&self, target: &ObjectId) -> Vec<ExposureLabel> {
176        self.classifications
177            .get(target.as_str())
178            .map(|labels| {
179                labels
180                    .iter()
181                    .map(|l| ExposureLabel::new(l.as_str()))
182                    .collect()
183            })
184            .unwrap_or_default()
185    }
186
187    fn list_grants(&self, subject: &ObjectId) -> Vec<CapabilityGrant> {
188        self.objects
189            .get(subject.as_str())
190            .map(|obj| {
191                obj.capabilities
192                    .iter()
193                    .map(|cap| CapabilityGrant {
194                        target: ObjectId::new(&cap.target),
195                        operations: cap.operations.iter().map(Operation::new).collect(),
196                    })
197                    .collect()
198            })
199            .unwrap_or_default()
200    }
201
202    fn can_delegate(&self, subject: &ObjectId) -> bool {
203        self.objects
204            .get(subject.as_str())
205            .map(|obj| obj.can_delegate)
206            .unwrap_or(false)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn test_policy() -> CListPolicy {
215        let toml = r#"
216[[objects]]
217id = "agent:openclaw"
218can_delegate = true
219capabilities = [
220    { target = "tool:file-read", operations = ["invoke"] },
221    { target = "tool:web-search", operations = ["invoke"] },
222    { target = "tool:email", operations = ["invoke"] },
223    { target = "data:user-profile", operations = ["read"] },
224    { target = "data:user-ssn", operations = ["read"] },
225]
226
227[[objects]]
228id = "service:api-gateway"
229can_delegate = false
230capabilities = [
231    { target = "service:user-service", operations = ["read", "write"] },
232]
233
234[classifications]
235"data:user-profile" = ["PII:email", "PII:address"]
236"data:user-ssn" = ["PII:SSN"]
237
238[[exposure_rules]]
239labels = ["PII:SSN"]
240blocks = ["tool:external-api", "tool:email", "tool:web-search"]
241
242[[exposure_rules]]
243labels = ["PII:*", "financial:*"]
244match = "all"
245blocks = ["tool:*"]
246        "#;
247
248        CListPolicy::from_toml(toml).expect("Failed to parse test policy")
249    }
250
251    #[test]
252    fn test_basic_grant() {
253        let policy = test_policy();
254        let decision = policy.evaluate(
255            &ObjectId::new("agent:openclaw"),
256            &ObjectId::new("tool:file-read"),
257            &Operation::new("invoke"),
258            &[],
259        );
260        assert!(decision.is_granted());
261    }
262
263    #[test]
264    fn test_denied_no_capability() {
265        let policy = test_policy();
266        let decision = policy.evaluate(
267            &ObjectId::new("agent:openclaw"),
268            &ObjectId::new("tool:delete-everything"),
269            &Operation::new("invoke"),
270            &[],
271        );
272        assert!(!decision.is_granted());
273    }
274
275    #[test]
276    fn test_denied_wrong_operation() {
277        let policy = test_policy();
278        let decision = policy.evaluate(
279            &ObjectId::new("service:api-gateway"),
280            &ObjectId::new("service:user-service"),
281            &Operation::new("delete"),
282            &[],
283        );
284        assert!(!decision.is_granted());
285    }
286
287    #[test]
288    fn test_denied_unknown_subject() {
289        let policy = test_policy();
290        let decision = policy.evaluate(
291            &ObjectId::new("agent:unknown"),
292            &ObjectId::new("tool:file-read"),
293            &Operation::new("invoke"),
294            &[],
295        );
296        assert!(!decision.is_granted());
297    }
298
299    #[test]
300    fn test_exposure_blocks_access() {
301        let policy = test_policy();
302        let exposure = vec![ExposureLabel::new("PII:SSN")];
303
304        // web-search should be blocked
305        let decision = policy.evaluate(
306            &ObjectId::new("agent:openclaw"),
307            &ObjectId::new("tool:web-search"),
308            &Operation::new("invoke"),
309            &exposure,
310        );
311        assert!(!decision.is_granted());
312        assert!(matches!(decision, PolicyDecision::DeniedByExposure { .. }));
313    }
314
315    #[test]
316    fn test_exposure_allows_non_blocked() {
317        let policy = test_policy();
318        let exposure = vec![ExposureLabel::new("PII:SSN")];
319
320        // file-read should still work with SSN exposure
321        let decision = policy.evaluate(
322            &ObjectId::new("agent:openclaw"),
323            &ObjectId::new("tool:file-read"),
324            &Operation::new("invoke"),
325            &exposure,
326        );
327        assert!(decision.is_granted());
328    }
329
330    #[test]
331    fn test_compound_exposure_rule() {
332        let policy = test_policy();
333
334        // PII alone shouldn't trigger the compound rule
335        let pii_only = vec![ExposureLabel::new("PII:email")];
336        let decision = policy.evaluate(
337            &ObjectId::new("agent:openclaw"),
338            &ObjectId::new("tool:file-read"),
339            &Operation::new("invoke"),
340            &pii_only,
341        );
342        assert!(decision.is_granted());
343
344        // PII + financial should trigger the compound rule blocking all tools
345        let both = vec![
346            ExposureLabel::new("PII:email"),
347            ExposureLabel::new("financial:balance"),
348        ];
349        let decision = policy.evaluate(
350            &ObjectId::new("agent:openclaw"),
351            &ObjectId::new("tool:file-read"),
352            &Operation::new("invoke"),
353            &both,
354        );
355        assert!(!decision.is_granted());
356    }
357
358    #[test]
359    fn test_classification_lookup() {
360        let policy = test_policy();
361
362        let labels = policy.classification(&ObjectId::new("data:user-ssn"));
363        assert_eq!(labels.len(), 1);
364        assert_eq!(labels[0].as_str(), "PII:SSN");
365
366        let labels = policy.classification(&ObjectId::new("data:user-profile"));
367        assert_eq!(labels.len(), 2);
368
369        let labels = policy.classification(&ObjectId::new("data:unclassified"));
370        assert!(labels.is_empty());
371    }
372
373    #[test]
374    fn test_list_grants() {
375        let policy = test_policy();
376        let grants = policy.list_grants(&ObjectId::new("agent:openclaw"));
377        assert_eq!(grants.len(), 5);
378    }
379
380    #[test]
381    fn test_can_delegate() {
382        let policy = test_policy();
383        assert!(policy.can_delegate(&ObjectId::new("agent:openclaw")));
384        assert!(!policy.can_delegate(&ObjectId::new("service:api-gateway")));
385        assert!(!policy.can_delegate(&ObjectId::new("unknown")));
386    }
387}