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