Skip to main content

rippy_cli/
verdict.rs

1use crate::mode::Mode;
2
3/// The three possible safety decisions, ordered so `max()` gives the most restrictive.
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
5pub enum Decision {
6    Allow,
7    Ask,
8    Deny,
9}
10
11/// A decision paired with a human-readable reason.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Verdict {
14    pub decision: Decision,
15    pub reason: String,
16    /// The fully-resolved command (after expansion of `$VAR`, `$'...'`, `$((...))`,
17    /// etc.) when the analyzer was able to statically resolve all expansions.
18    /// `None` when no resolution occurred or it failed.
19    pub resolved_command: Option<String>,
20}
21
22impl Verdict {
23    #[must_use]
24    pub fn allow(reason: impl Into<String>) -> Self {
25        Self {
26            decision: Decision::Allow,
27            reason: reason.into(),
28            resolved_command: None,
29        }
30    }
31
32    #[must_use]
33    pub fn ask(reason: impl Into<String>) -> Self {
34        Self {
35            decision: Decision::Ask,
36            reason: reason.into(),
37            resolved_command: None,
38        }
39    }
40
41    #[must_use]
42    pub fn deny(reason: impl Into<String>) -> Self {
43        Self {
44            decision: Decision::Deny,
45            reason: reason.into(),
46            resolved_command: None,
47        }
48    }
49
50    /// Attach a resolved command form to this verdict for transparency.
51    #[must_use]
52    pub fn with_resolution(mut self, resolved: impl Into<String>) -> Self {
53        self.resolved_command = Some(resolved.into());
54        self
55    }
56
57    /// Combine multiple verdicts, keeping the most restrictive decision
58    /// and the reason from whichever verdict drove that decision.
59    ///
60    /// The resolved command is preserved from the chosen verdict, or from any
61    /// other verdict in the input if the chosen one has none — so resolution
62    /// info is never accidentally dropped during combination.
63    #[must_use]
64    pub fn combine(verdicts: &[Self]) -> Self {
65        let mut chosen = verdicts
66            .iter()
67            .max_by_key(|v| v.decision)
68            .cloned()
69            .unwrap_or_default();
70        if chosen.resolved_command.is_none() {
71            chosen.resolved_command = verdicts.iter().find_map(|v| v.resolved_command.clone());
72        }
73        chosen
74    }
75
76    /// Serialize this verdict as JSON for the given AI tool mode.
77    #[must_use]
78    pub fn to_json(&self, mode: Mode) -> serde_json::Value {
79        match mode {
80            Mode::Claude => serde_json::json!({
81                "hookSpecificOutput": {
82                    "permissionDecision": self.decision.as_str(),
83                    "permissionDecisionReason": self.reason,
84                }
85            }),
86            Mode::Gemini | Mode::Codex => serde_json::json!({
87                "decision": self.decision.as_gemini_str(),
88                "reason": self.reason,
89            }),
90            Mode::Cursor => serde_json::json!({
91                "permission": self.decision.as_str(),
92                "userMessage": self.reason,
93                "agentMessage": self.reason,
94            }),
95        }
96    }
97}
98
99impl Default for Verdict {
100    fn default() -> Self {
101        Self {
102            decision: Decision::Allow,
103            reason: String::new(),
104            resolved_command: None,
105        }
106    }
107}
108
109impl Decision {
110    pub const fn as_str(self) -> &'static str {
111        match self {
112            Self::Allow => "allow",
113            Self::Ask => "ask",
114            Self::Deny => "deny",
115        }
116    }
117
118    /// Gemini has no "ask" concept — map Ask to "deny".
119    const fn as_gemini_str(self) -> &'static str {
120        match self {
121            Self::Allow => "allow",
122            Self::Ask | Self::Deny => "deny",
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[allow(clippy::unwrap_used)]
132    #[test]
133    fn decision_ordering() {
134        assert!(Decision::Allow < Decision::Ask);
135        assert!(Decision::Ask < Decision::Deny);
136        assert!(Decision::Allow < Decision::Deny);
137    }
138
139    #[allow(clippy::unwrap_used)]
140    #[test]
141    fn combine_takes_most_restrictive() {
142        let verdicts = vec![
143            Verdict::allow("safe"),
144            Verdict::ask("needs review"),
145            Verdict::allow("also safe"),
146        ];
147        let combined = Verdict::combine(&verdicts);
148        assert_eq!(combined.decision, Decision::Ask);
149        assert_eq!(combined.reason, "needs review");
150    }
151
152    #[allow(clippy::unwrap_used)]
153    #[test]
154    fn combine_empty_defaults_to_allow() {
155        let combined = Verdict::combine(&[]);
156        assert_eq!(combined.decision, Decision::Allow);
157    }
158
159    #[allow(clippy::unwrap_used)]
160    #[test]
161    fn claude_json_format() {
162        let v = Verdict::allow("git status is safe");
163        let json = v.to_json(Mode::Claude);
164        assert_eq!(json["hookSpecificOutput"]["permissionDecision"], "allow");
165        assert_eq!(
166            json["hookSpecificOutput"]["permissionDecisionReason"],
167            "git status is safe"
168        );
169    }
170
171    #[allow(clippy::unwrap_used)]
172    #[test]
173    fn gemini_ask_maps_to_deny() {
174        let v = Verdict::ask("needs review");
175        let json = v.to_json(Mode::Gemini);
176        assert_eq!(json["decision"], "deny");
177    }
178
179    #[allow(clippy::unwrap_used)]
180    #[test]
181    fn cursor_json_format() {
182        let v = Verdict::deny("dangerous");
183        let json = v.to_json(Mode::Cursor);
184        assert_eq!(json["permission"], "deny");
185        assert_eq!(json["userMessage"], "dangerous");
186        assert_eq!(json["agentMessage"], "dangerous");
187    }
188
189    #[test]
190    fn with_resolution_attaches_resolved_command() {
191        let v = Verdict::allow("ls is safe").with_resolution("ls /tmp");
192        assert_eq!(v.resolved_command.as_deref(), Some("ls /tmp"));
193        assert_eq!(v.decision, Decision::Allow);
194    }
195
196    #[test]
197    fn combine_preserves_resolved_command_from_chosen() {
198        let verdicts = vec![
199            Verdict::allow("safe"),
200            Verdict::ask("review").with_resolution("rm -rf /tmp"),
201        ];
202        let combined = Verdict::combine(&verdicts);
203        assert_eq!(combined.decision, Decision::Ask);
204        assert_eq!(combined.resolved_command.as_deref(), Some("rm -rf /tmp"));
205    }
206
207    #[test]
208    fn combine_borrows_resolved_command_from_other_when_chosen_has_none() {
209        let verdicts = vec![
210            Verdict::ask("review"),
211            Verdict::allow("safe").with_resolution("ls /tmp"),
212        ];
213        let combined = Verdict::combine(&verdicts);
214        assert_eq!(combined.decision, Decision::Ask);
215        assert_eq!(combined.resolved_command.as_deref(), Some("ls /tmp"));
216    }
217
218    #[test]
219    fn json_output_unchanged_when_resolved_present() {
220        // resolved_command is internal-only, not part of any wire format
221        let v = Verdict::allow("ls is safe").with_resolution("ls /tmp");
222        let json = v.to_json(Mode::Claude);
223        assert!(json.get("resolved_command").is_none());
224        assert!(json["hookSpecificOutput"].get("resolved_command").is_none());
225    }
226}