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")]
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 matches_prefix(&self.pattern, command)
40 }
41}
42
43pub(super) fn matches_prefix(pattern: &[String], command: &[String]) -> bool {
45 !pattern.is_empty() && command.starts_with(pattern)
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RuleMatch {
51 PrefixRuleMatch { rule: PrefixRule, decision: Decision },
53
54 HeuristicsRuleMatch { decision: Decision },
56}
57
58impl RuleMatch {
59 fn decision(&self) -> Decision {
61 match self {
62 Self::PrefixRuleMatch { decision, .. } => *decision,
63 Self::HeuristicsRuleMatch { decision } => *decision,
64 }
65 }
66
67 fn is_policy_match(&self) -> bool {
69 matches!(self, Self::PrefixRuleMatch { .. })
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct PolicyEvaluation {
76 decision: Decision,
78
79 matched_rules: Vec<RuleMatch>,
81}
82
83#[derive(Debug, Clone, Default)]
85pub struct Policy {
86 prefix_rules: Vec<PrefixRule>,
88}
89
90impl Policy {
91 pub fn empty() -> Self {
93 Self { prefix_rules: Vec::new() }
94 }
95
96 pub fn add_prefix_rule(&mut self, pattern: &[String], decision: Decision) -> anyhow::Result<()> {
101 anyhow::ensure!(!pattern.is_empty(), "prefix pattern cannot be empty");
102 self.prefix_rules.push(PrefixRule::new(pattern.to_vec(), decision));
103 Ok(())
104 }
105
106 pub fn rules(&self) -> &[PrefixRule] {
108 &self.prefix_rules
109 }
110
111 pub fn prepend_layer(&mut self, rules: impl IntoIterator<Item = PrefixRule>) {
118 let rules: Vec<_> = rules.into_iter().collect();
119 if rules.is_empty() {
120 return;
121 }
122 let mut seen: HashSet<&[String]> = self.prefix_rules.iter().map(|rule| rule.pattern.as_slice()).collect();
124 let keep: Vec<_> = rules.iter().map(|rule| seen.insert(rule.pattern.as_slice())).collect();
125 drop(seen);
126 let new_rules: Vec<_> = rules
127 .into_iter()
128 .zip(keep)
129 .filter_map(|(rule, keep)| keep.then_some(rule))
130 .collect();
131 drop(self.prefix_rules.splice(0..0, new_rules));
132 }
133
134 pub fn check(&self, command: &[String]) -> RuleMatch {
136 for rule in &self.prefix_rules {
137 if rule.matches(command) {
138 return RuleMatch::PrefixRuleMatch { rule: rule.clone(), decision: rule.decision };
139 }
140 }
141
142 RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
144 }
145
146 pub fn check_multiple<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
148 where
149 I: Iterator<Item = &'a Vec<String>>,
150 F: Fn(&[String]) -> Decision,
151 {
152 let mut matched_rules = Vec::new();
153 let mut overall_decision = Decision::Allow;
154
155 for command in commands {
156 let rule_match = self.check(command);
157
158 let decision = match &rule_match {
160 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
161 RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
162 };
163
164 overall_decision = match (overall_decision, decision) {
166 (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
167 (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
168 (Decision::Allow, Decision::Allow) => Decision::Allow,
169 };
170
171 matched_rules.push(rule_match);
172 }
173
174 PolicyEvaluation { decision: overall_decision, matched_rules }
175 }
176
177 pub fn prefix_rules(&self) -> &[PrefixRule] {
179 &self.prefix_rules
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn empty_prefix_never_matches() {
189 let rule = PrefixRule::new(Vec::new(), Decision::Allow);
190 assert!(!rule.matches(&[]));
191 assert!(!rule.matches(&["untrusted".into()]));
192 }
193
194 #[test]
195 fn empty_deserialized_rule_and_layer_cannot_match() {
196 let rule: PrefixRule = serde_json::from_str(r#"{"pattern":[],"decision":"allow"}"#).unwrap();
197 let mut policy = Policy::empty();
198 policy.prepend_layer([rule]);
199 for command in [vec![], vec!["untrusted".into()]] {
200 assert_eq!(policy.check(&command), RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt });
201 }
202 }
203
204 #[test]
205 fn rejected_insertion_preserves_rules() {
206 let mut policy = Policy::empty();
207 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
208 let before = policy.rules().to_vec();
209 for decision in [Decision::Allow, Decision::Prompt, Decision::Forbidden] {
210 assert!(policy.add_prefix_rule(&[], decision).is_err());
211 assert_eq!(policy.rules(), before);
212 }
213 }
214
215 #[test]
216 fn layer_deduplication_matches_pairwise_reference() {
217 let choices = [
218 PrefixRule::new(vec!["a".into()], Decision::Allow),
219 PrefixRule::new(vec!["b".into()], Decision::Prompt),
220 PrefixRule::new(vec!["a".into()], Decision::Forbidden),
221 PrefixRule::new(vec!["a".into(), "b".into()], Decision::Forbidden),
222 PrefixRule::new(vec![], Decision::Allow),
223 ];
224 for existing in &choices {
225 for first in &choices {
226 for second in &choices {
227 let mut policy = Policy { prefix_rules: vec![existing.clone()] };
228 let incoming = [first.clone(), second.clone(), first.clone()];
229 let mut expected = Vec::<PrefixRule>::new();
230 for rule in &incoming {
231 if !expected.iter().chain(policy.rules()).any(|prior| prior.pattern == rule.pattern) {
232 expected.push(rule.clone());
233 }
234 }
235 expected.push(existing.clone());
236 policy.prepend_layer(incoming);
237 assert_eq!(policy.rules(), expected);
238 for command in [vec![], vec!["a".into()], vec!["b".into()], vec!["a".into(), "b".into()]] {
239 let reference = Policy { prefix_rules: expected.clone() };
240 assert_eq!(policy.check(&command), reference.check(&command));
241 }
242 }
243 }
244 }
245 }
246
247 #[test]
248 fn layer_deduplication_preserves_order_and_existing_duplicates() {
249 let mut policy = Policy::empty();
250 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
251 policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
252 let existing = policy.rules().to_vec();
253 let first = PrefixRule::new(vec!["cargo".into()], Decision::Allow);
254 policy.prepend_layer([
255 first.clone(),
256 PrefixRule::new(vec!["git".into()], Decision::Allow),
257 PrefixRule::new(vec!["cargo".into()], Decision::Forbidden),
258 PrefixRule::new(vec!["cargo".into(), "build".into()], Decision::Forbidden),
259 ]);
260 assert_eq!(policy.rules().len(), 4);
261 assert_eq!(policy.rules()[0], first);
262 assert_eq!(&policy.rules()[2..], existing);
263 assert_eq!(policy.check(&["cargo".into(), "build".into()]).decision(), Decision::Allow);
264 assert_eq!(policy.check(&["git".into()]).decision(), Decision::Prompt);
265 let before = policy.rules().to_vec();
266 policy.prepend_layer([]);
267 assert_eq!(policy.rules(), before);
268 }
269
270 #[test]
271 fn nonempty_matching_agrees_with_prefix_oracle() {
272 let mut vectors = vec![Vec::new()];
274 for _ in 0..3 {
275 let previous = vectors.clone();
276 for prefix in previous {
277 for token in ["git", "push", ""] {
278 let mut next = prefix.clone();
279 next.push(token.to_string());
280 vectors.push(next);
281 }
282 }
283 }
284 for pattern in &vectors {
285 let rule = PrefixRule::new(pattern.clone(), Decision::Allow);
286 for command in &vectors {
287 let expected = !pattern.is_empty() && command.get(..pattern.len()) == Some(pattern.as_slice());
288 assert_eq!(rule.matches(command), expected, "{pattern:?} vs {command:?}");
289 }
290 }
291 }
292
293 #[test]
294 fn first_match_and_restrictive_aggregation_are_preserved() {
295 let mut policy = Policy::empty();
296 policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
297 policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
298 policy.add_prefix_rule(&["deny".into()], Decision::Forbidden).unwrap();
299 policy.add_prefix_rule(&["allow".into()], Decision::Allow).unwrap();
300 let mut commands = vec![vec!["allow".into()]];
301 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Allow);
302 commands.push(vec!["git".into(), "push".into()]);
303 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Prompt);
304 commands.push(vec!["deny".into()]);
305 assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Forbidden);
306 let unknown = [vec!["unknown".into()]];
307 assert_eq!(policy.check_multiple(unknown.iter(), &|_| Decision::Forbidden).decision, Decision::Forbidden);
308 }
309
310 #[test]
311 fn test_prefix_rule_matching() {
312 let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
313
314 assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
315 assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
316 assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
317 assert!(!rule.matches(&["cargo".to_string()]));
318 }
319
320 #[test]
321 fn test_policy_check() {
322 let mut policy = Policy::empty();
323 policy
324 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
325 .unwrap();
326 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
327
328 let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
329 assert_eq!(allow.decision(), Decision::Allow);
330 assert!(allow.is_policy_match());
331
332 let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
333 assert_eq!(forbidden.decision(), Decision::Forbidden);
334
335 let heuristics = policy.check(&["unknown".to_string()]);
336 assert!(!heuristics.is_policy_match());
337 }
338
339 #[test]
340 fn test_policy_evaluation() {
341 let mut policy = Policy::empty();
342 policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
343 policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
344
345 let commands = [
346 vec!["echo".to_string(), "hello".to_string()],
347 vec!["rm".to_string(), "-rf".to_string()],
348 ];
349
350 let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
351
352 assert_eq!(evaluation.decision, Decision::Forbidden);
354 }
355}