Skip to main content

openmoat_core/
verdict.rs

1//! The outcome of a policy decision.
2//!
3//! [`Verdict`] is `allow`, `defer`, `review` or `deny`, ordered by strictness.
4//! `review` was called `ask` before ADR-053; that name is still read, never written.
5//! [`Decision`] is a verdict plus the rule ids and reasons that produced it.
6//! When the atoms of one action are combined, the strictest verdict wins and
7//! the weaker matches are kept as `context`, not dropped (docs/POLICY.md §4).
8
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13/// Outcome of evaluating one or more atomic actions.
14///
15/// Ordering is by strictness: `Allow < Defer < Review < Deny`. When several
16/// atomic actions are combined, the strictest wins (docs/POLICY.md §4).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19#[non_exhaustive]
20pub enum Verdict {
21    /// The action may run.
22    Allow,
23    /// A person must decide; the host prompts or blocks. Read from `ask`, its
24    /// name before ADR-053, in policies, audit rows and SDK input.
25    #[serde(alias = "ask")]
26    Review,
27    /// The action is blocked.
28    Deny,
29    /// No objection and no approval: the agent's own permission flow decides
30    /// (ADR-025). Declared last so the other variants keep their discriminants.
31    Defer,
32}
33
34impl Verdict {
35    /// Position in the strictness order.
36    fn strictness(self) -> u8 {
37        match self {
38            Self::Allow => 0,
39            Self::Defer => 1,
40            Self::Review => 2,
41            Self::Deny => 3,
42        }
43    }
44}
45
46impl PartialOrd for Verdict {
47    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
48        Some(self.cmp(other))
49    }
50}
51
52impl Ord for Verdict {
53    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
54        self.strictness().cmp(&other.strictness())
55    }
56}
57
58impl Verdict {
59    /// The lowercase name used in the audit log, output and the SDK. Host hook
60    /// responses spell `review` as each host's protocol does (`openmoat-hosts`).
61    #[must_use]
62    pub fn as_str(self) -> &'static str {
63        match self {
64            Self::Allow => "allow",
65            Self::Review => "review",
66            Self::Deny => "deny",
67            Self::Defer => "defer",
68        }
69    }
70}
71
72/// The name `review` had before ADR-053. Audit rows, policies and SDK callers
73/// written before then use it; it is read as `review` and never written.
74pub const OLD_REVIEW: &str = "ask";
75
76/// A name that is not `allow`, `defer`, `review` (or its old name `ask`) or `deny`.
77#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78#[error("unknown verdict `{0}`")]
79pub struct UnknownVerdict(pub String);
80
81impl FromStr for Verdict {
82    type Err = UnknownVerdict;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        if s == OLD_REVIEW {
86            return Ok(Self::Review);
87        }
88        [Self::Allow, Self::Defer, Self::Review, Self::Deny]
89            .into_iter()
90            .find(|v| v.as_str() == s)
91            .ok_or_else(|| UnknownVerdict(s.to_owned()))
92    }
93}
94
95/// A verdict with the rule ids and human-readable reasons that produced it.
96///
97/// `rules`/`reasons` describe only the matches that carry the **final** verdict.
98/// Matches with a weaker verdict (e.g. an `allow` on `git status` inside a command
99/// that was denied for touching `~/.ssh`) are kept in `context` so the user still
100/// sees the full picture without the headline being noisy.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct Decision {
103    /// The strictest verdict across the matched rules.
104    pub verdict: Verdict,
105    /// Ids of the rule groups that produced `verdict`, in evaluation order, de-duplicated.
106    pub rules: Vec<String>,
107    /// One line per rule in `rules`.
108    pub reasons: Vec<String>,
109    /// Matches that did not determine the verdict (weaker outcomes), for display/audit.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub context: Vec<String>,
112}
113
114impl Decision {
115    /// A decision with no rules yet.
116    #[must_use]
117    pub fn new(verdict: Verdict) -> Self {
118        Self {
119            verdict,
120            rules: Vec::new(),
121            reasons: Vec::new(),
122            context: Vec::new(),
123        }
124    }
125
126    /// A decision produced by one rule for one reason.
127    #[must_use]
128    pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
129        let mut decision = Self::new(verdict);
130        decision.push(rule, reason.into());
131        decision
132    }
133
134    pub(crate) fn push(&mut self, rule: &str, reason: String) {
135        if !self.rules.iter().any(|r| r == rule) {
136            self.rules.push(rule.to_owned());
137        }
138        if !self.reasons.contains(&reason) {
139            self.reasons.push(reason);
140        }
141    }
142
143    /// Merge another decision in: strictest verdict wins; matches of the losing
144    /// side are demoted to `context`.
145    pub(crate) fn merge(&mut self, other: Decision) {
146        if other.verdict > self.verdict {
147            let demoted: Vec<String> = std::mem::take(&mut self.reasons);
148            self.rules.clear();
149            self.context.extend(demoted);
150            self.verdict = other.verdict;
151        } else if other.verdict < self.verdict {
152            self.context.extend(other.reasons);
153            self.context.extend(other.context);
154            return;
155        }
156        for (r, why) in other.rules.into_iter().zip(other.reasons) {
157            self.push(&r, why);
158        }
159        self.context.extend(other.context);
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::{Decision, UnknownVerdict, Verdict};
166
167    #[test]
168    fn verdict_names_round_trip() {
169        for v in [
170            Verdict::Allow,
171            Verdict::Defer,
172            Verdict::Review,
173            Verdict::Deny,
174        ] {
175            assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
176        }
177        assert_eq!(
178            "block".parse::<Verdict>(),
179            Err(UnknownVerdict("block".into()))
180        );
181    }
182
183    /// ADR-053: `ask`, the old name, still reads as `review` from text and
184    /// JSON (policies, audit rows, SDK callers), and only `review` is written.
185    #[test]
186    fn the_old_name_ask_reads_as_review_and_is_never_written() {
187        assert_eq!("ask".parse::<Verdict>(), Ok(Verdict::Review));
188        assert_eq!(
189            serde_json::from_str::<Verdict>("\"ask\"").unwrap(),
190            Verdict::Review
191        );
192        assert_eq!(
193            serde_json::to_string(&Verdict::Review).unwrap(),
194            "\"review\""
195        );
196        assert_eq!(Verdict::Review.as_str(), "review");
197        let d: Decision =
198            serde_json::from_str(r#"{"verdict":"ask","rules":["installs"],"reasons":["r"]}"#)
199                .unwrap();
200        assert_eq!(d.verdict, Verdict::Review);
201    }
202
203    #[test]
204    fn single_carries_one_rule_and_reason() {
205        let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
206        assert_eq!(d.rules, ["kernel-error"]);
207        assert_eq!(d.reasons, ["no lock"]);
208        assert_eq!(d.context, Vec::<String>::new());
209    }
210
211    fn decided(verdict: Verdict, rule: &str) -> Decision {
212        let mut d = Decision::new(verdict);
213        d.push(rule, format!("because {rule}"));
214        d
215    }
216
217    #[test]
218    fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
219        let mut d = decided(Verdict::Allow, "project-fs");
220        d.merge(decided(Verdict::Deny, "secrets-paths"));
221        assert_eq!(d.verdict, Verdict::Deny);
222        assert_eq!(d.rules, ["secrets-paths"]);
223        assert_eq!(d.reasons, ["because secrets-paths"]);
224        assert_eq!(d.context, ["because project-fs"]);
225    }
226
227    #[test]
228    fn weaker_verdict_only_adds_context() {
229        let mut d = decided(Verdict::Deny, "secrets-paths");
230        d.merge(decided(Verdict::Allow, "dev-shell"));
231        assert_eq!(d.verdict, Verdict::Deny);
232        assert_eq!(d.rules, ["secrets-paths"]);
233        assert_eq!(d.context, ["because dev-shell"]);
234    }
235
236    #[test]
237    fn equal_verdicts_union_rules_without_duplicates() {
238        let mut d = decided(Verdict::Review, "installs");
239        d.merge(decided(Verdict::Review, "installs"));
240        d.merge(decided(Verdict::Review, "push"));
241        assert_eq!(d.rules, ["installs", "push"]);
242        assert_eq!(d.reasons.len(), 2);
243        assert_eq!(d.context, Vec::<String>::new());
244    }
245
246    #[test]
247    fn verdict_order_is_allow_defer_review_deny() {
248        assert!(Verdict::Allow < Verdict::Defer);
249        assert!(Verdict::Defer < Verdict::Review && Verdict::Review < Verdict::Deny);
250    }
251}