1use serde::{Deserialize, Serialize};
4use std::{collections::HashSet, default::Default};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(rename_all = "lowercase")]
9#[must_use = "execution policy decisions must be enforced"]
10pub enum Decision {
11 Allow,
13
14 #[default]
16 Prompt,
17
18 Forbidden,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct PrefixRule {
25 pub(crate) pattern: Vec<String>,
27
28 pub(crate) decision: Decision,
30}
31
32impl PrefixRule {
33 pub fn new(pattern: Vec<String>, decision: Decision) -> Self {
35 Self { pattern, decision }
36 }
37
38 #[must_use]
40 pub fn matches(&self, command: &[String]) -> bool {
41 matches_prefix(&self.pattern, command)
42 }
43}
44
45pub(super) fn matches_prefix(pattern: &[String], command: &[String]) -> bool {
47 !pattern.is_empty() && command.starts_with(pattern)
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum RuleMatch {
53 PrefixRuleMatch { rule: PrefixRule, decision: Decision },
55
56 HeuristicsRuleMatch { decision: Decision },
58}
59
60impl RuleMatch {
61 fn decision(&self) -> Decision {
63 match self {
64 Self::PrefixRuleMatch { decision, .. } => *decision,
65 Self::HeuristicsRuleMatch { decision } => *decision,
66 }
67 }
68
69 fn is_policy_match(&self) -> bool {
71 matches!(self, Self::PrefixRuleMatch { .. })
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct PolicyEvaluation {
78 decision: Decision,
80
81 matched_rules: Vec<RuleMatch>,
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct Policy {
88 prefix_rules: Vec<PrefixRule>,
90}
91
92impl Policy {
93 pub fn empty() -> Self {
95 Self { prefix_rules: Vec::new() }
96 }
97
98 pub fn add_prefix_rule(&mut self, pattern: &[String], decision: Decision) -> anyhow::Result<()> {
103 anyhow::ensure!(!pattern.is_empty(), "prefix pattern cannot be empty");
104 self.prefix_rules.push(PrefixRule::new(pattern.to_vec(), decision));
105 Ok(())
106 }
107
108 pub fn rules(&self) -> &[PrefixRule] {
110 &self.prefix_rules
111 }
112
113 pub fn prepend_layer(&mut self, rules: impl IntoIterator<Item = PrefixRule>) {
120 let rules: Vec<_> = rules.into_iter().collect();
121 if rules.is_empty() {
122 return;
123 }
124 let mut seen: HashSet<&[String]> = self.prefix_rules.iter().map(|rule| rule.pattern.as_slice()).collect();
126 let keep: Vec<_> = rules.iter().map(|rule| seen.insert(rule.pattern.as_slice())).collect();
127 drop(seen);
128 let new_rules: Vec<_> = rules
129 .into_iter()
130 .zip(keep)
131 .filter_map(|(rule, keep)| keep.then_some(rule))
132 .collect();
133 drop(self.prefix_rules.splice(0..0, new_rules));
134 }
135
136 pub fn check(&self, command: &[String]) -> RuleMatch {
138 for rule in &self.prefix_rules {
139 if rule.matches(command) {
140 return RuleMatch::PrefixRuleMatch { rule: rule.clone(), decision: rule.decision };
141 }
142 }
143
144 RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
146 }
147
148 pub fn check_multiple<I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
151 where
152 I: Iterator,
153 I::Item: AsRef<[String]>,
154 F: Fn(&[String]) -> Decision,
155 {
156 let mut matched_rules = Vec::new();
157 let mut overall_decision = Decision::Allow;
158
159 for command in commands {
160 let command = command.as_ref();
161 let rule_match = self.check(command);
162
163 let decision = match &rule_match {
165 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
166 RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
167 };
168
169 overall_decision = match (overall_decision, decision) {
171 (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
172 (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
173 (Decision::Allow, Decision::Allow) => Decision::Allow,
174 };
175
176 matched_rules.push(rule_match);
177 }
178
179 PolicyEvaluation { decision: overall_decision, matched_rules }
180 }
181
182 pub fn prefix_rules(&self) -> &[PrefixRule] {
184 &self.prefix_rules
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn empty_prefix_never_matches() {
194 let rule = PrefixRule::new(Vec::new(), Decision::Allow);
195 assert!(!rule.matches(&[]));
196 assert!(!rule.matches(&["untrusted".into()]));
197 }
198
199 #[test]
200 fn empty_deserialized_rule_and_layer_cannot_match() {
201 let rule: PrefixRule = serde_json::from_str(r#"{"pattern":[],"decision":"allow"}"#).unwrap();
202 let mut policy = Policy::empty();
203 policy.prepend_layer([rule]);
204 for command in [vec![], vec!["untrusted".into()]] {
205 assert_eq!(policy.check(&command), RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt });
206 }
207 }
208
209 #[test]
210 fn rejected_insertion_preserves_rules() {
211 let mut policy = Policy::empty();
212 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
213 let before = policy.rules().to_vec();
214 for decision in [Decision::Allow, Decision::Prompt, Decision::Forbidden] {
215 assert!(policy.add_prefix_rule(&[], decision).is_err());
216 assert_eq!(policy.rules(), before);
217 }
218 }
219
220 #[test]
221 fn layer_deduplication_matches_pairwise_reference() {
222 let choices = [
223 PrefixRule::new(vec!["a".into()], Decision::Allow),
224 PrefixRule::new(vec!["b".into()], Decision::Prompt),
225 PrefixRule::new(vec!["a".into()], Decision::Forbidden),
226 PrefixRule::new(vec!["a".into(), "b".into()], Decision::Forbidden),
227 PrefixRule::new(vec![], Decision::Allow),
228 ];
229 for existing in &choices {
230 for first in &choices {
231 for second in &choices {
232 let mut policy = Policy { prefix_rules: vec![existing.clone()] };
233 let incoming = [first.clone(), second.clone(), first.clone()];
234 let mut expected = Vec::<PrefixRule>::new();
235 for rule in &incoming {
236 if !expected.iter().chain(policy.rules()).any(|prior| prior.pattern == rule.pattern) {
237 expected.push(rule.clone());
238 }
239 }
240 expected.push(existing.clone());
241 policy.prepend_layer(incoming);
242 assert_eq!(policy.rules(), expected);
243 for command in [vec![], vec!["a".into()], vec!["b".into()], vec!["a".into(), "b".into()]] {
244 let reference = Policy { prefix_rules: expected.clone() };
245 assert_eq!(policy.check(&command), reference.check(&command));
246 }
247 }
248 }
249 }
250 }
251
252 #[test]
253 fn layer_deduplication_preserves_order_and_existing_duplicates() {
254 let mut policy = Policy::empty();
255 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
256 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
257 let existing = policy.rules().to_vec();
258 let first = PrefixRule::new(vec!["cargo".into()], Decision::Allow);
259 policy.prepend_layer([
260 first.clone(),
261 PrefixRule::new(vec!["git".into()], Decision::Allow),
262 PrefixRule::new(vec!["cargo".into()], Decision::Forbidden),
263 PrefixRule::new(vec!["cargo".into(), "build".into()], Decision::Forbidden),
264 ]);
265 assert_eq!(policy.rules().len(), 4);
266 assert_eq!(policy.rules()[0], first);
267 assert_eq!(&policy.rules()[2..], existing);
268 assert_eq!(policy.check(&["cargo".into(), "build".into()]).decision(), Decision::Allow);
269 assert_eq!(policy.check(&["git".into()]).decision(), Decision::Prompt);
270 let before = policy.rules().to_vec();
271 policy.prepend_layer([]);
272 assert_eq!(policy.rules(), before);
273 }
274
275 #[test]
276 fn nonempty_matching_agrees_with_prefix_oracle() {
277 let mut vectors = vec![Vec::new()];
279 for _ in 0..3 {
280 let previous = vectors.clone();
281 for prefix in previous {
282 for token in ["git", "push", ""] {
283 let mut next = prefix.clone();
284 next.push(token.to_string());
285 vectors.push(next);
286 }
287 }
288 }
289 for pattern in &vectors {
290 let rule = PrefixRule::new(pattern.clone(), Decision::Allow);
291 for command in &vectors {
292 let expected = !pattern.is_empty() && command.get(..pattern.len()) == Some(pattern.as_slice());
293 assert_eq!(rule.matches(command), expected, "{pattern:?} vs {command:?}");
294 }
295 }
296 }
297
298 #[test]
299 fn borrowed_commands_preserve_rule_precedence_and_fallback_arguments() {
300 let mut policy = Policy::empty();
301 let first = PrefixRule::new(vec!["git".into()], Decision::Prompt);
302 policy.add_prefix_rule(&first.pattern, first.decision).unwrap();
303 policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
304 let known = ["git".to_owned(), "push".to_owned()];
305 let unknown = ["custom".to_owned(), "--dangerous".to_owned()];
306 let fallback_calls = std::cell::Cell::new(0);
307 let evaluation = policy.check_multiple([known.as_slice(), unknown.as_slice()].into_iter(), &|command| {
308 assert!(std::ptr::eq(command, unknown.as_slice()), "heuristics must borrow the original argv");
309 fallback_calls.set(fallback_calls.get() + 1);
310 Decision::Forbidden
311 });
312 assert_eq!(fallback_calls.get(), 1);
313 assert_eq!(evaluation.decision, Decision::Forbidden);
314 assert_eq!(
315 evaluation.matched_rules,
316 vec![
317 RuleMatch::PrefixRuleMatch { rule: first, decision: Decision::Prompt },
318 RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt },
319 ]
320 );
321 let empty = policy.check_multiple(std::iter::empty::<&[String]>(), &|_| panic!("no commands"));
322 assert_eq!(empty.decision, Decision::Allow);
323 assert!(empty.matched_rules.is_empty());
324 }
325
326 #[test]
327 fn first_match_and_restrictive_aggregation_are_preserved() {
328 let mut policy = Policy::empty();
329 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
330 policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
331 policy.add_prefix_rule(&["deny".into()], Decision::Forbidden).unwrap();
332 policy.add_prefix_rule(&["allow".into()], Decision::Allow).unwrap();
333 let mut commands = vec![vec!["allow".into()]];
334 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Allow);
335 commands.push(vec!["git".into(), "push".into()]);
336 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Prompt);
337 commands.push(vec!["deny".into()]);
338 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Forbidden);
339 let unknown = [vec!["unknown".into()]];
340 assert_eq!(policy.check_multiple(unknown.iter(), &|_| Decision::Forbidden).decision, Decision::Forbidden);
341 }
342
343 #[test]
344 fn test_prefix_rule_matching() {
345 let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
346
347 assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
348 assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
349 assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
350 assert!(!rule.matches(&["cargo".to_string()]));
351 }
352
353 #[test]
354 fn test_policy_check() {
355 let mut policy = Policy::empty();
356 policy
357 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
358 .unwrap();
359 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
360
361 let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
362 assert_eq!(allow.decision(), Decision::Allow);
363 assert!(allow.is_policy_match());
364
365 let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
366 assert_eq!(forbidden.decision(), Decision::Forbidden);
367
368 let heuristics = policy.check(&["unknown".to_string()]);
369 assert!(!heuristics.is_policy_match());
370 }
371
372 #[test]
373 fn test_policy_evaluation() {
374 let mut policy = Policy::empty();
375 policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
376 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
377
378 let commands = [
379 vec!["echo".to_string(), "hello".to_string()],
380 vec!["rm".to_string(), "-rf".to_string()],
381 ];
382
383 let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
384
385 assert_eq!(evaluation.decision, Decision::Forbidden);
387 }
388}