vtcode_safety/exec_policy/
policy.rs1use 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<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
150 where
151 I: Iterator<Item = &'a Vec<String>>,
152 F: Fn(&[String]) -> Decision,
153 {
154 let mut matched_rules = Vec::new();
155 let mut overall_decision = Decision::Allow;
156
157 for command in commands {
158 let rule_match = self.check(command);
159
160 let decision = match &rule_match {
162 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
163 RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
164 };
165
166 overall_decision = match (overall_decision, decision) {
168 (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
169 (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
170 (Decision::Allow, Decision::Allow) => Decision::Allow,
171 };
172
173 matched_rules.push(rule_match);
174 }
175
176 PolicyEvaluation { decision: overall_decision, matched_rules }
177 }
178
179 pub fn prefix_rules(&self) -> &[PrefixRule] {
181 &self.prefix_rules
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn empty_prefix_never_matches() {
191 let rule = PrefixRule::new(Vec::new(), Decision::Allow);
192 assert!(!rule.matches(&[]));
193 assert!(!rule.matches(&["untrusted".into()]));
194 }
195
196 #[test]
197 fn empty_deserialized_rule_and_layer_cannot_match() {
198 let rule: PrefixRule = serde_json::from_str(r#"{"pattern":[],"decision":"allow"}"#).unwrap();
199 let mut policy = Policy::empty();
200 policy.prepend_layer([rule]);
201 for command in [vec![], vec!["untrusted".into()]] {
202 assert_eq!(policy.check(&command), RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt });
203 }
204 }
205
206 #[test]
207 fn rejected_insertion_preserves_rules() {
208 let mut policy = Policy::empty();
209 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
210 let before = policy.rules().to_vec();
211 for decision in [Decision::Allow, Decision::Prompt, Decision::Forbidden] {
212 assert!(policy.add_prefix_rule(&[], decision).is_err());
213 assert_eq!(policy.rules(), before);
214 }
215 }
216
217 #[test]
218 fn layer_deduplication_matches_pairwise_reference() {
219 let choices = [
220 PrefixRule::new(vec!["a".into()], Decision::Allow),
221 PrefixRule::new(vec!["b".into()], Decision::Prompt),
222 PrefixRule::new(vec!["a".into()], Decision::Forbidden),
223 PrefixRule::new(vec!["a".into(), "b".into()], Decision::Forbidden),
224 PrefixRule::new(vec![], Decision::Allow),
225 ];
226 for existing in &choices {
227 for first in &choices {
228 for second in &choices {
229 let mut policy = Policy { prefix_rules: vec![existing.clone()] };
230 let incoming = [first.clone(), second.clone(), first.clone()];
231 let mut expected = Vec::<PrefixRule>::new();
232 for rule in &incoming {
233 if !expected.iter().chain(policy.rules()).any(|prior| prior.pattern == rule.pattern) {
234 expected.push(rule.clone());
235 }
236 }
237 expected.push(existing.clone());
238 policy.prepend_layer(incoming);
239 assert_eq!(policy.rules(), expected);
240 for command in [vec![], vec!["a".into()], vec!["b".into()], vec!["a".into(), "b".into()]] {
241 let reference = Policy { prefix_rules: expected.clone() };
242 assert_eq!(policy.check(&command), reference.check(&command));
243 }
244 }
245 }
246 }
247 }
248
249 #[test]
250 fn layer_deduplication_preserves_order_and_existing_duplicates() {
251 let mut policy = Policy::empty();
252 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
253 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
254 let existing = policy.rules().to_vec();
255 let first = PrefixRule::new(vec!["cargo".into()], Decision::Allow);
256 policy.prepend_layer([
257 first.clone(),
258 PrefixRule::new(vec!["git".into()], Decision::Allow),
259 PrefixRule::new(vec!["cargo".into()], Decision::Forbidden),
260 PrefixRule::new(vec!["cargo".into(), "build".into()], Decision::Forbidden),
261 ]);
262 assert_eq!(policy.rules().len(), 4);
263 assert_eq!(policy.rules()[0], first);
264 assert_eq!(&policy.rules()[2..], existing);
265 assert_eq!(policy.check(&["cargo".into(), "build".into()]).decision(), Decision::Allow);
266 assert_eq!(policy.check(&["git".into()]).decision(), Decision::Prompt);
267 let before = policy.rules().to_vec();
268 policy.prepend_layer([]);
269 assert_eq!(policy.rules(), before);
270 }
271
272 #[test]
273 fn nonempty_matching_agrees_with_prefix_oracle() {
274 let mut vectors = vec![Vec::new()];
276 for _ in 0..3 {
277 let previous = vectors.clone();
278 for prefix in previous {
279 for token in ["git", "push", ""] {
280 let mut next = prefix.clone();
281 next.push(token.to_string());
282 vectors.push(next);
283 }
284 }
285 }
286 for pattern in &vectors {
287 let rule = PrefixRule::new(pattern.clone(), Decision::Allow);
288 for command in &vectors {
289 let expected = !pattern.is_empty() && command.get(..pattern.len()) == Some(pattern.as_slice());
290 assert_eq!(rule.matches(command), expected, "{pattern:?} vs {command:?}");
291 }
292 }
293 }
294
295 #[test]
296 fn first_match_and_restrictive_aggregation_are_preserved() {
297 let mut policy = Policy::empty();
298 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
299 policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
300 policy.add_prefix_rule(&["deny".into()], Decision::Forbidden).unwrap();
301 policy.add_prefix_rule(&["allow".into()], Decision::Allow).unwrap();
302 let mut commands = vec![vec!["allow".into()]];
303 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Allow);
304 commands.push(vec!["git".into(), "push".into()]);
305 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Prompt);
306 commands.push(vec!["deny".into()]);
307 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Forbidden);
308 let unknown = [vec!["unknown".into()]];
309 assert_eq!(policy.check_multiple(unknown.iter(), &|_| Decision::Forbidden).decision, Decision::Forbidden);
310 }
311
312 #[test]
313 fn test_prefix_rule_matching() {
314 let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
315
316 assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
317 assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
318 assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
319 assert!(!rule.matches(&["cargo".to_string()]));
320 }
321
322 #[test]
323 fn test_policy_check() {
324 let mut policy = Policy::empty();
325 policy
326 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
327 .unwrap();
328 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
329
330 let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
331 assert_eq!(allow.decision(), Decision::Allow);
332 assert!(allow.is_policy_match());
333
334 let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
335 assert_eq!(forbidden.decision(), Decision::Forbidden);
336
337 let heuristics = policy.check(&["unknown".to_string()]);
338 assert!(!heuristics.is_policy_match());
339 }
340
341 #[test]
342 fn test_policy_evaluation() {
343 let mut policy = Policy::empty();
344 policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
345 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
346
347 let commands = [
348 vec!["echo".to_string(), "hello".to_string()],
349 vec!["rm".to_string(), "-rf".to_string()],
350 ];
351
352 let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
353
354 assert_eq!(evaluation.decision, Decision::Forbidden);
356 }
357}