Skip to main content

openmoat_core/
verdict.rs

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