1use serde::Deserialize;
13
14use crate::types::{Class, Decision, Mode, Verdict};
15
16#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
18pub struct Policy {
19 #[serde(default)]
21 pub mode: Option<Mode>,
22 #[serde(default)]
25 pub threshold: Option<u8>,
26 #[serde(default)]
28 pub rules: Rules,
29}
30
31pub const DEFAULT_THRESHOLD: u8 = 50;
33
34#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
36pub struct Rules {
37 #[serde(default)]
40 pub allow: Vec<String>,
41 #[serde(default)]
43 pub deny: Vec<String>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum PolicyAction {
49 None,
51 Allow,
53 Deny,
55}
56
57impl Policy {
58 pub fn parse(toml_str: &str) -> Result<Self, toml::de::Error> {
60 toml::from_str(toml_str)
61 }
62
63 pub fn merge(global: Policy, repo: Policy) -> Policy {
66 let mut allow = global.rules.allow;
67 allow.extend(repo.rules.allow);
68 let mut deny = global.rules.deny;
69 deny.extend(repo.rules.deny);
70 Policy {
71 mode: repo.mode.or(global.mode),
72 threshold: repo.threshold.or(global.threshold),
73 rules: Rules { allow, deny },
74 }
75 }
76
77 pub fn risk_threshold(&self) -> u8 {
79 self.threshold.unwrap_or(DEFAULT_THRESHOLD)
80 }
81
82 pub fn action_for(&self, command: &str) -> PolicyAction {
84 let cmd = command.trim();
85 if self.rules.deny.iter().any(|p| matches(p, cmd)) {
86 return PolicyAction::Deny;
87 }
88 if self.rules.allow.iter().any(|p| matches(p, cmd)) {
89 return PolicyAction::Allow;
90 }
91 PolicyAction::None
92 }
93}
94
95pub fn adjust_for_policy(mut verdict: Verdict, action: PolicyAction, mode: Mode) -> Verdict {
103 match action {
104 PolicyAction::None => verdict,
105 PolicyAction::Deny => {
106 match mode {
107 Mode::Attended => verdict.decision = Decision::Hold,
108 Mode::Unattended => verdict.decision = Decision::Deny,
109 Mode::Notify => {} }
111 verdict.reason = format!("policy:deny ({})", verdict.reason);
112 verdict
113 }
114 PolicyAction::Allow => {
115 if verdict.class == Class::Catastrophic {
116 verdict.reason = format!("policy:allow-ignored-catastrophic ({})", verdict.reason);
118 verdict
119 } else {
120 verdict.decision = Decision::Allow;
121 verdict.reason = format!("policy:allow ({})", verdict.reason);
122 verdict
123 }
124 }
125 }
126}
127
128pub fn matches(pattern: &str, command: &str) -> bool {
133 let pattern = pattern.trim();
134 let command = command.trim();
135 if pattern.is_empty() {
136 return false;
137 }
138 if pattern.contains('*') {
139 glob_match(pattern, command)
140 } else {
141 command == pattern || command.starts_with(&format!("{pattern} "))
142 }
143}
144
145fn glob_match(pattern: &str, text: &str) -> bool {
147 let parts: Vec<&str> = pattern.split('*').collect();
148 let anchored_start = !pattern.starts_with('*');
149 let anchored_end = !pattern.ends_with('*');
150
151 let mut pos = 0usize;
152 for (i, part) in parts.iter().enumerate() {
153 if part.is_empty() {
154 continue;
155 }
156 match text[pos..].find(part) {
157 Some(idx) => {
158 let abs = pos + idx;
159 if i == 0 && anchored_start && abs != 0 {
160 return false;
161 }
162 pos = abs + part.len();
163 }
164 None => return false,
165 }
166 }
167 if anchored_end {
168 if let Some(last) = parts.iter().rev().find(|p| !p.is_empty()) {
169 return text.ends_with(last);
170 }
171 }
172 true
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn parses_mode_and_rules() {
181 let p = Policy::parse(
182 r#"
183 mode = "notify"
184 [rules]
185 allow = ["cargo run"]
186 deny = ["git push *", "rm -rf"]
187 "#,
188 )
189 .unwrap();
190 assert_eq!(p.mode, Some(Mode::Notify));
191 assert_eq!(p.rules.allow, vec!["cargo run"]);
192 assert_eq!(p.rules.deny.len(), 2);
193 }
194
195 #[test]
196 fn empty_policy_parses() {
197 let p = Policy::parse("").unwrap();
198 assert_eq!(p, Policy::default());
199 }
200
201 #[test]
202 fn prefix_matching() {
203 assert!(matches("git push", "git push --force origin main"));
204 assert!(matches("git push", "git push"));
205 assert!(!matches("git push", "git pushing")); assert!(!matches("git push", "git status"));
207 }
208
209 #[test]
210 fn glob_matching() {
211 assert!(matches("rm *", "rm file.txt"));
212 assert!(matches("*secret*", "cat my-secret-file"));
213 assert!(matches("git * --force", "git push --force"));
214 assert!(!matches("git * --force", "git push origin"));
215 }
216
217 #[test]
218 fn deny_takes_precedence_over_allow() {
219 let p = Policy {
220 mode: None,
221 threshold: None,
222 rules: Rules {
223 allow: vec!["deploy".into()],
224 deny: vec!["deploy".into()],
225 },
226 };
227 assert_eq!(p.action_for("deploy now"), PolicyAction::Deny);
228 assert_eq!(p.risk_threshold(), DEFAULT_THRESHOLD);
229 }
230
231 #[test]
232 fn merge_repo_overrides_mode_and_extends_rules() {
233 let global = Policy::parse("mode = \"attended\"\n[rules]\nallow=[\"a\"]").unwrap();
234 let repo = Policy::parse("mode = \"notify\"\n[rules]\ndeny=[\"b\"]").unwrap();
235 let merged = Policy::merge(global, repo);
236 assert_eq!(merged.mode, Some(Mode::Notify));
237 assert_eq!(merged.rules.allow, vec!["a"]);
238 assert_eq!(merged.rules.deny, vec!["b"]);
239 }
240
241 #[test]
242 fn deny_escalates_safe_to_hold_in_attended() {
243 let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
244 let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Attended);
245 assert_eq!(adjusted.decision, Decision::Hold);
246 assert!(adjusted.reason.starts_with("policy:deny"));
247 }
248
249 #[test]
250 fn deny_in_notify_does_not_block() {
251 let v = Verdict::rules(Class::Safe, Decision::Allow, "safe:ls");
252 let adjusted = adjust_for_policy(v, PolicyAction::Deny, Mode::Notify);
253 assert_eq!(adjusted.decision, Decision::Allow);
254 }
255
256 #[test]
257 fn allow_never_downgrades_catastrophic() {
258 let v = Verdict::rules(Class::Catastrophic, Decision::Hold, "rm:recursive");
259 let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
260 assert_eq!(adjusted.decision, Decision::Hold, "hard floor must stand");
261 }
262
263 #[test]
264 fn allow_tames_ambiguous() {
265 let v = Verdict::rules(Class::Ambiguous, Decision::Hold, "ambiguous:make");
266 let adjusted = adjust_for_policy(v, PolicyAction::Allow, Mode::Attended);
267 assert_eq!(adjusted.decision, Decision::Allow);
268 }
269}