1use 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
12pub struct CListPolicy {
19 objects: HashMap<String, ObjectEntry>,
21 classifications: HashMap<String, Vec<String>>,
23 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 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 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 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 pub fn empty() -> Self {
78 Self {
79 objects: HashMap::new(),
80 classifications: HashMap::new(),
81 exposure_rules: Vec::new(),
82 }
83 }
84
85 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 rule.labels.iter().all(|pattern| {
99 exposure_labels
100 .iter()
101 .any(|label| matches_pattern(pattern, label.as_str()))
102 })
103 } else {
104 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 for blocked in &rule.blocks {
115 if matches_pattern(blocked, target) {
116 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 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 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 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 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 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 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}