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::{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    // 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 = crate::utils::merge_env_patterns(&config.allow_list, "VTCODE_COMMANDS_ALLOW_LIST");
28        // Deny prefix entries are matched against policy segments whose
29        // leading `KEY=value` / `env` words are already stripped, so an entry
30        // authored with an assignment prefix (`FOO=1 rm`) would never match.
31        // Normalize the entry the same way; this is fail-safe because it can
32        // only widen deny matching, never narrow it.
33        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        // Preserve argv boundaries when checking a direct executable. Joining
101        // raw arguments would let punctuation inside an argument (for example
102        // Python's `;` or a quoted string) look like shell operators. Explicit
103        // shell wrappers are handled separately so their script still receives
104        // segment-level validation.
105        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        // Deny takes precedence. Every shell segment is evaluated so chained,
119        // piped, or list-joined commands cannot ride on an earlier match.
120        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 no allow rules defined, allow by default
129        if self.allow_prefixes.is_empty() && self.allow_regexes_empty && self.allow_globs_empty {
130            return true;
131        }
132
133        // Check allow rules: each segment must independently match
134        // The harness's exact, statically validated pipefail setup only makes
135        // status stricter. It needs no executable grant; every pipeline stage
136        // still needs its own grant, and deny rules above include the setup.
137        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    /// Enhanced async evaluation with command resolution and caching
147    /// Returns (allowed, resolved_path, reason, decision)
148    pub fn evaluate_with_resolution(&self, command_text: &str) -> (bool, Option<PathBuf>, String, PermissionDecision) {
149        let cmd = command_text.trim();
150
151        // Check cache first
152        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        // Resolve command to actual path
162        let resolved_path = self.resolve_path(cmd);
163
164        // Evaluate policy
165        let allowed = self.allows_text(cmd);
166
167        // Determine reason - use static strings where possible to avoid allocations
168        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        // Cache the result
185        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
207/// Return the script carried by an explicit shell invocation.
208///
209/// The exec tool accepts both a regular argv request (`["printf", "ok"]`) and
210/// an already-constructed shell request (`["/bin/sh", "-lc", "printf ok"]`).
211/// Command policy must inspect the script in the latter form; matching the
212/// wrapper itself would reject safe commands and could also hide a denied
213/// command behind the shell name.
214fn 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
243/// Return whether argv explicitly names a supported shell, even when it does
244/// not carry a `-c` script. Callers use this to preserve the user's shell
245/// boundary while selecting the policy representation.
246pub(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
275/// Split a command string into its shell segments so prefix/regex policy
276/// rules apply to every chained, piped, or list-joined command rather than
277/// only to the first. Falls back to the whole string when the parser cannot
278/// handle the syntax, preserving the previous whole-string behavior.
279fn 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                // The shell parser keeps leading `KEY=value` assignments as
285                // words so intent/activity classification can inspect injection
286                // keys. For policy matching they are the command's environment,
287                // not its executable: strip them so `FOO=bar rg …` still matches
288                // the allow rule for `rg` (shared helper, single source of truth).
289                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    /// Environment-assignment prefixes are the command's environment, not its
373    /// executable: `FOO=bar rg …` / `IFS= read …` must still match the allow
374    /// rule for the real program. Regression guard for the shell parser keeping
375    /// `KEY=value` words so intent/activity classification can inspect them.
376    #[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        // An explicit shell wrapper carries the prefix inside its script.
386        assert!(prefixed.allows_text("LANG=C rg foo"));
387
388        // A denied program stays denied even behind an assignment prefix.
389        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    /// A deny entry authored *with* an assignment prefix (`FOO=1 rm`) must
400    /// still match: deny entries are compared against segments whose leading
401    /// `KEY=value` words are stripped, so the entry is normalized the same way
402    /// at construction. Fail-safe direction: this can only widen deny
403    /// matching, never narrow it.
404    #[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        // Both prefix forms of the deny entry collapse onto the bare program.
413        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        // The allow rule for `rg` is unaffected by deny-entry normalization.
417        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        // Clear default allow_list to reduce noise
449        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}