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
25struct PolicyVerdict {
31 allowed: bool,
32 reason: &'static str,
33}
34
35impl CommandPolicyEvaluator {
36 pub fn from_config(config: &CommandsConfig) -> Self {
37 let allow_prefixes = crate::utils::merge_env_patterns(&config.allow_list, "VTCODE_COMMANDS_ALLOW_LIST");
38 let deny_prefixes = crate::utils::merge_env_patterns(&config.deny_list, "VTCODE_COMMANDS_DENY_LIST")
44 .into_iter()
45 .map(|pattern| {
46 let words: Vec<String> = pattern.split_whitespace().map(str::to_string).collect();
47 crate::tools::command_args::command_words_after_environment_prefix(&words).join(" ")
48 })
49 .collect::<Vec<_>>();
50
51 let allow_regex_patterns = crate::utils::merge_env_patterns(&config.allow_regex, "VTCODE_COMMANDS_ALLOW_REGEX");
52 let deny_regex_patterns = crate::utils::merge_env_patterns(&config.deny_regex, "VTCODE_COMMANDS_DENY_REGEX");
53
54 let allow_glob_patterns = crate::utils::merge_env_patterns(&config.allow_glob, "VTCODE_COMMANDS_ALLOW_GLOB");
55 let deny_glob_patterns = crate::utils::merge_env_patterns(&config.deny_glob, "VTCODE_COMMANDS_DENY_GLOB");
56
57 let allow_regexes = compile_regexes(&allow_regex_patterns);
58 let deny_regexes = compile_regexes(&deny_regex_patterns);
59 let allow_glob_regexes = compile_globs(&allow_glob_patterns);
60 let deny_glob_regexes = compile_globs(&deny_glob_patterns);
61
62 Self {
63 allow_prefixes,
64 deny_prefixes,
65 allow_regexes,
66 deny_regexes,
67 allow_glob_regexes,
68 deny_glob_regexes,
69 allow_regexes_empty: allow_regex_patterns.is_empty(),
70 allow_globs_empty: allow_glob_patterns.is_empty(),
71 resolver: Arc::new(Mutex::new(CommandResolver::new())),
72 cache: Arc::new(Mutex::new(PermissionCache::new())),
73 }
74 }
75
76 fn cached_decision(&self, command_text: &str) -> Option<bool> {
77 self.cache
78 .lock()
79 .unwrap_or_else(|poisoned| {
80 warn!("command_policy: permission cache mutex poisoned; recovering");
81 poisoned.into_inner()
82 })
83 .get(command_text)
84 }
85
86 fn resolve_path(&self, command_text: &str) -> Option<PathBuf> {
87 self.resolver
88 .lock()
89 .unwrap_or_else(|poisoned| {
90 warn!("command_policy: command resolver mutex poisoned; recovering");
91 poisoned.into_inner()
92 })
93 .resolve(command_text)
94 .resolved_path
95 .clone()
96 }
97
98 fn cache_decision(&self, command_text: &str, allowed: bool, reason: &str) {
99 let mut cache = self.cache.lock().unwrap_or_else(|poisoned| {
100 warn!("command_policy: permission cache mutex poisoned; recovering");
101 poisoned.into_inner()
102 });
103 cache.put(command_text, allowed, reason);
104 }
105
106 pub fn allows(&self, command: &[String]) -> bool {
107 if command.is_empty() {
108 return false;
109 }
110 let command_text =
116 shell_script_from_argv(command).unwrap_or_else(|| shell_words::join(command.iter().map(String::as_str)));
117 self.allows_text(&command_text)
118 }
119
120 pub fn allows_text(&self, command_text: &str) -> bool {
121 let cmd = command_text.trim();
122 if cmd.is_empty() {
123 return false;
124 }
125
126 self.evaluate_policy(cmd).allowed
127 }
128
129 fn evaluate_policy(&self, cmd: &str) -> PolicyVerdict {
138 let segments = policy_segments(cmd);
139
140 for segment in &segments {
145 if self.matches_prefix(segment, &self.deny_prefixes) {
146 return PolicyVerdict { allowed: false, reason: "deny_list match" };
147 }
148 if Self::matches_any(&self.deny_glob_regexes, segment) {
149 return PolicyVerdict { allowed: false, reason: "deny_glob match" };
150 }
151 if Self::matches_any(&self.deny_regexes, segment) {
152 return PolicyVerdict { allowed: false, reason: "deny_regex match" };
153 }
154 }
155
156 if self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
158 return PolicyVerdict {
159 allowed: true,
160 reason: "default allow (no allow rules)",
161 };
162 }
163
164 let verifier_setup = crate::tools::tool_intent::activity::pipefail_verifier_pipeline(cmd).is_some();
169 let allowed = segments.iter().enumerate().all(|(index, segment)| {
170 (index == 0 && verifier_setup && segment == "set -o pipefail")
171 || self.matches_prefix(segment, &self.allow_prefixes)
172 || Self::matches_any(&self.allow_regexes, segment)
173 || Self::matches_any(&self.allow_glob_regexes, segment)
174 });
175 if !allowed {
176 return PolicyVerdict { allowed: false, reason: "no allow rule matched" };
177 }
178
179 let representative = segments
183 .iter()
184 .find(|segment| *segment != "set -o pipefail")
185 .map(String::as_str)
186 .unwrap_or("");
187 if self.matches_prefix(representative, &self.allow_prefixes) {
188 PolicyVerdict { allowed: true, reason: "allow_list match" }
189 } else if Self::matches_any(&self.allow_glob_regexes, representative) {
190 PolicyVerdict { allowed: true, reason: "allow_glob match" }
191 } else {
192 PolicyVerdict { allowed: true, reason: "allow_regex match" }
193 }
194 }
195
196 pub fn evaluate_with_resolution(&self, command_text: &str) -> (bool, Option<PathBuf>, String, PermissionDecision) {
199 let cmd = command_text.trim();
200
201 if let Some(allowed) = self.cached_decision(cmd) {
203 let reason = if allowed {
204 "Cached allow decision"
205 } else {
206 "Cached deny decision"
207 };
208 return (allowed, None, reason.to_string(), PermissionDecision::Cached);
209 }
210
211 let resolved_path = self.resolve_path(cmd);
213
214 let verdict = self.evaluate_policy(cmd);
217 let reason = verdict.reason.to_string();
218
219 self.cache_decision(cmd, verdict.allowed, &reason);
222
223 let decision = if verdict.allowed {
224 PermissionDecision::Allowed
225 } else {
226 PermissionDecision::Denied
227 };
228
229 (verdict.allowed, resolved_path, reason, decision)
230 }
231
232 fn matches_prefix(&self, value: &str, prefixes: &[String]) -> bool {
233 prefixes
234 .iter()
235 .filter(|pattern| !pattern.is_empty())
236 .any(|pattern| value.starts_with(pattern))
237 }
238 fn matches_any(regexes: &[Regex], value: &str) -> bool {
239 regexes.iter().any(|re| re.is_match(value))
240 }
241}
242
243fn shell_script_from_argv(command: &[String]) -> Option<String> {
251 let program = command.first()?;
252 let basename = Path::new(program).file_name()?.to_str()?.to_ascii_lowercase();
253 let shell_kind = shell_invocation_kind(basename.as_str())?;
254
255 let flag_index = command.iter().enumerate().skip(1).find_map(|(index, argument)| {
256 let is_command_flag = match shell_kind {
257 ShellInvocationKind::Posix => {
258 argument == "-c"
259 || (argument.starts_with('-')
260 && !argument.starts_with("--")
261 && argument.as_bytes().get(1..).is_some_and(|flags| flags.contains(&b'c')))
262 }
263 ShellInvocationKind::Cmd => argument.eq_ignore_ascii_case("/c") || argument.eq_ignore_ascii_case("/k"),
264 ShellInvocationKind::PowerShell => {
265 matches!(argument.to_ascii_lowercase().as_str(), "-command" | "-c" | "-encodedcommand" | "-e")
266 }
267 };
268 is_command_flag.then_some(index)
269 })?;
270
271 command.get(flag_index + 1).cloned()
272}
273
274pub(crate) fn is_shell_argv(command: &[String]) -> bool {
278 command
279 .first()
280 .and_then(|program| Path::new(program).file_name())
281 .and_then(|name| name.to_str())
282 .is_some_and(|name| shell_invocation_kind(name.to_ascii_lowercase().as_str()).is_some())
283}
284
285#[derive(Clone, Copy)]
286enum ShellInvocationKind {
287 Posix,
288 Cmd,
289 PowerShell,
290}
291
292fn shell_invocation_kind(lowered_basename: &str) -> Option<ShellInvocationKind> {
299 match lowered_basename {
300 "sh" | "bash" | "zsh" | "dash" | "ksh" => Some(ShellInvocationKind::Posix),
301 "cmd" | "cmd.exe" => Some(ShellInvocationKind::Cmd),
302 "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => Some(ShellInvocationKind::PowerShell),
303 _ => None,
304 }
305}
306
307fn policy_segments(command_text: &str) -> Vec<String> {
312 match crate::command_safety::shell_parser::parse_shell_commands(command_text) {
313 Ok(segments) if !segments.is_empty() => segments
314 .into_iter()
315 .map(|argv| {
316 crate::tools::command_args::command_words_after_environment_prefix(&argv).join(" ")
322 })
323 .collect(),
324 _ => vec![command_text.to_string()],
325 }
326}
327
328fn compile_regexes(patterns: &[String]) -> Vec<Regex> {
329 patterns
330 .iter()
331 .filter_map(|pattern| {
332 Regex::new(pattern)
333 .map_err(|error| {
334 warn!(%error, %pattern, "Ignoring invalid command regex pattern");
335 error
336 })
337 .ok()
338 })
339 .collect()
340}
341
342fn compile_globs(patterns: &[String]) -> Vec<Regex> {
343 patterns
344 .iter()
345 .filter_map(|pattern| {
346 let escaped = regex::escape(pattern);
347 let glob_regex = format!("^{}$", escaped.replace(r"\*", ".*").replace(r"\?", "."));
348 Regex::new(&glob_regex)
349 .map_err(|error| {
350 warn!(%error, pattern = %pattern, "Ignoring invalid command glob pattern");
351 error
352 })
353 .ok()
354 })
355 .collect()
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::config::CommandsConfig;
362
363 #[test]
364 fn verifier_pipefail_setup_preserves_stage_permissions_and_denials() {
365 let config = CommandsConfig {
366 allow_list: vec!["cargo check".into(), "grep".into()],
367 allow_regex: vec![],
368 allow_glob: vec![],
369 deny_list: vec![],
370 deny_regex: vec![],
371 deny_glob: vec![],
372 ..Default::default()
373 };
374 let evaluator = CommandPolicyEvaluator::from_config(&config);
375 let valid = "set -o pipefail && cargo check | grep warning";
376 assert!(evaluator.allows_text(valid));
377 for denied in [
378 "set -o pipefail && cargo build | grep warning",
379 "set -o pipefail && cargo check | wc -l",
380 "set -o pipefail && cargo check | grep warning; true",
381 "set +o pipefail && cargo check | grep warning",
382 "set -o pipefail && cargo check | grep $(touch marker)",
383 ] {
384 assert!(!evaluator.allows_text(denied), "{denied}");
385 }
386 for denied_stage in ["set", "cargo", "grep"] {
387 let mut denied = config.clone();
388 denied.deny_list.push(denied_stage.into());
389 assert!(!CommandPolicyEvaluator::from_config(&denied).allows_text(valid), "{denied_stage}");
390 }
391 }
392
393 #[test]
394 fn glob_allows_cargo_commands() {
395 let mut config = CommandsConfig::default();
396 config.allow_list.clear();
397 config.allow_regex.clear();
398 config.allow_glob = vec!["cargo *".to_string()];
399 let evaluator = CommandPolicyEvaluator::from_config(&config);
400 assert!(evaluator.allows_text("cargo fmt"));
401 assert!(evaluator.allows(&["cargo".into(), "check".into()]));
402 }
403
404 #[test]
409 fn environment_prefix_does_not_block_allow_rules() {
410 let prefix = CommandsConfig {
411 allow_list: vec!["rg".into(), "read".into(), "printf".into()],
412 ..Default::default()
413 };
414 let prefixed = CommandPolicyEvaluator::from_config(&prefix);
415 assert!(prefixed.allows(&["LANG=C".into(), "rg".into(), "foo".into()]));
416 assert!(prefixed.allows(&["IFS=".into(), "read".into(), "-r".into(), "line".into()]));
417 assert!(prefixed.allows_text("LANG=C rg foo"));
419
420 let deny = CommandsConfig {
422 allow_list: vec!["rg".into()],
423 deny_list: vec!["rm".into()],
424 ..Default::default()
425 };
426 let denying = CommandPolicyEvaluator::from_config(&deny);
427 assert!(denying.allows(&["LANG=C".into(), "rg".into()]));
428 assert!(!denying.allows(&["FOO=1".into(), "rm".into(), "-rf".into(), "/".into()]));
429 }
430
431 #[test]
437 fn deny_entry_with_env_prefix_matches_stripped_segment() {
438 let config = CommandsConfig {
439 allow_list: vec!["rg".into()],
440 deny_list: vec!["FOO=1 rm".into(), "env rm".into()],
441 ..Default::default()
442 };
443 let evaluator = CommandPolicyEvaluator::from_config(&config);
444 assert!(!evaluator.allows(&["FOO=1".into(), "rm".into(), "-rf".into(), "/".into()]));
446 assert!(!evaluator.allows(&["env".into(), "rm".into(), "x".into()]));
447 assert!(!evaluator.allows_text("FOO=1 rm -rf /"));
448 assert!(evaluator.allows(&["rg".into(), "foo".into()]));
450 assert!(evaluator.allows(&["LANG=C".into(), "rg".into(), "foo".into()]));
451 }
452
453 #[test]
454 fn glob_supports_question_mark() {
455 let mut config = CommandsConfig::default();
456 config.allow_list.clear();
457 config.allow_regex.clear();
458 config.allow_glob = vec!["go test ./pkg/?".to_string()];
459 let evaluator = CommandPolicyEvaluator::from_config(&config);
460 assert!(evaluator.allows_text("go test ./pkg/a"));
461 assert!(!evaluator.allows_text("go test ./pkg/ab"));
462 }
463
464 #[test]
465 fn glob_allows_node_ecosystem_commands() {
466 let mut config = CommandsConfig::default();
467 config.allow_list.clear();
468 config.allow_regex.clear();
469 config.allow_glob = vec!["npm *".to_string(), "bun *".to_string()];
470 let evaluator = CommandPolicyEvaluator::from_config(&config);
471 assert!(evaluator.allows_text("npm install"));
472 assert!(evaluator.allows_text("npm run build"));
473 assert!(evaluator.allows_text("bun install"));
474 assert!(evaluator.allows_text("bun run check"));
475 }
476
477 #[test]
478 fn allow_list_allows_exact_git_and_cargo_commands() {
479 let mut config = CommandsConfig::default();
480 config.allow_list.clear();
482 config.allow_list.push("git".to_string());
483 config.allow_list.push("cargo".to_string());
484 let evaluator = CommandPolicyEvaluator::from_config(&config);
485 assert!(evaluator.allows_text("git"));
486 assert!(evaluator.allows_text("cargo"));
487 assert!(evaluator.allows(&["git".into()]));
488 assert!(evaluator.allows(&["cargo".into()]));
489 }
490
491 #[test]
492 fn shell_wrapper_policy_evaluates_inner_script() {
493 let evaluator = CommandPolicyEvaluator::from_config(&CommandsConfig::default());
494 assert!(evaluator.allows(&["/bin/sh".into(), "-lc".into(), "printf vtcode-terminal".into(),]));
495 assert!(!evaluator.allows(&["/bin/sh".into(), "-lc".into(), "rm -rf /".into(),]));
496 }
497
498 #[test]
499 fn shell_argv_and_script_extraction_share_one_shell_table() {
500 for shell in [
503 "sh",
504 "bash",
505 "zsh",
506 "dash",
507 "ksh",
508 "cmd",
509 "cmd.exe",
510 "powershell",
511 "powershell.exe",
512 "pwsh",
513 "pwsh.exe",
514 ] {
515 assert!(is_shell_argv(&[shell.to_string()]), "{shell}");
516 assert!(is_shell_argv(&[format!("/bin/{shell}")]), "/bin/{shell} should resolve by basename");
517 assert!(is_shell_argv(&[shell.to_ascii_uppercase()]), "{shell} matches case-insensitively");
518 }
519 for program in ["rg", "git", "shim", "bashful", ""] {
520 assert!(!is_shell_argv(&[program.to_string()]), "{program} is not a shell");
521 }
522 assert!(is_shell_argv(&["bash".to_string()]));
524 assert_eq!(shell_script_from_argv(&["bash".to_string()]), None);
525 assert_eq!(
526 shell_script_from_argv(&["/bin/bash".to_string(), "-lc".to_string(), "echo hi".to_string()]),
527 Some("echo hi".to_string())
528 );
529 assert_eq!(shell_script_from_argv(&["rg".to_string(), "foo".to_string()]), None);
530 }
531
532 fn policy_config_with(allow: &[&str], deny: &[&str]) -> CommandsConfig {
533 CommandsConfig {
534 allow_list: allow.iter().map(|rule| (*rule).to_string()).collect(),
535 deny_list: deny.iter().map(|rule| (*rule).to_string()).collect(),
536 ..Default::default()
537 }
538 }
539
540 #[test]
541 fn evaluate_with_resolution_agrees_with_allows_text() {
542 let evaluator =
543 CommandPolicyEvaluator::from_config(&policy_config_with(&["cargo check", "cargo", "grep"], &["rm"]));
544 for command in [
545 "cargo check",
546 "rm -rf /",
547 "cargo check | grep warning",
548 "cargo check | rm -rf /",
549 "LANG=C cargo check",
550 "set -o pipefail && cargo check | grep warning",
551 "true",
552 ] {
553 let expected = evaluator.allows_text(command);
554 let (allowed, _, _, decision) = evaluator.evaluate_with_resolution(command);
555 assert_eq!(allowed, expected, "{command}");
556 assert!(matches!(decision, PermissionDecision::Allowed | PermissionDecision::Denied), "{command}");
557 let (cached_allowed, _, _, cached) = evaluator.evaluate_with_resolution(command);
560 assert_eq!(cached_allowed, expected, "{command}");
561 assert!(matches!(cached, PermissionDecision::Cached), "{command}");
562 }
563 }
564
565 #[test]
566 fn evaluate_with_resolution_names_the_denying_stage() {
567 let evaluator =
568 CommandPolicyEvaluator::from_config(&policy_config_with(&["cargo check", "cargo", "grep"], &["rm"]));
569 let (allowed, _, reason, _) = evaluator.evaluate_with_resolution("cargo check | rm -rf /");
573 assert!(!allowed);
574 assert_eq!(reason, "deny_list match");
575 assert!(!reason.contains("cargo check"), "reason must not clone the command");
576 }
577
578 #[test]
579 fn evaluate_with_resolution_reasons_use_static_families() {
580 let prefix = CommandPolicyEvaluator::from_config(&policy_config_with(&["cargo check"], &["rm"]));
581 let (_, _, reason, _) = prefix.evaluate_with_resolution("cargo check");
582 assert_eq!(reason, "allow_list match");
583 let (_, _, reason, _) = prefix.evaluate_with_resolution("rm -rf /");
584 assert_eq!(reason, "deny_list match");
585
586 let mut globbed = CommandsConfig::default();
587 globbed.allow_list.clear();
588 globbed.allow_regex.clear();
589 globbed.allow_glob = vec!["cargo *".to_string()];
590 let globbed = CommandPolicyEvaluator::from_config(&globbed);
591 let (allowed, _, reason, _) = globbed.evaluate_with_resolution("cargo fmt");
592 assert!(allowed);
593 assert_eq!(reason, "allow_glob match");
594
595 let mut regexed = CommandsConfig::default();
596 regexed.allow_list.clear();
597 regexed.allow_glob.clear();
598 regexed.allow_regex = vec!["^cargo\\s+fmt".to_string()];
599 let regexed = CommandPolicyEvaluator::from_config(®exed);
600 let (allowed, _, reason, _) = regexed.evaluate_with_resolution("cargo fmt");
601 assert!(allowed);
602 assert_eq!(reason, "allow_regex match");
603
604 let mut defaulted = CommandsConfig::default();
605 defaulted.allow_list.clear();
606 defaulted.allow_regex.clear();
607 defaulted.allow_glob.clear();
608 let defaulted = CommandPolicyEvaluator::from_config(&defaulted);
609 let (allowed, _, reason, _) = defaulted.evaluate_with_resolution("anything goes");
610 assert!(allowed);
611 assert_eq!(reason, "default allow (no allow rules)");
612 }
613}