1use regex::Regex;
12use serde_json::Value;
13use std::sync::OnceLock;
14
15use crate::pack::{PolicyPack, Rule};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Decision {
19 Allow,
20 Warn,
21 Deny,
22}
23
24impl Decision {
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Decision::Allow => "ALLOW",
28 Decision::Warn => "WARN",
29 Decision::Deny => "DENY",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct DecisionResult {
37 pub decision: &'static str,
38 pub deciding_pack: Option<String>,
39 pub deciding_rule: Option<String>,
40 pub deciding_layer: Option<String>,
41 pub reason: Option<String>,
42}
43
44pub fn get_dotpath<'a>(d: &'a Value, path: &str) -> Option<&'a Value> {
49 let mut cur = d;
50 for part in path.split('.') {
51 let obj = cur.as_object()?;
52 cur = obj.get(part)?;
53 }
54 Some(cur)
55}
56
57pub fn match_action(rule_match_action: &Value, action: &str) -> bool {
62 match rule_match_action {
63 Value::String(s) => {
64 if s == "*" {
65 return true;
66 }
67 if let Some(prefix) = s.strip_suffix(".*") {
68 return action.starts_with(&format!("{prefix}."));
70 }
71 s == action
72 }
73 Value::Array(arr) => arr.iter().any(|p| match_action(p, action)),
74 _ => false,
75 }
76}
77
78pub fn eval_rego_subset(module: &str, value_to_test: &str) -> &'static str {
89 static DEFAULT_RE: OnceLock<Regex> = OnceLock::new();
90 static DENY_RE: OnceLock<Regex> = OnceLock::new();
91
92 let default_re = DEFAULT_RE.get_or_init(|| {
93 Regex::new(r#"default\s+decision\s*:=\s*"(ALLOW|WARN|DENY)""#).unwrap()
94 });
95 let deny_re = DENY_RE.get_or_init(|| {
96 Regex::new(r#"decision\s*:=\s*"DENY"\s*\{\s*re_match\(\s*`([^`]+)`\s*,\s*input\.value\s*\)\s*\}"#).unwrap()
99 });
100
101 let default = match default_re.captures(module).and_then(|c| c.get(1)) {
102 Some(m) => match m.as_str() {
103 "ALLOW" => "ALLOW",
104 "WARN" => "WARN",
105 "DENY" => "DENY",
106 _ => "ALLOW",
107 },
108 None => "ALLOW",
109 };
110
111 if let Some(c) = deny_re.captures(module) {
112 let pattern = c.get(1).unwrap().as_str();
113 match Regex::new(pattern) {
114 Ok(re) => {
115 if re.is_match(value_to_test) {
116 return "DENY";
117 }
118 }
119 Err(e) => {
120 tracing::warn!("rego_regex_error: {} in pattern {:?}", e, pattern);
121 }
122 }
123 }
124 default
125}
126
127pub fn eval_rego_allowlist(module: &str, action: &str) -> &'static str {
140 if !module.contains("input.action in allowed") {
141 return eval_rego_subset(module, action);
142 }
143 static ALLOWED_RE: OnceLock<Regex> = OnceLock::new();
144 static MEMBER_RE: OnceLock<Regex> = OnceLock::new();
145 static DEFAULT_RE: OnceLock<Regex> = OnceLock::new();
146 let allowed_re = ALLOWED_RE
148 .get_or_init(|| Regex::new(r"(?s)allowed\s*:=\s*\{([^}]+)\}").unwrap());
149 let member_re = MEMBER_RE.get_or_init(|| Regex::new(r#""([^"]+)""#).unwrap());
150 let default_re = DEFAULT_RE.get_or_init(|| {
151 Regex::new(r#"default\s+decision\s*:=\s*"(ALLOW|DENY)""#).unwrap()
152 });
153
154 let allowed_block = match allowed_re.captures(module) {
155 Some(c) => c.get(1).unwrap().as_str().to_owned(),
156 None => return "ALLOW",
157 };
158 let allowed: Vec<&str> = member_re
159 .captures_iter(&allowed_block)
160 .filter_map(|c| c.get(1).map(|m| m.as_str()))
161 .collect();
162 let allowed_owned: Vec<String> = allowed.into_iter().map(String::from).collect();
164
165 let default = match default_re.captures(module).and_then(|c| c.get(1)) {
166 Some(m) => match m.as_str() {
167 "ALLOW" => "ALLOW",
168 "DENY" => "DENY",
169 _ => "ALLOW",
170 },
171 None => "ALLOW",
172 };
173
174 if allowed_owned.iter().any(|a| a == action) {
175 "ALLOW"
176 } else {
177 default
178 }
179}
180
181pub fn eval_jsonlogic(module: &str, context: &Value) -> &'static str {
189 let rule: Value = match serde_json::from_str(module) {
190 Ok(v) => v,
191 Err(_) => return "ALLOW",
192 };
193 let ctx_obj = context.as_object();
194 if let Some(and_arr) = rule.get("and").and_then(|v| v.as_array()) {
195 for child in and_arr {
196 if let Some(args) = child.get("<=").and_then(|v| v.as_array()) {
197 if args.len() < 2 {
198 continue;
199 }
200 let var_name = args[0].get("var").and_then(|v| v.as_str());
201 let threshold = args[1].as_f64();
202 let var_name = match var_name {
203 Some(n) => n,
204 None => continue,
205 };
206 let threshold = match threshold {
207 Some(t) => t,
208 None => continue,
209 };
210 let cur = ctx_obj
211 .and_then(|o| o.get(var_name))
212 .and_then(|v| v.as_f64())
213 .unwrap_or(0.0);
214 if cur > threshold {
215 return "DENY";
216 }
217 }
218 }
219 }
220 "ALLOW"
221}
222
223pub fn evaluate_rule(
227 rule: &Rule,
228 action_payload: &Value,
229 daemon_state: &Value,
230) -> (&'static str, Option<String>) {
231 let action = action_payload
232 .get("action")
233 .and_then(|v| v.as_str())
234 .unwrap_or("");
235 let match_action_val = match &rule.r#match.action {
236 Value::Null => Value::String("*".into()),
237 v => v.clone(),
238 };
239 if !match_action(&match_action_val, action) {
240 return ("ALLOW", None);
241 }
242
243 let lang = rule.policy.language.as_str();
244 let module = rule.policy.module.as_str();
245 let deny_reason = rule
246 .deny_reason
247 .clone()
248 .unwrap_or_else(|| "rule_match".into());
249 let warn_only = rule.warn_only;
250
251 let mut decision: &'static str = "ALLOW";
252 match lang {
253 "rego" => {
254 if module.contains("input.action in allowed") {
255 decision = eval_rego_allowlist(module, action);
256 } else {
257 let fields = rule
258 .r#match
259 .fields
260 .clone()
261 .unwrap_or_else(|| vec!["input".to_string()]);
262 for field in &fields {
263 let value_node = get_dotpath(action_payload, field);
266 let value_str: String = match value_node {
267 None => String::new(),
268 Some(Value::String(s)) => s.clone(),
269 Some(Value::Null) => String::new(),
270 Some(other) => serde_json::to_string(other).unwrap_or_default(),
271 };
272 if eval_rego_subset(module, &value_str) == "DENY" {
273 decision = "DENY";
274 break;
275 }
276 }
277 }
278 }
279 "jsonlogic" => {
280 let mut ctx = serde_json::Map::new();
282 if let Some(o) = action_payload.as_object() {
283 for (k, v) in o {
284 ctx.insert(k.clone(), v.clone());
285 }
286 }
287 if let Some(o) = daemon_state.as_object() {
288 for (k, v) in o {
289 ctx.insert(k.clone(), v.clone());
290 }
291 }
292 decision = eval_jsonlogic(module, &Value::Object(ctx));
293 }
294 other => {
295 tracing::warn!(
296 "unsupported_policy_language={} rule={}",
297 other,
298 rule.rule_id
299 );
300 return ("ALLOW", None);
301 }
302 }
303
304 if decision == "DENY" && warn_only {
305 return ("WARN", Some(deny_reason));
306 }
307 let reason = if decision == "ALLOW" { None } else { Some(deny_reason) };
308 (decision, reason)
309}
310
311pub fn evaluate_packs(
316 packs: &[PolicyPack],
317 action_payload: &Value,
318 daemon_state: &Value,
319) -> DecisionResult {
320 let mut result = DecisionResult {
321 decision: "ALLOW",
322 ..Default::default()
323 };
324
325 for pack in packs {
326 for rule in &pack.rules {
327 let (decision, reason) = evaluate_rule(rule, action_payload, daemon_state);
328 match decision {
329 "DENY" => {
330 return DecisionResult {
331 decision: "DENY",
332 deciding_pack: Some(pack.pack_id.clone()),
333 deciding_rule: Some(rule.rule_id.clone()),
334 deciding_layer: Some(
335 rule.layer.clone().unwrap_or_else(|| "L?".into()),
336 ),
337 reason,
338 };
339 }
340 "WARN" if result.decision == "ALLOW" => {
341 result.decision = "WARN";
342 result.deciding_pack = Some(pack.pack_id.clone());
343 result.deciding_rule = Some(rule.rule_id.clone());
344 result.deciding_layer = Some(
345 rule.layer.clone().unwrap_or_else(|| "L?".into()),
346 );
347 result.reason = reason;
348 }
349 _ => {}
350 }
351 }
352 }
353 result
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use serde_json::json;
360
361 #[test]
362 fn dotpath_simple() {
363 let v = json!({"a": {"b": "ok"}});
364 assert_eq!(get_dotpath(&v, "a.b"), Some(&Value::String("ok".into())));
365 assert!(get_dotpath(&v, "a.c").is_none());
366 assert!(get_dotpath(&v, "x.y").is_none());
367 }
368
369 #[test]
370 fn action_glob() {
371 assert!(match_action(&Value::String("*".into()), "anything"));
372 assert!(match_action(&Value::String("tool.*".into()), "tool.read"));
373 assert!(!match_action(&Value::String("tool.*".into()), "toolread"));
374 assert!(!match_action(&Value::String("tool.*".into()), "function.x"));
375 assert!(match_action(&Value::String("exact".into()), "exact"));
376 assert!(match_action(
377 &json!(["tool.*", "function.*"]),
378 "function.call"
379 ));
380 }
381
382 #[test]
383 fn rego_subset_default_allow_no_match() {
384 let module = "default decision := \"ALLOW\"\ndecision := \"DENY\" { re_match(`\\d+`, input.value) }";
385 assert_eq!(eval_rego_subset(module, "no digits"), "ALLOW");
386 assert_eq!(eval_rego_subset(module, "abc123"), "DENY");
387 }
388
389 #[test]
390 fn rego_allowlist_membership() {
391 let module = "allowed := {\n \"tool.read_file\",\n \"tool.search\"\n}\ndefault decision := \"DENY\"\ndecision := \"ALLOW\" { input.action in allowed }";
392 assert_eq!(eval_rego_allowlist(module, "tool.read_file"), "ALLOW");
393 assert_eq!(eval_rego_allowlist(module, "tool.write_file"), "DENY");
394 }
395
396 #[test]
397 fn jsonlogic_rate_cap() {
398 let module = r#"{"and": [{"<=": [{"var": "agent_actions_last_minute"}, 60]}]}"#;
399 assert_eq!(
400 eval_jsonlogic(module, &json!({"agent_actions_last_minute": 1})),
401 "ALLOW"
402 );
403 assert_eq!(
404 eval_jsonlogic(module, &json!({"agent_actions_last_minute": 61})),
405 "DENY"
406 );
407 }
408}