vtcode_safety/exec_policy/
policy.rs1use serde::{Deserialize, Serialize};
4use std::default::Default;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(rename_all = "lowercase")]
9pub enum Decision {
10 Allow,
12
13 #[default]
15 Prompt,
16
17 Forbidden,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct PrefixRule {
24 pub(crate) pattern: Vec<String>,
26
27 pub(crate) decision: Decision,
29}
30
31impl PrefixRule {
32 pub fn new(pattern: Vec<String>, decision: Decision) -> Self {
34 Self { pattern, decision }
35 }
36
37 pub fn matches(&self, command: &[String]) -> bool {
39 if command.len() < self.pattern.len() {
40 return false;
41 }
42 self.pattern.iter().zip(command.iter()).all(|(pattern, cmd)| pattern == cmd)
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RuleMatch {
49 PrefixRuleMatch { rule: PrefixRule, decision: Decision },
51
52 HeuristicsRuleMatch { decision: Decision },
54}
55
56impl RuleMatch {
57 fn decision(&self) -> Decision {
59 match self {
60 Self::PrefixRuleMatch { decision, .. } => *decision,
61 Self::HeuristicsRuleMatch { decision } => *decision,
62 }
63 }
64
65 fn is_policy_match(&self) -> bool {
67 matches!(self, Self::PrefixRuleMatch { .. })
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct PolicyEvaluation {
74 decision: Decision,
76
77 matched_rules: Vec<RuleMatch>,
79}
80
81#[derive(Debug, Clone, Default)]
83pub struct Policy {
84 prefix_rules: Vec<PrefixRule>,
86}
87
88impl Policy {
89 pub fn empty() -> Self {
91 Self { prefix_rules: Vec::new() }
92 }
93
94 pub fn add_prefix_rule(&mut self, pattern: &[String], decision: Decision) -> anyhow::Result<()> {
96 self.prefix_rules.push(PrefixRule::new(pattern.to_vec(), decision));
97 Ok(())
98 }
99
100 pub fn check(&self, command: &[String]) -> RuleMatch {
102 for rule in &self.prefix_rules {
103 if rule.matches(command) {
104 return RuleMatch::PrefixRuleMatch { rule: rule.clone(), decision: rule.decision };
105 }
106 }
107
108 RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
110 }
111
112 pub fn check_multiple<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
114 where
115 I: Iterator<Item = &'a Vec<String>>,
116 F: Fn(&[String]) -> Decision,
117 {
118 let mut matched_rules = Vec::new();
119 let mut overall_decision = Decision::Allow;
120
121 for command in commands {
122 let rule_match = self.check(command);
123
124 let decision = match &rule_match {
126 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
127 RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
128 };
129
130 overall_decision = match (overall_decision, decision) {
132 (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
133 (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
134 (Decision::Allow, Decision::Allow) => Decision::Allow,
135 };
136
137 matched_rules.push(rule_match);
138 }
139
140 PolicyEvaluation { decision: overall_decision, matched_rules }
141 }
142
143 pub fn prefix_rules(&self) -> &[PrefixRule] {
145 &self.prefix_rules
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn test_prefix_rule_matching() {
155 let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
156
157 assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
158 assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
159 assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
160 assert!(!rule.matches(&["cargo".to_string()]));
161 }
162
163 #[test]
164 fn test_policy_check() {
165 let mut policy = Policy::empty();
166 policy
167 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
168 .unwrap();
169 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
170
171 let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
172 assert_eq!(allow.decision(), Decision::Allow);
173 assert!(allow.is_policy_match());
174
175 let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
176 assert_eq!(forbidden.decision(), Decision::Forbidden);
177
178 let heuristics = policy.check(&["unknown".to_string()]);
179 assert!(!heuristics.is_policy_match());
180 }
181
182 #[test]
183 fn test_policy_evaluation() {
184 let mut policy = Policy::empty();
185 policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
186 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
187
188 let commands = [
189 vec!["echo".to_string(), "hello".to_string()],
190 vec!["rm".to_string(), "-rf".to_string()],
191 ];
192
193 let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
194
195 assert_eq!(evaluation.decision, Decision::Forbidden);
197 }
198}