use crate::audit::PermissionDecision;
use crate::config::CommandsConfig;
use crate::tools::command_cache::PermissionCache;
use crate::tools::command_resolver::CommandResolver;
use regex::Regex;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tracing::warn;
#[derive(Clone)]
pub struct CommandPolicyEvaluator {
allow_prefixes: Vec<String>,
deny_prefixes: Vec<String>,
allow_regexes: Vec<Regex>,
deny_regexes: Vec<Regex>,
allow_glob_regexes: Vec<Regex>,
deny_glob_regexes: Vec<Regex>,
allow_regexes_empty: bool,
allow_globs_empty: bool,
resolver: Arc<Mutex<CommandResolver>>,
cache: Arc<Mutex<PermissionCache>>,
}
impl CommandPolicyEvaluator {
pub fn from_config(config: &CommandsConfig) -> Self {
let allow_prefixes = crate::utils::merge_env_patterns(&config.allow_list, "VTCODE_COMMANDS_ALLOW_LIST");
let deny_prefixes = crate::utils::merge_env_patterns(&config.deny_list, "VTCODE_COMMANDS_DENY_LIST");
let allow_regex_patterns = crate::utils::merge_env_patterns(&config.allow_regex, "VTCODE_COMMANDS_ALLOW_REGEX");
let deny_regex_patterns = crate::utils::merge_env_patterns(&config.deny_regex, "VTCODE_COMMANDS_DENY_REGEX");
let allow_glob_patterns = crate::utils::merge_env_patterns(&config.allow_glob, "VTCODE_COMMANDS_ALLOW_GLOB");
let deny_glob_patterns = crate::utils::merge_env_patterns(&config.deny_glob, "VTCODE_COMMANDS_DENY_GLOB");
let allow_regexes = compile_regexes(&allow_regex_patterns);
let deny_regexes = compile_regexes(&deny_regex_patterns);
let allow_glob_regexes = compile_globs(&allow_glob_patterns);
let deny_glob_regexes = compile_globs(&deny_glob_patterns);
Self {
allow_prefixes,
deny_prefixes,
allow_regexes,
deny_regexes,
allow_glob_regexes,
deny_glob_regexes,
allow_regexes_empty: allow_regex_patterns.is_empty(),
allow_globs_empty: allow_glob_patterns.is_empty(),
resolver: Arc::new(Mutex::new(CommandResolver::new())),
cache: Arc::new(Mutex::new(PermissionCache::new())),
}
}
fn cached_decision(&self, command_text: &str) -> Option<bool> {
self.cache
.lock()
.unwrap_or_else(|poisoned| {
warn!("command_policy: permission cache mutex poisoned; recovering");
poisoned.into_inner()
})
.get(command_text)
}
fn resolve_path(&self, command_text: &str) -> Option<PathBuf> {
self.resolver
.lock()
.unwrap_or_else(|poisoned| {
warn!("command_policy: command resolver mutex poisoned; recovering");
poisoned.into_inner()
})
.resolve(command_text)
.resolved_path
.clone()
}
fn cache_decision(&self, command_text: &str, allowed: bool, reason: &str) {
let mut cache = self.cache.lock().unwrap_or_else(|poisoned| {
warn!("command_policy: permission cache mutex poisoned; recovering");
poisoned.into_inner()
});
cache.put(command_text, allowed, reason);
}
pub fn allows(&self, command: &[String]) -> bool {
if command.is_empty() {
return false;
}
let command_text =
shell_script_from_argv(command).unwrap_or_else(|| shell_words::join(command.iter().map(String::as_str)));
self.allows_text(&command_text)
}
pub fn allows_text(&self, command_text: &str) -> bool {
let cmd = command_text.trim();
if cmd.is_empty() {
return false;
}
let segments = policy_segments(cmd);
if segments.iter().any(|segment| {
self.matches_prefix(segment, &self.deny_prefixes)
|| Self::matches_any(&self.deny_regexes, segment)
|| Self::matches_any(&self.deny_glob_regexes, segment)
}) {
return false;
}
if self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
return true;
}
segments.iter().all(|segment| {
self.matches_prefix(segment, &self.allow_prefixes)
|| Self::matches_any(&self.allow_regexes, segment)
|| Self::matches_any(&self.allow_glob_regexes, segment)
})
}
pub fn evaluate_with_resolution(&self, command_text: &str) -> (bool, Option<PathBuf>, String, PermissionDecision) {
let cmd = command_text.trim();
if let Some(allowed) = self.cached_decision(cmd) {
let reason = if allowed {
"Cached allow decision"
} else {
"Cached deny decision"
};
return (allowed, None, reason.to_string(), PermissionDecision::Cached);
}
let resolved_path = self.resolve_path(cmd);
let allowed = self.allows_text(cmd);
let reason = if allowed {
if self.matches_prefix(cmd, &self.allow_prefixes) {
format!("allow_list match: {cmd}")
} else if Self::matches_any(&self.allow_glob_regexes, cmd) {
"allow_glob match".to_string()
} else {
"allow_regex match".to_string()
}
} else if self.matches_prefix(cmd, &self.deny_prefixes) {
format!("deny_list match: {cmd}")
} else if Self::matches_any(&self.deny_glob_regexes, cmd) {
"deny_glob match".to_string()
} else {
"deny_regex match".to_string()
};
self.cache_decision(cmd, allowed, &reason);
let decision = if allowed {
PermissionDecision::Allowed
} else {
PermissionDecision::Denied
};
(allowed, resolved_path, reason, decision)
}
fn matches_prefix(&self, value: &str, prefixes: &[String]) -> bool {
prefixes
.iter()
.filter(|pattern| !pattern.is_empty())
.any(|pattern| value.starts_with(pattern))
}
fn matches_any(regexes: &[Regex], value: &str) -> bool {
regexes.iter().any(|re| re.is_match(value))
}
}
fn shell_script_from_argv(command: &[String]) -> Option<String> {
let program = command.first()?;
let basename = Path::new(program).file_name()?.to_str()?.to_ascii_lowercase();
let shell_kind = match basename.as_str() {
"sh" | "bash" | "zsh" | "dash" | "ksh" => ShellInvocationKind::Posix,
"cmd" | "cmd.exe" => ShellInvocationKind::Cmd,
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => ShellInvocationKind::PowerShell,
_ => return None,
};
let flag_index = command.iter().enumerate().skip(1).find_map(|(index, argument)| {
let is_command_flag = match shell_kind {
ShellInvocationKind::Posix => {
argument == "-c"
|| (argument.starts_with('-')
&& !argument.starts_with("--")
&& argument.as_bytes().get(1..).is_some_and(|flags| flags.contains(&b'c')))
}
ShellInvocationKind::Cmd => argument.eq_ignore_ascii_case("/c") || argument.eq_ignore_ascii_case("/k"),
ShellInvocationKind::PowerShell => {
matches!(argument.to_ascii_lowercase().as_str(), "-command" | "-c" | "-encodedcommand" | "-e")
}
};
is_command_flag.then_some(index)
})?;
command.get(flag_index + 1).cloned()
}
pub(crate) fn is_shell_argv(command: &[String]) -> bool {
command
.first()
.and_then(|program| Path::new(program).file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name.to_ascii_lowercase().as_str(),
"sh" | "bash"
| "zsh"
| "dash"
| "ksh"
| "cmd"
| "cmd.exe"
| "powershell"
| "powershell.exe"
| "pwsh"
| "pwsh.exe"
)
})
}
#[derive(Clone, Copy)]
enum ShellInvocationKind {
Posix,
Cmd,
PowerShell,
}
fn policy_segments(command_text: &str) -> Vec<String> {
match crate::command_safety::shell_parser::parse_shell_commands(command_text) {
Ok(segments) if !segments.is_empty() => segments.into_iter().map(|argv| argv.join(" ")).collect(),
_ => vec![command_text.to_string()],
}
}
fn compile_regexes(patterns: &[String]) -> Vec<Regex> {
patterns
.iter()
.filter_map(|pattern| {
Regex::new(pattern)
.map_err(|error| {
warn!(%error, %pattern, "Ignoring invalid command regex pattern");
error
})
.ok()
})
.collect()
}
fn compile_globs(patterns: &[String]) -> Vec<Regex> {
patterns
.iter()
.filter_map(|pattern| {
let escaped = regex::escape(pattern);
let glob_regex = format!("^{}$", escaped.replace(r"\*", ".*").replace(r"\?", "."));
Regex::new(&glob_regex)
.map_err(|error| {
warn!(%error, pattern = %pattern, "Ignoring invalid command glob pattern");
error
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CommandsConfig;
#[test]
fn glob_allows_cargo_commands() {
let mut config = CommandsConfig::default();
config.allow_list.clear();
config.allow_regex.clear();
config.allow_glob = vec!["cargo *".to_string()];
let evaluator = CommandPolicyEvaluator::from_config(&config);
assert!(evaluator.allows_text("cargo fmt"));
assert!(evaluator.allows(&["cargo".into(), "check".into()]));
}
#[test]
fn glob_supports_question_mark() {
let mut config = CommandsConfig::default();
config.allow_list.clear();
config.allow_regex.clear();
config.allow_glob = vec!["go test ./pkg/?".to_string()];
let evaluator = CommandPolicyEvaluator::from_config(&config);
assert!(evaluator.allows_text("go test ./pkg/a"));
assert!(!evaluator.allows_text("go test ./pkg/ab"));
}
#[test]
fn glob_allows_node_ecosystem_commands() {
let mut config = CommandsConfig::default();
config.allow_list.clear();
config.allow_regex.clear();
config.allow_glob = vec!["npm *".to_string(), "bun *".to_string()];
let evaluator = CommandPolicyEvaluator::from_config(&config);
assert!(evaluator.allows_text("npm install"));
assert!(evaluator.allows_text("npm run build"));
assert!(evaluator.allows_text("bun install"));
assert!(evaluator.allows_text("bun run check"));
}
#[test]
fn allow_list_allows_exact_git_and_cargo_commands() {
let mut config = CommandsConfig::default();
config.allow_list.clear();
config.allow_list.push("git".to_string());
config.allow_list.push("cargo".to_string());
let evaluator = CommandPolicyEvaluator::from_config(&config);
assert!(evaluator.allows_text("git"));
assert!(evaluator.allows_text("cargo"));
assert!(evaluator.allows(&["git".into()]));
assert!(evaluator.allows(&["cargo".into()]));
}
#[test]
fn shell_wrapper_policy_evaluates_inner_script() {
let evaluator = CommandPolicyEvaluator::from_config(&CommandsConfig::default());
assert!(evaluator.allows(&["/bin/sh".into(), "-lc".into(), "printf vtcode-terminal".into(),]));
assert!(!evaluator.allows(&["/bin/sh".into(), "-lc".into(), "rm -rf /".into(),]));
}
}