Skip to main content

vtcode_core/tools/
command_policy.rs

1use crate::audit::PermissionDecision;
2use crate::config::CommandsConfig;
3use crate::tools::command_cache::PermissionCache;
4use crate::tools::command_resolver::CommandResolver;
5use regex::Regex;
6use std::path::PathBuf;
7use std::sync::{Arc, Mutex, PoisonError};
8use tracing::warn;
9
10#[derive(Clone)]
11pub struct CommandPolicyEvaluator {
12    allow_prefixes: Vec<String>,
13    deny_prefixes: Vec<String>,
14    allow_regexes: Vec<Regex>,
15    deny_regexes: Vec<Regex>,
16    allow_glob_regexes: Vec<Regex>,
17    deny_glob_regexes: Vec<Regex>,
18    allow_regexes_empty: bool,
19    allow_globs_empty: bool,
20    // NEW: Command resolution and caching for improved security visibility
21    resolver: Arc<Mutex<CommandResolver>>,
22    cache: Arc<Mutex<PermissionCache>>,
23}
24
25impl CommandPolicyEvaluator {
26    pub fn from_config(config: &CommandsConfig) -> Self {
27        let allow_prefixes =
28            crate::utils::merge_env_patterns(&config.allow_list, "VTCODE_COMMANDS_ALLOW_LIST");
29        let deny_prefixes =
30            crate::utils::merge_env_patterns(&config.deny_list, "VTCODE_COMMANDS_DENY_LIST");
31
32        let allow_regex_patterns =
33            crate::utils::merge_env_patterns(&config.allow_regex, "VTCODE_COMMANDS_ALLOW_REGEX");
34        let deny_regex_patterns =
35            crate::utils::merge_env_patterns(&config.deny_regex, "VTCODE_COMMANDS_DENY_REGEX");
36
37        let allow_glob_patterns =
38            crate::utils::merge_env_patterns(&config.allow_glob, "VTCODE_COMMANDS_ALLOW_GLOB");
39        let deny_glob_patterns =
40            crate::utils::merge_env_patterns(&config.deny_glob, "VTCODE_COMMANDS_DENY_GLOB");
41
42        let allow_regexes = compile_regexes(&allow_regex_patterns);
43        let deny_regexes = compile_regexes(&deny_regex_patterns);
44        let allow_glob_regexes = compile_globs(&allow_glob_patterns);
45        let deny_glob_regexes = compile_globs(&deny_glob_patterns);
46
47        Self {
48            allow_prefixes,
49            deny_prefixes,
50            allow_regexes,
51            deny_regexes,
52            allow_glob_regexes,
53            deny_glob_regexes,
54            allow_regexes_empty: allow_regex_patterns.is_empty(),
55            allow_globs_empty: allow_glob_patterns.is_empty(),
56            resolver: Arc::new(Mutex::new(CommandResolver::new())),
57            cache: Arc::new(Mutex::new(PermissionCache::new())),
58        }
59    }
60
61    fn cached_decision(&self, command_text: &str) -> Option<bool> {
62        self.cache
63            .lock()
64            .unwrap_or_else(PoisonError::into_inner)
65            .get(command_text)
66    }
67
68    fn resolve_path(&self, command_text: &str) -> Option<PathBuf> {
69        self.resolver
70            .lock()
71            .unwrap_or_else(PoisonError::into_inner)
72            .resolve(command_text)
73            .resolved_path
74            .clone()
75    }
76
77    fn cache_decision(&self, command_text: &str, allowed: bool, reason: &str) {
78        let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
79        cache.put(command_text, allowed, reason);
80    }
81
82    pub fn allows(&self, command: &[String]) -> bool {
83        if command.is_empty() {
84            return false;
85        }
86        let command_text = command.join(" ");
87        self.allows_text(&command_text)
88    }
89
90    pub fn allows_text(&self, command_text: &str) -> bool {
91        let cmd = command_text.trim();
92        if cmd.is_empty() {
93            return false;
94        }
95
96        // Deny takes precedence
97        if self.matches_prefix(cmd, &self.deny_prefixes)
98            || Self::matches_any(&self.deny_regexes, cmd)
99            || Self::matches_any(&self.deny_glob_regexes, cmd)
100        {
101            return false;
102        }
103
104        // If no allow rules defined, allow by default
105        if self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
106            return true;
107        }
108
109        // Check allow rules
110        self.matches_prefix(cmd, &self.allow_prefixes)
111            || Self::matches_any(&self.allow_regexes, cmd)
112            || Self::matches_any(&self.allow_glob_regexes, cmd)
113    }
114
115    /// Enhanced async evaluation with command resolution and caching
116    /// Returns (allowed, resolved_path, reason, decision)
117    pub fn evaluate_with_resolution(
118        &self,
119        command_text: &str,
120    ) -> (bool, Option<PathBuf>, String, PermissionDecision) {
121        let cmd = command_text.trim();
122
123        // Check cache first
124        if let Some(allowed) = self.cached_decision(cmd) {
125            let reason = if allowed {
126                "Cached allow decision"
127            } else {
128                "Cached deny decision"
129            };
130            return (
131                allowed,
132                None,
133                reason.to_string(),
134                PermissionDecision::Cached,
135            );
136        }
137
138        // Resolve command to actual path
139        let resolved_path = self.resolve_path(cmd);
140
141        // Evaluate policy
142        let allowed = self.allows_text(cmd);
143
144        // Determine reason - use static strings where possible to avoid allocations
145        let reason = if allowed {
146            if self.matches_prefix(cmd, &self.allow_prefixes) {
147                format!("allow_list match: {}", cmd)
148            } else if Self::matches_any(&self.allow_glob_regexes, cmd) {
149                "allow_glob match".to_string()
150            } else {
151                "allow_regex match".to_string()
152            }
153        } else if self.matches_prefix(cmd, &self.deny_prefixes) {
154            format!("deny_list match: {}", cmd)
155        } else if Self::matches_any(&self.deny_glob_regexes, cmd) {
156            "deny_glob match".to_string()
157        } else {
158            "deny_regex match".to_string()
159        };
160
161        // Cache the result
162        self.cache_decision(cmd, allowed, &reason);
163
164        let decision = if allowed {
165            PermissionDecision::Allowed
166        } else {
167            PermissionDecision::Denied
168        };
169
170        (allowed, resolved_path, reason, decision)
171    }
172
173    fn matches_prefix(&self, value: &str, prefixes: &[String]) -> bool {
174        prefixes
175            .iter()
176            .filter(|pattern| !pattern.is_empty())
177            .any(|pattern| value.starts_with(pattern))
178    }
179
180    fn matches_any(regexes: &[Regex], value: &str) -> bool {
181        regexes.iter().any(|re| re.is_match(value))
182    }
183}
184
185fn compile_regexes(patterns: &[String]) -> Vec<Regex> {
186    patterns
187        .iter()
188        .filter_map(|pattern| {
189            Regex::new(pattern)
190                .map_err(|error| {
191                    warn!(%error, %pattern, "Ignoring invalid command regex pattern");
192                    error
193                })
194                .ok()
195        })
196        .collect()
197}
198
199fn compile_globs(patterns: &[String]) -> Vec<Regex> {
200    patterns
201        .iter()
202        .filter_map(|pattern| {
203            let escaped = regex::escape(pattern);
204            let glob_regex = format!("^{}$", escaped.replace(r"\*", ".*").replace(r"\?", "."));
205            Regex::new(&glob_regex)
206                .map_err(|error| {
207                    warn!(%error, pattern = %pattern, "Ignoring invalid command glob pattern");
208                    error
209                })
210                .ok()
211        })
212        .collect()
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::config::CommandsConfig;
219
220    #[test]
221    fn glob_allows_cargo_commands() {
222        let mut config = CommandsConfig::default();
223        config.allow_list.clear();
224        config.allow_regex.clear();
225        config.allow_glob = vec!["cargo *".to_string()];
226        let evaluator = CommandPolicyEvaluator::from_config(&config);
227        assert!(evaluator.allows_text("cargo fmt"));
228        assert!(evaluator.allows(&["cargo".into(), "check".into()]));
229    }
230
231    #[test]
232    fn glob_supports_question_mark() {
233        let mut config = CommandsConfig::default();
234        config.allow_list.clear();
235        config.allow_regex.clear();
236        config.allow_glob = vec!["go test ./pkg/?".to_string()];
237        let evaluator = CommandPolicyEvaluator::from_config(&config);
238        assert!(evaluator.allows_text("go test ./pkg/a"));
239        assert!(!evaluator.allows_text("go test ./pkg/ab"));
240    }
241
242    #[test]
243    fn glob_allows_node_ecosystem_commands() {
244        let mut config = CommandsConfig::default();
245        config.allow_list.clear();
246        config.allow_regex.clear();
247        config.allow_glob = vec!["npm *".to_string(), "bun *".to_string()];
248        let evaluator = CommandPolicyEvaluator::from_config(&config);
249        assert!(evaluator.allows_text("npm install"));
250        assert!(evaluator.allows_text("npm run build"));
251        assert!(evaluator.allows_text("bun install"));
252        assert!(evaluator.allows_text("bun run check"));
253    }
254
255    #[test]
256    fn allow_list_allows_exact_git_and_cargo_commands() {
257        let mut config = CommandsConfig::default();
258        // Clear default allow_list to reduce noise
259        config.allow_list.clear();
260        config.allow_list.push("git".to_string());
261        config.allow_list.push("cargo".to_string());
262        let evaluator = CommandPolicyEvaluator::from_config(&config);
263        assert!(evaluator.allows_text("git"));
264        assert!(evaluator.allows_text("cargo"));
265        assert!(evaluator.allows(&["git".into()]));
266        assert!(evaluator.allows(&["cargo".into()]));
267    }
268}