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 rules(&self) -> &[PrefixRule] {
102 &self.prefix_rules
103 }
104
105 pub fn prepend_layer(&mut self, rules: impl IntoIterator<Item = PrefixRule>) {
112 let mut new_rules: Vec<PrefixRule> = Vec::new();
113 for rule in rules {
114 let pattern = rule.pattern.clone();
115 let duplicate = new_rules.iter().any(|existing: &PrefixRule| existing.pattern == pattern)
116 || self.prefix_rules.iter().any(|existing| existing.pattern == pattern);
117 if !duplicate {
118 new_rules.push(rule);
119 }
120 }
121 drop(self.prefix_rules.splice(0..0, new_rules));
122 }
123
124 pub fn check(&self, command: &[String]) -> RuleMatch {
126 for rule in &self.prefix_rules {
127 if rule.matches(command) {
128 return RuleMatch::PrefixRuleMatch { rule: rule.clone(), decision: rule.decision };
129 }
130 }
131
132 RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
134 }
135
136 pub fn check_multiple<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
138 where
139 I: Iterator<Item = &'a Vec<String>>,
140 F: Fn(&[String]) -> Decision,
141 {
142 let mut matched_rules = Vec::new();
143 let mut overall_decision = Decision::Allow;
144
145 for command in commands {
146 let rule_match = self.check(command);
147
148 let decision = match &rule_match {
150 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
151 RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
152 };
153
154 overall_decision = match (overall_decision, decision) {
156 (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
157 (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
158 (Decision::Allow, Decision::Allow) => Decision::Allow,
159 };
160
161 matched_rules.push(rule_match);
162 }
163
164 PolicyEvaluation { decision: overall_decision, matched_rules }
165 }
166
167 pub fn prefix_rules(&self) -> &[PrefixRule] {
169 &self.prefix_rules
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_prefix_rule_matching() {
179 let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
180
181 assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
182 assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
183 assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
184 assert!(!rule.matches(&["cargo".to_string()]));
185 }
186
187 #[test]
188 fn test_policy_check() {
189 let mut policy = Policy::empty();
190 policy
191 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
192 .unwrap();
193 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
194
195 let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
196 assert_eq!(allow.decision(), Decision::Allow);
197 assert!(allow.is_policy_match());
198
199 let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
200 assert_eq!(forbidden.decision(), Decision::Forbidden);
201
202 let heuristics = policy.check(&["unknown".to_string()]);
203 assert!(!heuristics.is_policy_match());
204 }
205
206 #[test]
207 fn test_policy_evaluation() {
208 let mut policy = Policy::empty();
209 policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
210 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
211
212 let commands = [
213 vec!["echo".to_string(), "hello".to_string()],
214 vec!["rm".to_string(), "-rf".to_string()],
215 ];
216
217 let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
218
219 assert_eq!(evaluation.decision, Decision::Forbidden);
221 }
222}