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")]
9#[must_use = "execution policy decisions must be enforced"]
10pub enum Decision {
11    /// Allow the command to execute.
12    Allow,
13
14    /// Require user confirmation before executing.
15    #[default]
16    Prompt,
17
18    /// Forbid the command from executing.
19    Forbidden,
20}
21
22/// A prefix-based rule for matching commands.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct PrefixRule {
25    /// The command pattern to match.
26    pub(crate) pattern: Vec<String>,
27
28    /// The decision when the pattern matches.
29    pub(crate) decision: Decision,
30}
31
32impl PrefixRule {
33    /// Create a new prefix rule.
34    pub fn new(pattern: Vec<String>, decision: Decision) -> Self {
35        Self { pattern, decision }
36    }
37
38    /// Check if a command matches this rule. Zero-token patterns never match.
39    #[must_use]
40    pub fn matches(&self, command: &[String]) -> bool {
41        matches_prefix(&self.pattern, command)
42    }
43}
44
45/// Empty token conjunctions must never authorize a command.
46pub(super) fn matches_prefix(pattern: &[String], command: &[String]) -> bool {
47    !pattern.is_empty() && command.starts_with(pattern)
48}
49
50/// Result of matching a command against a rule.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum RuleMatch {
53    /// Matched a prefix rule.
54    PrefixRuleMatch { rule: PrefixRule, decision: Decision },
55
56    /// Matched via heuristics (no explicit rule).
57    HeuristicsRuleMatch { decision: Decision },
58}
59
60impl RuleMatch {
61    /// Get the decision from the match.
62    fn decision(&self) -> Decision {
63        match self {
64            Self::PrefixRuleMatch { decision, .. } => *decision,
65            Self::HeuristicsRuleMatch { decision } => *decision,
66        }
67    }
68
69    /// Check if this match came from an explicit policy rule.
70    fn is_policy_match(&self) -> bool {
71        matches!(self, Self::PrefixRuleMatch { .. })
72    }
73}
74
75/// Result of evaluating multiple commands against a policy.
76#[derive(Debug, Clone)]
77pub struct PolicyEvaluation {
78    /// The overall decision.
79    decision: Decision,
80
81    /// All rules that matched.
82    matched_rules: Vec<RuleMatch>,
83}
84
85/// Execution policy containing rules for command authorization.
86#[derive(Debug, Clone, Default)]
87pub struct Policy {
88    /// Prefix rules in order of priority (first match wins).
89    prefix_rules: Vec<PrefixRule>,
90}
91
92impl Policy {
93    /// Create an empty policy.
94    pub fn empty() -> Self {
95        Self { prefix_rules: Vec::new() }
96    }
97
98    /// Add a prefix rule to the policy.
99    ///
100    /// # Errors
101    /// Returns an error for a zero-token pattern without changing the policy.
102    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    /// The prefix rules in evaluation order (first match wins).
109    pub fn rules(&self) -> &[PrefixRule] {
110        &self.prefix_rules
111    }
112
113    /// Prepend a higher-precedence layer of rules.
114    ///
115    /// Used when merging rule-file layers (workspace rules over user rules):
116    /// a pattern that already exists — either in this policy or earlier within
117    /// `rules` — keeps its first occurrence, so the highest-precedence layer
118    /// wins and evaluation order (`check`) stays deterministic.
119    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        // Membership is separate from evaluation order; the vector still defines precedence.
125        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    /// Check a single command against the policy.
137    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        // No explicit rule matched - use heuristics
145        RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
146    }
147
148    /// Check multiple commands against the policy, accepting borrowed slices
149    /// as well as references to command vectors without copying arguments.
150    pub fn check_multiple<I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
151    where
152        I: Iterator,
153        I::Item: AsRef<[String]>,
154        F: Fn(&[String]) -> Decision,
155    {
156        let mut matched_rules = Vec::new();
157        let mut overall_decision = Decision::Allow;
158
159        for command in commands {
160            let command = command.as_ref();
161            let rule_match = self.check(command);
162
163            // Apply heuristics for non-policy matches
164            let decision = match &rule_match {
165                RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
166                RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
167            };
168
169            // Track the most restrictive decision
170            overall_decision = match (overall_decision, decision) {
171                (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
172                (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
173                (Decision::Allow, Decision::Allow) => Decision::Allow,
174            };
175
176            matched_rules.push(rule_match);
177        }
178
179        PolicyEvaluation { decision: overall_decision, matched_rules }
180    }
181
182    /// Get all prefix rules.
183    pub fn prefix_rules(&self) -> &[PrefixRule] {
184        &self.prefix_rules
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn empty_prefix_never_matches() {
194        let rule = PrefixRule::new(Vec::new(), Decision::Allow);
195        assert!(!rule.matches(&[]));
196        assert!(!rule.matches(&["untrusted".into()]));
197    }
198
199    #[test]
200    fn empty_deserialized_rule_and_layer_cannot_match() {
201        let rule: PrefixRule = serde_json::from_str(r#"{"pattern":[],"decision":"allow"}"#).unwrap();
202        let mut policy = Policy::empty();
203        policy.prepend_layer([rule]);
204        for command in [vec![], vec!["untrusted".into()]] {
205            assert_eq!(policy.check(&command), RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt });
206        }
207    }
208
209    #[test]
210    fn rejected_insertion_preserves_rules() {
211        let mut policy = Policy::empty();
212        policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
213        let before = policy.rules().to_vec();
214        for decision in [Decision::Allow, Decision::Prompt, Decision::Forbidden] {
215            assert!(policy.add_prefix_rule(&[], decision).is_err());
216            assert_eq!(policy.rules(), before);
217        }
218    }
219
220    #[test]
221    fn layer_deduplication_matches_pairwise_reference() {
222        let choices = [
223            PrefixRule::new(vec!["a".into()], Decision::Allow),
224            PrefixRule::new(vec!["b".into()], Decision::Prompt),
225            PrefixRule::new(vec!["a".into()], Decision::Forbidden),
226            PrefixRule::new(vec!["a".into(), "b".into()], Decision::Forbidden),
227            PrefixRule::new(vec![], Decision::Allow),
228        ];
229        for existing in &choices {
230            for first in &choices {
231                for second in &choices {
232                    let mut policy = Policy { prefix_rules: vec![existing.clone()] };
233                    let incoming = [first.clone(), second.clone(), first.clone()];
234                    let mut expected = Vec::<PrefixRule>::new();
235                    for rule in &incoming {
236                        if !expected.iter().chain(policy.rules()).any(|prior| prior.pattern == rule.pattern) {
237                            expected.push(rule.clone());
238                        }
239                    }
240                    expected.push(existing.clone());
241                    policy.prepend_layer(incoming);
242                    assert_eq!(policy.rules(), expected);
243                    for command in [vec![], vec!["a".into()], vec!["b".into()], vec!["a".into(), "b".into()]] {
244                        let reference = Policy { prefix_rules: expected.clone() };
245                        assert_eq!(policy.check(&command), reference.check(&command));
246                    }
247                }
248            }
249        }
250    }
251
252    #[test]
253    fn layer_deduplication_preserves_order_and_existing_duplicates() {
254        let mut policy = Policy::empty();
255        policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
256        policy.add_prefix_rule(&["git".into()], Decision::Forbidden).unwrap();
257        let existing = policy.rules().to_vec();
258        let first = PrefixRule::new(vec!["cargo".into()], Decision::Allow);
259        policy.prepend_layer([
260            first.clone(),
261            PrefixRule::new(vec!["git".into()], Decision::Allow),
262            PrefixRule::new(vec!["cargo".into()], Decision::Forbidden),
263            PrefixRule::new(vec!["cargo".into(), "build".into()], Decision::Forbidden),
264        ]);
265        assert_eq!(policy.rules().len(), 4);
266        assert_eq!(policy.rules()[0], first);
267        assert_eq!(&policy.rules()[2..], existing);
268        assert_eq!(policy.check(&["cargo".into(), "build".into()]).decision(), Decision::Allow);
269        assert_eq!(policy.check(&["git".into()]).decision(), Decision::Prompt);
270        let before = policy.rules().to_vec();
271        policy.prepend_layer([]);
272        assert_eq!(policy.rules(), before);
273    }
274
275    #[test]
276    fn nonempty_matching_agrees_with_prefix_oracle() {
277        // Enumerate asymmetric tokens, including an empty-string argument.
278        let mut vectors = vec![Vec::new()];
279        for _ in 0..3 {
280            let previous = vectors.clone();
281            for prefix in previous {
282                for token in ["git", "push", ""] {
283                    let mut next = prefix.clone();
284                    next.push(token.to_string());
285                    vectors.push(next);
286                }
287            }
288        }
289        for pattern in &vectors {
290            let rule = PrefixRule::new(pattern.clone(), Decision::Allow);
291            for command in &vectors {
292                let expected = !pattern.is_empty() && command.get(..pattern.len()) == Some(pattern.as_slice());
293                assert_eq!(rule.matches(command), expected, "{pattern:?} vs {command:?}");
294            }
295        }
296    }
297
298    #[test]
299    fn borrowed_commands_preserve_rule_precedence_and_fallback_arguments() {
300        let mut policy = Policy::empty();
301        let first = PrefixRule::new(vec!["git".into()], Decision::Prompt);
302        policy.add_prefix_rule(&first.pattern, first.decision).unwrap();
303        policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
304        let known = ["git".to_owned(), "push".to_owned()];
305        let unknown = ["custom".to_owned(), "--dangerous".to_owned()];
306        let fallback_calls = std::cell::Cell::new(0);
307        let evaluation = policy.check_multiple([known.as_slice(), unknown.as_slice()].into_iter(), &|command| {
308            assert!(std::ptr::eq(command, unknown.as_slice()), "heuristics must borrow the original argv");
309            fallback_calls.set(fallback_calls.get() + 1);
310            Decision::Forbidden
311        });
312        assert_eq!(fallback_calls.get(), 1);
313        assert_eq!(evaluation.decision, Decision::Forbidden);
314        assert_eq!(
315            evaluation.matched_rules,
316            vec![
317                RuleMatch::PrefixRuleMatch { rule: first, decision: Decision::Prompt },
318                RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt },
319            ]
320        );
321        let empty = policy.check_multiple(std::iter::empty::<&[String]>(), &|_| panic!("no commands"));
322        assert_eq!(empty.decision, Decision::Allow);
323        assert!(empty.matched_rules.is_empty());
324    }
325
326    #[test]
327    fn first_match_and_restrictive_aggregation_are_preserved() {
328        let mut policy = Policy::empty();
329        policy.add_prefix_rule(&["git".into()], Decision::Prompt).unwrap();
330        policy.add_prefix_rule(&["git".into(), "push".into()], Decision::Allow).unwrap();
331        policy.add_prefix_rule(&["deny".into()], Decision::Forbidden).unwrap();
332        policy.add_prefix_rule(&["allow".into()], Decision::Allow).unwrap();
333        let mut commands = vec![vec!["allow".into()]];
334        assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Allow);
335        commands.push(vec!["git".into(), "push".into()]);
336        assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Prompt);
337        commands.push(vec!["deny".into()]);
338        assert_eq!(policy.check_multiple(commands.iter(), &|_| Decision::Allow).decision, Decision::Forbidden);
339        let unknown = [vec!["unknown".into()]];
340        assert_eq!(policy.check_multiple(unknown.iter(), &|_| Decision::Forbidden).decision, Decision::Forbidden);
341    }
342
343    #[test]
344    fn test_prefix_rule_matching() {
345        let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
346
347        assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
348        assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
349        assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
350        assert!(!rule.matches(&["cargo".to_string()]));
351    }
352
353    #[test]
354    fn test_policy_check() {
355        let mut policy = Policy::empty();
356        policy
357            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
358            .unwrap();
359        policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
360
361        let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
362        assert_eq!(allow.decision(), Decision::Allow);
363        assert!(allow.is_policy_match());
364
365        let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
366        assert_eq!(forbidden.decision(), Decision::Forbidden);
367
368        let heuristics = policy.check(&["unknown".to_string()]);
369        assert!(!heuristics.is_policy_match());
370    }
371
372    #[test]
373    fn test_policy_evaluation() {
374        let mut policy = Policy::empty();
375        policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
376        policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
377
378        let commands = [
379            vec!["echo".to_string(), "hello".to_string()],
380            vec!["rm".to_string(), "-rf".to_string()],
381        ];
382
383        let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
384
385        // Should be forbidden because one command is forbidden
386        assert_eq!(evaluation.decision, Decision::Forbidden);
387    }
388}