Skip to main content

vtcode_core/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 pattern: Vec<String>,
26
27    /// The decision when the pattern matches.
28    pub 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
43            .iter()
44            .zip(command.iter())
45            .all(|(pattern, cmd)| pattern == cmd)
46    }
47}
48
49/// Result of matching a command against a rule.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum RuleMatch {
52    /// Matched a prefix rule.
53    PrefixRuleMatch {
54        rule: PrefixRule,
55        decision: Decision,
56    },
57
58    /// Matched via heuristics (no explicit rule).
59    HeuristicsRuleMatch { decision: Decision },
60}
61
62impl RuleMatch {
63    /// Get the decision from the match.
64    pub fn decision(&self) -> Decision {
65        match self {
66            Self::PrefixRuleMatch { decision, .. } => *decision,
67            Self::HeuristicsRuleMatch { decision } => *decision,
68        }
69    }
70
71    /// Check if this match came from an explicit policy rule.
72    pub fn is_policy_match(&self) -> bool {
73        matches!(self, Self::PrefixRuleMatch { .. })
74    }
75}
76
77/// Result of evaluating multiple commands against a policy.
78#[derive(Debug, Clone)]
79pub struct PolicyEvaluation {
80    /// The overall decision.
81    pub decision: Decision,
82
83    /// All rules that matched.
84    pub matched_rules: Vec<RuleMatch>,
85}
86
87/// Execution policy containing rules for command authorization.
88#[derive(Debug, Clone, Default)]
89pub struct Policy {
90    /// Prefix rules in order of priority (first match wins).
91    prefix_rules: Vec<PrefixRule>,
92}
93
94impl Policy {
95    /// Create an empty policy.
96    pub fn empty() -> Self {
97        Self {
98            prefix_rules: Vec::new(),
99        }
100    }
101
102    /// Add a prefix rule to the policy.
103    pub fn add_prefix_rule(
104        &mut self,
105        pattern: &[String],
106        decision: Decision,
107    ) -> anyhow::Result<()> {
108        self.prefix_rules
109            .push(PrefixRule::new(pattern.to_vec(), decision));
110        Ok(())
111    }
112
113    /// Check a single command against the policy.
114    pub fn check(&self, command: &[String]) -> RuleMatch {
115        for rule in &self.prefix_rules {
116            if rule.matches(command) {
117                return RuleMatch::PrefixRuleMatch {
118                    rule: rule.clone(),
119                    decision: rule.decision,
120                };
121            }
122        }
123
124        // No explicit rule matched - use heuristics
125        RuleMatch::HeuristicsRuleMatch {
126            decision: Decision::Prompt,
127        }
128    }
129
130    /// Check multiple commands against the policy.
131    pub fn check_multiple<'a, I, F>(&self, commands: I, heuristics_fallback: &F) -> PolicyEvaluation
132    where
133        I: Iterator<Item = &'a Vec<String>>,
134        F: Fn(&[String]) -> Decision,
135    {
136        let mut matched_rules = Vec::new();
137        let mut overall_decision = Decision::Allow;
138
139        for command in commands {
140            let rule_match = self.check(command);
141
142            // Apply heuristics for non-policy matches
143            let decision = match &rule_match {
144                RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
145                RuleMatch::HeuristicsRuleMatch { .. } => heuristics_fallback(command),
146            };
147
148            // Track the most restrictive decision
149            overall_decision = match (overall_decision, decision) {
150                (Decision::Forbidden, _) | (_, Decision::Forbidden) => Decision::Forbidden,
151                (Decision::Prompt, _) | (_, Decision::Prompt) => Decision::Prompt,
152                (Decision::Allow, Decision::Allow) => Decision::Allow,
153            };
154
155            matched_rules.push(rule_match);
156        }
157
158        PolicyEvaluation {
159            decision: overall_decision,
160            matched_rules,
161        }
162    }
163
164    /// Get all prefix rules.
165    pub fn prefix_rules(&self) -> &[PrefixRule] {
166        &self.prefix_rules
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_prefix_rule_matching() {
176        let rule = PrefixRule::new(
177            vec!["cargo".to_string(), "build".to_string()],
178            Decision::Allow,
179        );
180
181        assert!(rule.matches(&["cargo".to_string(), "build".to_string()]));
182        assert!(rule.matches(&[
183            "cargo".to_string(),
184            "build".to_string(),
185            "--release".to_string()
186        ]));
187        assert!(!rule.matches(&["cargo".to_string(), "test".to_string()]));
188        assert!(!rule.matches(&["cargo".to_string()]));
189    }
190
191    #[test]
192    fn test_policy_check() {
193        let mut policy = Policy::empty();
194        policy
195            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
196            .unwrap();
197        policy
198            .add_prefix_rule(&["rm".to_string()], Decision::Forbidden)
199            .unwrap();
200
201        let allow = policy.check(&["cargo".to_string(), "build".to_string()]);
202        assert_eq!(allow.decision(), Decision::Allow);
203        assert!(allow.is_policy_match());
204
205        let forbidden = policy.check(&["rm".to_string(), "-rf".to_string()]);
206        assert_eq!(forbidden.decision(), Decision::Forbidden);
207
208        let heuristics = policy.check(&["unknown".to_string()]);
209        assert!(!heuristics.is_policy_match());
210    }
211
212    #[test]
213    fn test_policy_evaluation() {
214        let mut policy = Policy::empty();
215        policy
216            .add_prefix_rule(&["echo".to_string()], Decision::Allow)
217            .unwrap();
218        policy
219            .add_prefix_rule(&["rm".to_string()], Decision::Forbidden)
220            .unwrap();
221
222        let commands = [
223            vec!["echo".to_string(), "hello".to_string()],
224            vec!["rm".to_string(), "-rf".to_string()],
225        ];
226
227        let evaluation = policy.check_multiple(commands.iter(), &|_| Decision::Prompt);
228
229        // Should be forbidden because one command is forbidden
230        assert_eq!(evaluation.decision, Decision::Forbidden);
231    }
232}