Skip to main content

openmoat_core/
verdict.rs

1use std::str::FromStr;
2
3use serde::{Deserialize, Serialize};
4
5/// Outcome of evaluating one or more atomic actions.
6///
7/// Ordering is by strictness: `Allow < Ask < Deny`. When several atomic
8/// actions are combined, the strictest wins (docs/POLICY.md ยง4).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Verdict {
12    /// The action may run.
13    Allow,
14    /// A person must decide; the host prompts or blocks.
15    Ask,
16    /// The action is blocked.
17    Deny,
18}
19
20impl Verdict {
21    /// The lowercase name used in hook responses, the audit log and output.
22    #[must_use]
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Allow => "allow",
26            Self::Ask => "ask",
27            Self::Deny => "deny",
28        }
29    }
30}
31
32/// A name that is not `allow`, `ask` or `deny`.
33#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34#[error("unknown verdict `{0}`")]
35pub struct UnknownVerdict(pub String);
36
37impl FromStr for Verdict {
38    type Err = UnknownVerdict;
39
40    fn from_str(s: &str) -> Result<Self, Self::Err> {
41        [Self::Allow, Self::Ask, Self::Deny]
42            .into_iter()
43            .find(|v| v.as_str() == s)
44            .ok_or_else(|| UnknownVerdict(s.to_owned()))
45    }
46}
47
48/// A verdict with the rule ids and human-readable reasons that produced it.
49///
50/// `rules`/`reasons` describe only the matches that carry the **final** verdict.
51/// Matches with a weaker verdict (e.g. an `allow` on `git status` inside a command
52/// that was denied for touching `~/.ssh`) are kept in `context` so the user still
53/// sees the full picture without the headline being noisy.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct Decision {
56    /// The strictest verdict across the matched rules.
57    pub verdict: Verdict,
58    /// Ids of the rule groups that produced `verdict`, in evaluation order, de-duplicated.
59    pub rules: Vec<String>,
60    /// One line per rule in `rules`.
61    pub reasons: Vec<String>,
62    /// Matches that did not determine the verdict (weaker outcomes), for display/audit.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub context: Vec<String>,
65}
66
67impl Decision {
68    /// A decision with no rules yet.
69    #[must_use]
70    pub fn new(verdict: Verdict) -> Self {
71        Self {
72            verdict,
73            rules: Vec::new(),
74            reasons: Vec::new(),
75            context: Vec::new(),
76        }
77    }
78
79    /// A decision produced by one rule for one reason.
80    #[must_use]
81    pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
82        let mut decision = Self::new(verdict);
83        decision.push(rule, reason.into());
84        decision
85    }
86
87    pub(crate) fn push(&mut self, rule: &str, reason: String) {
88        if !self.rules.iter().any(|r| r == rule) {
89            self.rules.push(rule.to_owned());
90        }
91        if !self.reasons.contains(&reason) {
92            self.reasons.push(reason);
93        }
94    }
95
96    /// Merge another decision in: strictest verdict wins; matches of the losing
97    /// side are demoted to `context`.
98    pub(crate) fn merge(&mut self, other: Decision) {
99        if other.verdict > self.verdict {
100            let demoted: Vec<String> = self.reasons.drain(..).collect();
101            self.rules.clear();
102            self.context.extend(demoted);
103            self.verdict = other.verdict;
104        } else if other.verdict < self.verdict {
105            self.context.extend(other.reasons);
106            self.context.extend(other.context);
107            return;
108        }
109        for (r, why) in other.rules.into_iter().zip(other.reasons) {
110            self.push(&r, why);
111        }
112        self.context.extend(other.context);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{Decision, UnknownVerdict, Verdict};
119
120    #[test]
121    fn verdict_names_round_trip() {
122        for v in [Verdict::Allow, Verdict::Ask, Verdict::Deny] {
123            assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
124        }
125        assert_eq!(
126            "block".parse::<Verdict>(),
127            Err(UnknownVerdict("block".into()))
128        );
129    }
130
131    #[test]
132    fn single_carries_one_rule_and_reason() {
133        let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
134        assert_eq!(d.rules, ["kernel-error"]);
135        assert_eq!(d.reasons, ["no lock"]);
136        assert!(d.context.is_empty());
137    }
138
139    fn decided(verdict: Verdict, rule: &str) -> Decision {
140        let mut d = Decision::new(verdict);
141        d.push(rule, format!("because {rule}"));
142        d
143    }
144
145    #[test]
146    fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
147        let mut d = decided(Verdict::Allow, "project-fs");
148        d.merge(decided(Verdict::Deny, "secrets-paths"));
149        assert_eq!(d.verdict, Verdict::Deny);
150        assert_eq!(d.rules, ["secrets-paths"]);
151        assert_eq!(d.reasons, ["because secrets-paths"]);
152        assert_eq!(d.context, ["because project-fs"]);
153    }
154
155    #[test]
156    fn weaker_verdict_only_adds_context() {
157        let mut d = decided(Verdict::Deny, "secrets-paths");
158        d.merge(decided(Verdict::Allow, "dev-shell"));
159        assert_eq!(d.verdict, Verdict::Deny);
160        assert_eq!(d.rules, ["secrets-paths"]);
161        assert_eq!(d.context, ["because dev-shell"]);
162    }
163
164    #[test]
165    fn equal_verdicts_union_rules_without_duplicates() {
166        let mut d = decided(Verdict::Ask, "installs");
167        d.merge(decided(Verdict::Ask, "installs"));
168        d.merge(decided(Verdict::Ask, "push"));
169        assert_eq!(d.rules, ["installs", "push"]);
170        assert_eq!(d.reasons.len(), 2);
171        assert!(d.context.is_empty());
172    }
173
174    #[test]
175    fn verdict_order_is_allow_ask_deny() {
176        assert!(Verdict::Allow < Verdict::Ask && Verdict::Ask < Verdict::Deny);
177    }
178}