Skip to main content

vtcode_safety/exec_policy/
policy.rs

1//! Policy types for execution control.
2
3use serde::{Deserialize, Serialize};
4use std::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.
38    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/// Result of matching a command against a rule.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RuleMatch {
49    /// Matched a prefix rule.
50    PrefixRuleMatch { rule: PrefixRule, decision: Decision },
51
52    /// Matched via heuristics (no explicit rule).
53    HeuristicsRuleMatch { decision: Decision },
54}
55
56impl RuleMatch {
57    /// Get the decision from the match.
58    fn decision(&self) -> Decision {
59        match self {
60            Self::PrefixRuleMatch { decision, .. } => *decision,
61            Self::HeuristicsRuleMatch { decision } => *decision,
62        }
63    }
64
65    /// Check if this match came from an explicit policy rule.
66    fn is_policy_match(&self) -> bool {
67        matches!(self, Self::PrefixRuleMatch { .. })
68    }
69}
70
71/// Result of evaluating multiple commands against a policy.
72#[derive(Debug, Clone)]
73pub struct PolicyEvaluation {
74    /// The overall decision.
75    decision: Decision,
76
77    /// All rules that matched.
78    matched_rules: Vec<RuleMatch>,
79}
80
81/// Execution policy containing rules for command authorization.
82#[derive(Debug, Clone, Default)]
83pub struct Policy {
84    /// Prefix rules in order of priority (first match wins).
85    prefix_rules: Vec<PrefixRule>,
86}
87
88impl Policy {
89    /// Create an empty policy.
90    pub fn empty() -> Self {
91        Self { prefix_rules: Vec::new() }
92    }
93
94    /// Add a prefix rule to the policy.
95    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    /// The prefix rules in evaluation order (first match wins).
101    pub fn rules(&self) -> &[PrefixRule] {
102        &self.prefix_rules
103    }
104
105    /// Prepend a higher-precedence layer of rules.
106    ///
107    /// Used when merging rule-file layers (workspace rules over user rules):
108    /// a pattern that already exists — either in this policy or earlier within
109    /// `rules` — keeps its first occurrence, so the highest-precedence layer
110    /// wins and evaluation order (`check`) stays deterministic.
111    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    /// Check a single command against the policy.
125    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        // No explicit rule matched - use heuristics
133        RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
134    }
135
136    /// Check multiple commands against the policy.
137    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            // Apply heuristics for non-policy matches
149            let decision = match &rule_match {
150                RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
151                RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
152            };
153
154            // Track the most restrictive decision
155            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    /// Get all prefix rules.
168    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        // Should be forbidden because one command is forbidden
220        assert_eq!(evaluation.decision, Decision::Forbidden);
221    }
222}