vtcode_core/tools/
command_policy.rs1use 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 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 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 self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
106 return true;
107 }
108
109 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 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 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 let resolved_path = self.resolve_path(cmd);
140
141 let allowed = self.allows_text(cmd);
143
144 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 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 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}