Skip to main content

vtcode_safety/exec_policy/
policy.rs

1//! Policy types for execution control.
2
3use serde::{Deserialize, Serialize};
4use std::{collections::HashSet, default::Default};
5
6/// Decision made by a policy rule.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8#[serde(rename_all = "lowercase")]
9pub enum Decision {
10    /// Allow the command to execute.
11    Allow,
12
13    /// Require user confirmation before executing.
14    #[default]
15    Prompt,
16
17    /// Forbid the command from executing.
18    Forbidden,
19}
20
21/// A prefix-based rule for matching commands.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct PrefixRule {
24    /// The command pattern to match.
25    pub(crate) pattern: Vec<String>,
26
27    /// The decision when the pattern matches.
28    pub(crate) decision: Decision,
29}
30
31impl PrefixRule {
32    /// Create a new prefix rule.
33    pub fn new(pattern: Vec<String>, decision: Decision) -> Self {
34        Self { pattern, decision }
35    }
36
37    /// Check if a command matches this rule. Zero-token patterns never match.
38    pub fn matches(&self, command: &[String]) -> bool {
39        matches_prefix(&self.pattern, command)
40    }
41}
42
43/// Empty token conjunctions must never authorize a command.
44pub(super) fn matches_prefix(pattern: &[String], command: &[String]) -> bool {
45    !pattern.is_empty() && command.starts_with(pattern)
46}
47
48/// Result of matching a command against a rule.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum RuleMatch {
51    /// Matched a prefix rule.
52    PrefixRuleMatch { rule: PrefixRule, decision: Decision },
53
54    /// Matched via heuristics (no explicit rule).
55    HeuristicsRuleMatch { decision: Decision },
56}
57
58impl RuleMatch {
59    /// Get the decision from the match.
60    fn decision(&self) -> Decision {
61        match self {
62            Self::PrefixRuleMatch { decision, .. } => *decision,
63            Self::HeuristicsRuleMatch { decision } => *decision,
64        }
65    }
66
67    /// Check if this match came from an explicit policy rule.
68    fn is_policy_match(&self) -> bool {
69        matches!(self, Self::PrefixRuleMatch { .. })
70    }
71}
72
73/// Result of evaluating multiple commands against a policy.
74#[derive(Debug, Clone)]
75pub struct PolicyEvaluation {
76    /// The overall decision.
77    decision: Decision,
78
79    /// All rules that matched.
80    matched_rules: Vec<RuleMatch>,
81}
82
83/// Execution policy containing rules for command authorization.
84#[derive(Debug, Clone, Default)]
85pub struct Policy {
86    /// Prefix rules in order of priority (first match wins).
87    prefix_rules: Vec<PrefixRule>,
88}
89
90impl Policy {
91    /// Create an empty policy.
92    pub fn empty() -> Self {
93        Self { prefix_rules: Vec::new() }
94    }
95
96    /// Add a prefix rule to the policy.
97    ///
98    /// # Errors
99    /// Returns an error for a zero-token pattern without changing the policy.
100    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    /// The prefix rules in evaluation order (first match wins).
107    pub fn rules(&self) -> &[PrefixRule] {
108        &self.prefix_rules
109    }
110
111    /// Prepend a higher-precedence layer of rules.
112    ///
113    /// Used when merging rule-file layers (workspace rules over user rules):
114    /// a pattern that already exists — either in this policy or earlier within
115    /// `rules` — keeps its first occurrence, so the highest-precedence layer
116    /// wins and evaluation order (`check`) stays deterministic.
117    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        // Membership is separate from evaluation order; the vector still defines precedence.
123        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    /// Check a single command against the policy.
135    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        // No explicit rule matched - use heuristics
143        RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
144    }
145
146    /// Check multiple commands against the policy.
147    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            // Apply heuristics for non-policy matches
159            let decision = match &rule_match {
160                RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
161                RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
162            };
163
164            // Track the most restrictive decision
165            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    /// Get all prefix rules.
178    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        // Enumerate asymmetric tokens, including an empty-string argument.
273        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        // Should be forbidden because one command is forbidden
353        assert_eq!(evaluation.decision, Decision::Forbidden);
354    }
355}