Skip to main content

harness_core/
eval.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Rule + pack evaluator.
3//!
4//! Direct port of the Python reference:
5//!   `_get_dotpath`, `_match_action`, `_eval_rego_subset`, `_eval_rego_allowlist`,
6//!   `_eval_jsonlogic`, `evaluate_rule`, `evaluate_packs`.
7//!
8//! Semantics are documented in `policy-pack-format.md` section "Evaluation
9//! semantics": any DENY wins, else WARN, else ALLOW.
10
11use 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/// Aggregate result returned by `evaluate_packs`.
35#[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
44/// Walk a dot-path through nested JSON objects.
45///
46/// Ports `_get_dotpath`. Returns `None` if any intermediate is not an object,
47/// or if a final key is absent.
48pub 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
57/// Does the rule's `match.action` cover the inbound action?
58///
59/// Ports `_match_action`. Accepts a string ("*", glob "tool.*", or exact match)
60/// OR a list of strings (any-match).
61pub 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                // Python: action.startswith(rule_match_action[:-1])  i.e. include the dot.
69                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
78/// Minimal Rego subset — supports the SINGLE pattern shipped in the example
79/// PII pack:
80///
81/// ```rego
82/// default decision := "ALLOW"
83/// decision := "DENY" { re_match(`<regex>`, input.value) }
84/// ```
85///
86/// Anything more complex falls through to the default (fail-open in the
87/// reference, fail-closed in prod). Ports `_eval_rego_subset`.
88pub 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        // Python pattern uses backticks to delimit the regex literal:
97        //   decision := "DENY" { re_match(`<regex>`, input.value) }
98        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
127/// Rego allowlist pattern (used by `tool-allowlist.json`).
128///
129/// Pattern:
130///
131/// ```rego
132/// allowed := { "tool.read_file", ... }
133/// default decision := "DENY"
134/// decision := "ALLOW" { input.action in allowed }
135/// ```
136///
137/// Ports `_eval_rego_allowlist`. If the module doesn't look like an allowlist
138/// (no `input.action in allowed` literal), falls back to `eval_rego_subset`.
139pub 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    // (?s) = dotall so `.` matches newlines, mirroring Python's re.DOTALL.
147    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    // We need owned strings to outlive the borrow; clone into Vec<String>.
163    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
181/// Minimal JSONLogic — supports the SINGLE pattern shipped in `rate-limit.json`:
182///
183/// ```json
184/// {"and": [{"<=": [{"var": "X"}, N]}]}
185/// ```
186///
187/// Ports `_eval_jsonlogic`. Anything else falls through to ALLOW.
188pub 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
223/// Evaluate a single rule. Returns `(decision, deny_reason)`.
224///
225/// Ports `evaluate_rule`. `decision` is "ALLOW" / "WARN" / "DENY".
226pub 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                    // Python: value = _get_dotpath(action_payload, field) or ""
264                    // then coerce non-strings via json.dumps with compact separators.
265                    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            // Python merges action_payload with daemon_state via dict-spread.
281            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
311/// Run an action through every loaded pack in pack-load order.
312///
313/// Aggregate semantics (per `policy-pack-format.md`): any DENY short-circuits +
314/// wins; else any WARN; else ALLOW.
315pub 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}