Skip to main content

openmoat_core/
verdict.rs

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