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::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
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 = crate::utils::merge_env_patterns(&config.allow_list, "VTCODE_COMMANDS_ALLOW_LIST");
28 let deny_prefixes = crate::utils::merge_env_patterns(&config.deny_list, "VTCODE_COMMANDS_DENY_LIST")
34 .into_iter()
35 .map(|pattern| {
36 let words: Vec<String> = pattern.split_whitespace().map(str::to_string).collect();
37 crate::tools::command_args::command_words_after_environment_prefix(&words).join(" ")
38 })
39 .collect::<Vec<_>>();
40
41 let allow_regex_patterns = crate::utils::merge_env_patterns(&config.allow_regex, "VTCODE_COMMANDS_ALLOW_REGEX");
42 let deny_regex_patterns = crate::utils::merge_env_patterns(&config.deny_regex, "VTCODE_COMMANDS_DENY_REGEX");
43
44 let allow_glob_patterns = crate::utils::merge_env_patterns(&config.allow_glob, "VTCODE_COMMANDS_ALLOW_GLOB");
45 let deny_glob_patterns = crate::utils::merge_env_patterns(&config.deny_glob, "VTCODE_COMMANDS_DENY_GLOB");
46
47 let allow_regexes = compile_regexes(&allow_regex_patterns);
48 let deny_regexes = compile_regexes(&deny_regex_patterns);
49 let allow_glob_regexes = compile_globs(&allow_glob_patterns);
50 let deny_glob_regexes = compile_globs(&deny_glob_patterns);
51
52 Self {
53 allow_prefixes,
54 deny_prefixes,
55 allow_regexes,
56 deny_regexes,
57 allow_glob_regexes,
58 deny_glob_regexes,
59 allow_regexes_empty: allow_regex_patterns.is_empty(),
60 allow_globs_empty: allow_glob_patterns.is_empty(),
61 resolver: Arc::new(Mutex::new(CommandResolver::new())),
62 cache: Arc::new(Mutex::new(PermissionCache::new())),
63 }
64 }
65
66 fn cached_decision(&self, command_text: &str) -> Option<bool> {
67 self.cache
68 .lock()
69 .unwrap_or_else(|poisoned| {
70 warn!("command_policy: permission cache mutex poisoned; recovering");
71 poisoned.into_inner()
72 })
73 .get(command_text)
74 }
75
76 fn resolve_path(&self, command_text: &str) -> Option<PathBuf> {
77 self.resolver
78 .lock()
79 .unwrap_or_else(|poisoned| {
80 warn!("command_policy: command resolver mutex poisoned; recovering");
81 poisoned.into_inner()
82 })
83 .resolve(command_text)
84 .resolved_path
85 .clone()
86 }
87
88 fn cache_decision(&self, command_text: &str, allowed: bool, reason: &str) {
89 let mut cache = self.cache.lock().unwrap_or_else(|poisoned| {
90 warn!("command_policy: permission cache mutex poisoned; recovering");
91 poisoned.into_inner()
92 });
93 cache.put(command_text, allowed, reason);
94 }
95
96 pub fn allows(&self, command: &[String]) -> bool {
97 if command.is_empty() {
98 return false;
99 }
100 let command_text =
106 shell_script_from_argv(command).unwrap_or_else(|| shell_words::join(command.iter().map(String::as_str)));
107 self.allows_text(&command_text)
108 }
109
110 pub fn allows_text(&self, command_text: &str) -> bool {
111 let cmd = command_text.trim();
112 if cmd.is_empty() {
113 return false;
114 }
115
116 let segments = policy_segments(cmd);
117
118 if segments.iter().any(|segment| {
121 self.matches_prefix(segment, &self.deny_prefixes)
122 || Self::matches_any(&self.deny_regexes, segment)
123 || Self::matches_any(&self.deny_glob_regexes, segment)
124 }) {
125 return false;
126 }
127
128 if self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
130 return true;
131 }
132
133 let verifier_setup = crate::tools::tool_intent::activity::pipefail_verifier_pipeline(cmd).is_some();
138 segments.iter().enumerate().all(|(index, segment)| {
139 (index == 0 && verifier_setup && segment == "set -o pipefail")
140 || self.matches_prefix(segment, &self.allow_prefixes)
141 || Self::matches_any(&self.allow_regexes, segment)
142 || Self::matches_any(&self.allow_glob_regexes, segment)
143 })
144 }
145
146 pub fn evaluate_with_resolution(&self, command_text: &str) -> (bool, Option<PathBuf>, String, PermissionDecision) {
149 let cmd = command_text.trim();
150
151 if let Some(allowed) = self.cached_decision(cmd) {
153 let reason = if allowed {
154 "Cached allow decision"
155 } else {
156 "Cached deny decision"
157 };
158 return (allowed, None, reason.to_string(), PermissionDecision::Cached);
159 }
160
161 let resolved_path = self.resolve_path(cmd);
163
164 let allowed = self.allows_text(cmd);
166
167 let reason = if allowed {
169 if self.matches_prefix(cmd, &self.allow_prefixes) {
170 format!("allow_list match: {cmd}")
171 } else if Self::matches_any(&self.allow_glob_regexes, cmd) {
172 "allow_glob match".to_string()
173 } else {
174 "allow_regex match".to_string()
175 }
176 } else if self.matches_prefix(cmd, &self.deny_prefixes) {
177 format!("deny_list match: {cmd}")
178 } else if Self::matches_any(&self.deny_glob_regexes, cmd) {
179 "deny_glob match".to_string()
180 } else {
181 "deny_regex match".to_string()
182 };
183
184 self.cache_decision(cmd, allowed, &reason);
186
187 let decision = if allowed {
188 PermissionDecision::Allowed
189 } else {
190 PermissionDecision::Denied
191 };
192
193 (allowed, resolved_path, reason, decision)
194 }
195
196 fn matches_prefix(&self, value: &str, prefixes: &[String]) -> bool {
197 prefixes
198 .iter()
199 .filter(|pattern| !pattern.is_empty())
200 .any(|pattern| value.starts_with(pattern))
201 }
202 fn matches_any(regexes: &[Regex], value: &str) -> bool {
203 regexes.iter().any(|re| re.is_match(value))
204 }
205}
206
207fn shell_script_from_argv(command: &[String]) -> Option<String> {
215 let program = command.first()?;
216 let basename = Path::new(program).file_name()?.to_str()?.to_ascii_lowercase();
217 let shell_kind = match basename.as_str() {
218 "sh" | "bash" | "zsh" | "dash" | "ksh" => ShellInvocationKind::Posix,
219 "cmd" | "cmd.exe" => ShellInvocationKind::Cmd,
220 "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => ShellInvocationKind::PowerShell,
221 _ => return None,
222 };
223
224 let flag_index = command.iter().enumerate().skip(1).find_map(|(index, argument)| {
225 let is_command_flag = match shell_kind {
226 ShellInvocationKind::Posix => {
227 argument == "-c"
228 || (argument.starts_with('-')
229 && !argument.starts_with("--")
230 && argument.as_bytes().get(1..).is_some_and(|flags| flags.contains(&b'c')))
231 }
232 ShellInvocationKind::Cmd => argument.eq_ignore_ascii_case("/c") || argument.eq_ignore_ascii_case("/k"),
233 ShellInvocationKind::PowerShell => {
234 matches!(argument.to_ascii_lowercase().as_str(), "-command" | "-c" | "-encodedcommand" | "-e")
235 }
236 };
237 is_command_flag.then_some(index)
238 })?;
239
240 command.get(flag_index + 1).cloned()
241}
242
243pub(crate) fn is_shell_argv(command: &[String]) -> bool {
247 command
248 .first()
249 .and_then(|program| Path::new(program).file_name())
250 .and_then(|name| name.to_str())
251 .is_some_and(|name| {
252 matches!(
253 name.to_ascii_lowercase().as_str(),
254 "sh" | "bash"
255 | "zsh"
256 | "dash"
257 | "ksh"
258 | "cmd"
259 | "cmd.exe"
260 | "powershell"
261 | "powershell.exe"
262 | "pwsh"
263 | "pwsh.exe"
264 )
265 })
266}
267
268#[derive(Clone, Copy)]
269enum ShellInvocationKind {
270 Posix,
271 Cmd,
272 PowerShell,
273}
274
275fn policy_segments(command_text: &str) -> Vec<String> {
280 match crate::command_safety::shell_parser::parse_shell_commands(command_text) {
281 Ok(segments) if !segments.is_empty() => segments
282 .into_iter()
283 .map(|argv| {
284 crate::tools::command_args::command_words_after_environment_prefix(&argv).join(" ")
290 })
291 .collect(),
292 _ => vec![command_text.to_string()],
293 }
294}
295
296fn compile_regexes(patterns: &[String]) -> Vec<Regex> {
297 patterns
298 .iter()
299 .filter_map(|pattern| {
300 Regex::new(pattern)
301 .map_err(|error| {
302 warn!(%error, %pattern, "Ignoring invalid command regex pattern");
303 error
304 })
305 .ok()
306 })
307 .collect()
308}
309
310fn compile_globs(patterns: &[String]) -> Vec<Regex> {
311 patterns
312 .iter()
313 .filter_map(|pattern| {
314 let escaped = regex::escape(pattern);
315 let glob_regex = format!("^{}$", escaped.replace(r"\*", ".*").replace(r"\?", "."));
316 Regex::new(&glob_regex)
317 .map_err(|error| {
318 warn!(%error, pattern = %pattern, "Ignoring invalid command glob pattern");
319 error
320 })
321 .ok()
322 })
323 .collect()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::config::CommandsConfig;
330
331 #[test]
332 fn verifier_pipefail_setup_preserves_stage_permissions_and_denials() {
333 let config = CommandsConfig {
334 allow_list: vec!["cargo check".into(), "grep".into()],
335 allow_regex: vec![],
336 allow_glob: vec![],
337 deny_list: vec![],
338 deny_regex: vec![],
339 deny_glob: vec![],
340 ..Default::default()
341 };
342 let evaluator = CommandPolicyEvaluator::from_config(&config);
343 let valid = "set -o pipefail && cargo check | grep warning";
344 assert!(evaluator.allows_text(valid));
345 for denied in [
346 "set -o pipefail && cargo build | grep warning",
347 "set -o pipefail && cargo check | wc -l",
348 "set -o pipefail && cargo check | grep warning; true",
349 "set +o pipefail && cargo check | grep warning",
350 "set -o pipefail && cargo check | grep $(touch marker)",
351 ] {
352 assert!(!evaluator.allows_text(denied), "{denied}");
353 }
354 for denied_stage in ["set", "cargo", "grep"] {
355 let mut denied = config.clone();
356 denied.deny_list.push(denied_stage.into());
357 assert!(!CommandPolicyEvaluator::from_config(&denied).allows_text(valid), "{denied_stage}");
358 }
359 }
360
361 #[test]
362 fn glob_allows_cargo_commands() {
363 let mut config = CommandsConfig::default();
364 config.allow_list.clear();
365 config.allow_regex.clear();
366 config.allow_glob = vec!["cargo *".to_string()];
367 let evaluator = CommandPolicyEvaluator::from_config(&config);
368 assert!(evaluator.allows_text("cargo fmt"));
369 assert!(evaluator.allows(&["cargo".into(), "check".into()]));
370 }
371
372 #[test]
377 fn environment_prefix_does_not_block_allow_rules() {
378 let prefix = CommandsConfig {
379 allow_list: vec!["rg".into(), "read".into(), "printf".into()],
380 ..Default::default()
381 };
382 let prefixed = CommandPolicyEvaluator::from_config(&prefix);
383 assert!(prefixed.allows(&["LANG=C".into(), "rg".into(), "foo".into()]));
384 assert!(prefixed.allows(&["IFS=".into(), "read".into(), "-r".into(), "line".into()]));
385 assert!(prefixed.allows_text("LANG=C rg foo"));
387
388 let deny = CommandsConfig {
390 allow_list: vec!["rg".into()],
391 deny_list: vec!["rm".into()],
392 ..Default::default()
393 };
394 let denying = CommandPolicyEvaluator::from_config(&deny);
395 assert!(denying.allows(&["LANG=C".into(), "rg".into()]));
396 assert!(!denying.allows(&["FOO=1".into(), "rm".into(), "-rf".into(), "/".into()]));
397 }
398
399 #[test]
405 fn deny_entry_with_env_prefix_matches_stripped_segment() {
406 let config = CommandsConfig {
407 allow_list: vec!["rg".into()],
408 deny_list: vec!["FOO=1 rm".into(), "env rm".into()],
409 ..Default::default()
410 };
411 let evaluator = CommandPolicyEvaluator::from_config(&config);
412 assert!(!evaluator.allows(&["FOO=1".into(), "rm".into(), "-rf".into(), "/".into()]));
414 assert!(!evaluator.allows(&["env".into(), "rm".into(), "x".into()]));
415 assert!(!evaluator.allows_text("FOO=1 rm -rf /"));
416 assert!(evaluator.allows(&["rg".into(), "foo".into()]));
418 assert!(evaluator.allows(&["LANG=C".into(), "rg".into(), "foo".into()]));
419 }
420
421 #[test]
422 fn glob_supports_question_mark() {
423 let mut config = CommandsConfig::default();
424 config.allow_list.clear();
425 config.allow_regex.clear();
426 config.allow_glob = vec!["go test ./pkg/?".to_string()];
427 let evaluator = CommandPolicyEvaluator::from_config(&config);
428 assert!(evaluator.allows_text("go test ./pkg/a"));
429 assert!(!evaluator.allows_text("go test ./pkg/ab"));
430 }
431
432 #[test]
433 fn glob_allows_node_ecosystem_commands() {
434 let mut config = CommandsConfig::default();
435 config.allow_list.clear();
436 config.allow_regex.clear();
437 config.allow_glob = vec!["npm *".to_string(), "bun *".to_string()];
438 let evaluator = CommandPolicyEvaluator::from_config(&config);
439 assert!(evaluator.allows_text("npm install"));
440 assert!(evaluator.allows_text("npm run build"));
441 assert!(evaluator.allows_text("bun install"));
442 assert!(evaluator.allows_text("bun run check"));
443 }
444
445 #[test]
446 fn allow_list_allows_exact_git_and_cargo_commands() {
447 let mut config = CommandsConfig::default();
448 config.allow_list.clear();
450 config.allow_list.push("git".to_string());
451 config.allow_list.push("cargo".to_string());
452 let evaluator = CommandPolicyEvaluator::from_config(&config);
453 assert!(evaluator.allows_text("git"));
454 assert!(evaluator.allows_text("cargo"));
455 assert!(evaluator.allows(&["git".into()]));
456 assert!(evaluator.allows(&["cargo".into()]));
457 }
458
459 #[test]
460 fn shell_wrapper_policy_evaluates_inner_script() {
461 let evaluator = CommandPolicyEvaluator::from_config(&CommandsConfig::default());
462 assert!(evaluator.allows(&["/bin/sh".into(), "-lc".into(), "printf vtcode-terminal".into(),]));
463 assert!(!evaluator.allows(&["/bin/sh".into(), "-lc".into(), "rm -rf /".into(),]));
464 }
465}