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    /// How the agent and its person are told about a `deny` or `review`
113    /// (ADR-059), when the rule or OpenMoat's own check that decided it has a
114    /// title. Presentation only: the engine never sets it, no decision reads
115    /// it, and it is never serialised, so JSON output and the audit log stay
116    /// as they were.
117    #[serde(skip)]
118    pub notice: Option<Box<Notice>>,
119}
120
121/// The words of the block message for one decision (ADR-059): filled by the
122/// program that answers the host from the deciding rule's `title` and `why`
123/// and the call, already made safe to show (secrets masked, one line each).
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct Notice {
126    /// The deciding rule's title: "reading your SSH private key".
127    pub title: String,
128    /// The deciding rule's `why`, when it has one.
129    pub why: Option<String>,
130    /// The call as the person would describe it: a command, or a tool and
131    /// what it names (`Read ~/.ssh/id_ed25519`).
132    pub call: Option<String>,
133    /// The decision's reasons as they may be shown, in place of
134    /// [`Decision::reasons`] in the message.
135    pub detail: Option<String>,
136    /// The person's one step, where the deciding check has its own for this
137    /// verdict ("run `openmoat doctor` in your terminal to see what
138    /// changed"); `None` for the verdict's own step.
139    pub next: Option<String>,
140    /// Set where a `review` reaches a host that cannot put it to the person,
141    /// as a `deny`: why it cannot ("Codex hooks cannot prompt").
142    pub unasked: Option<String>,
143}
144
145impl Decision {
146    /// A decision with no rules yet.
147    #[must_use]
148    pub fn new(verdict: Verdict) -> Self {
149        Self {
150            verdict,
151            rules: Vec::new(),
152            reasons: Vec::new(),
153            context: Vec::new(),
154            notice: None,
155        }
156    }
157
158    /// A decision produced by one rule for one reason.
159    #[must_use]
160    pub fn single(verdict: Verdict, rule: &str, reason: impl Into<String>) -> Self {
161        let mut decision = Self::new(verdict);
162        decision.push(rule, reason.into());
163        decision
164    }
165
166    pub(crate) fn push(&mut self, rule: &str, reason: String) {
167        if !self.rules.iter().any(|r| r == rule) {
168            self.rules.push(rule.to_owned());
169        }
170        if !self.reasons.contains(&reason) {
171            self.reasons.push(reason);
172        }
173    }
174
175    /// Merge another decision in: strictest verdict wins; matches of the losing
176    /// side are demoted to `context`.
177    pub(crate) fn merge(&mut self, other: Decision) {
178        if other.verdict > self.verdict {
179            let demoted: Vec<String> = std::mem::take(&mut self.reasons);
180            self.rules.clear();
181            self.context.extend(demoted);
182            self.verdict = other.verdict;
183        } else if other.verdict < self.verdict {
184            self.context.extend(other.reasons);
185            self.context.extend(other.context);
186            return;
187        }
188        for (r, why) in other.rules.into_iter().zip(other.reasons) {
189            self.push(&r, why);
190        }
191        self.context.extend(other.context);
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{Decision, UnknownVerdict, Verdict};
198
199    #[test]
200    fn verdict_names_round_trip() {
201        for v in [
202            Verdict::Allow,
203            Verdict::Defer,
204            Verdict::Review,
205            Verdict::Deny,
206        ] {
207            assert_eq!(v.as_str().parse::<Verdict>(), Ok(v));
208        }
209        assert_eq!(
210            "block".parse::<Verdict>(),
211            Err(UnknownVerdict("block".into()))
212        );
213    }
214
215    /// ADR-053: `ask`, the old name, still reads as `review` from text and
216    /// JSON (policies, audit rows, SDK callers), and only `review` is written.
217    #[test]
218    fn the_old_name_ask_reads_as_review_and_is_never_written() {
219        assert_eq!("ask".parse::<Verdict>(), Ok(Verdict::Review));
220        assert_eq!(
221            serde_json::from_str::<Verdict>("\"ask\"").unwrap(),
222            Verdict::Review
223        );
224        assert_eq!(
225            serde_json::to_string(&Verdict::Review).unwrap(),
226            "\"review\""
227        );
228        assert_eq!(Verdict::Review.as_str(), "review");
229        let d: Decision =
230            serde_json::from_str(r#"{"verdict":"ask","rules":["installs"],"reasons":["r"]}"#)
231                .unwrap();
232        assert_eq!(d.verdict, Verdict::Review);
233    }
234
235    /// ADR-059: the notice is presentation only; JSON never carries it.
236    #[test]
237    fn a_notice_is_never_serialised() {
238        let mut d = Decision::single(Verdict::Deny, "secrets-paths", "secret material");
239        let plain = serde_json::to_string(&d).unwrap();
240        d.notice = Some(Box::new(super::Notice {
241            title: "reading your SSH private key".into(),
242            ..super::Notice::default()
243        }));
244        assert_eq!(serde_json::to_string(&d).unwrap(), plain);
245        let read: Decision = serde_json::from_str(&plain).unwrap();
246        assert_eq!(read.notice, None);
247    }
248
249    #[test]
250    fn single_carries_one_rule_and_reason() {
251        let d = Decision::single(Verdict::Deny, "kernel-error", "no lock");
252        assert_eq!(d.rules, ["kernel-error"]);
253        assert_eq!(d.reasons, ["no lock"]);
254        assert_eq!(d.context, Vec::<String>::new());
255    }
256
257    fn decided(verdict: Verdict, rule: &str) -> Decision {
258        let mut d = Decision::new(verdict);
259        d.push(rule, format!("because {rule}"));
260        d
261    }
262
263    #[test]
264    fn stricter_verdict_replaces_and_demotes_the_weaker_match() {
265        let mut d = decided(Verdict::Allow, "project-fs");
266        d.merge(decided(Verdict::Deny, "secrets-paths"));
267        assert_eq!(d.verdict, Verdict::Deny);
268        assert_eq!(d.rules, ["secrets-paths"]);
269        assert_eq!(d.reasons, ["because secrets-paths"]);
270        assert_eq!(d.context, ["because project-fs"]);
271    }
272
273    #[test]
274    fn weaker_verdict_only_adds_context() {
275        let mut d = decided(Verdict::Deny, "secrets-paths");
276        d.merge(decided(Verdict::Allow, "dev-shell"));
277        assert_eq!(d.verdict, Verdict::Deny);
278        assert_eq!(d.rules, ["secrets-paths"]);
279        assert_eq!(d.context, ["because dev-shell"]);
280    }
281
282    #[test]
283    fn equal_verdicts_union_rules_without_duplicates() {
284        let mut d = decided(Verdict::Review, "installs");
285        d.merge(decided(Verdict::Review, "installs"));
286        d.merge(decided(Verdict::Review, "push"));
287        assert_eq!(d.rules, ["installs", "push"]);
288        assert_eq!(d.reasons.len(), 2);
289        assert_eq!(d.context, Vec::<String>::new());
290    }
291
292    #[test]
293    fn verdict_order_is_allow_defer_review_deny() {
294        assert!(Verdict::Allow < Verdict::Defer);
295        assert!(Verdict::Defer < Verdict::Review && Verdict::Review < Verdict::Deny);
296    }
297}