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    /// Check a single command against the policy.
101    pub fn check(&self, command: &[String]) -> RuleMatch {
102        for rule in &self.prefix_rules {
103            if rule.matches(command) {
104                return RuleMatch::PrefixRuleMatch { rule: rule.clone(), decision: rule.decision };
105            }
106        }
107
108        // No explicit rule matched - use heuristics
109        RuleMatch::HeuristicsRuleMatch { decision: Decision::Prompt }
110    }
111
112    /// Check multiple commands against the policy.
113    pub fn check_multiple<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
114    where
115        I: Iterator<Item = &'a Vec<String>>,
116        F: Fn(&[String]) -> Decision,
117    {
118        let mut matched_rules = Vec::new();
119        let mut overall_decision = Decision::Allow;
120
121        for command in commands {
122            let rule_match = self.check(command);
123
124            // Apply heuristics for non-policy matches
125            let decision = match &rule_match {
126                RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
127                RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
128            };
129
130            // Track the most restrictive decision
131            overall_decision = match (overall_decision, decision) {
132                (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
133                (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
134                (Decision::Allow, Decision::Allow) => Decision::Allow,
135            };
136
137            matched_rules.push(rule_match);
138        }
139
140        PolicyEvaluation { decision: overall_decision, matched_rules }
141    }
142
143    /// Get all prefix rules.
144    pub fn prefix_rules(&self) -> &[PrefixRule] {
145        &self.prefix_rules
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_prefix_rule_matching() {
155        let rule = PrefixRule::new(vec!["cargo".to_string(), "build".to_string()], Decision::Allow);
156
157        assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
158        assert!(rule.matches(&["cargo".to_string(), "build".to_string(), "--release".to_string()]));
159        assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
160        assert!(!rule.matches(&["cargo".to_string()]));
161    }
162
163    #[test]
164    fn test_policy_check() {
165        let mut policy = Policy::empty();
166        policy
167            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
168            .unwrap();
169        policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
170
171        let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
172        assert_eq!(allow.decision(), Decision::Allow);
173        assert!(allow.is_policy_match());
174
175        let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
176        assert_eq!(forbidden.decision(), Decision::Forbidden);
177
178        let heuristics = policy.check(&["unknown".to_string()]);
179        assert!(!heuristics.is_policy_match());
180    }
181
182    #[test]
183    fn test_policy_evaluation() {
184        let mut policy = Policy::empty();
185        policy.add_prefix_rule(&["echo".to_string()], Decision::Allow).unwrap();
186        policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden).unwrap();
187
188        let commands = [
189            vec!["echo".to_string(), "hello".to_string()],
190            vec!["rm".to_string(), "-rf".to_string()],
191        ];
192
193        let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
194
195        // Should be forbidden because one command is forbidden
196        assert_eq!(evaluation.decision, Decision::Forbidden);
197    }
198}