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
25/// Verdict of one per-segment policy evaluation.
26///
27/// `reason` names the rule family that decided, using static strings: the
28/// command itself is never cloned into it (it is already the cache key and
29/// travels as a separate audit field).
30struct 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        // Deny prefix entries are matched against policy segments whose
39        // leading `KEY=value` / `env` words are already stripped, so an entry
40        // authored with an assignment prefix (`FOO=1 rm`) would never match.
41        // Normalize the entry the same way; this is fail-safe because it can
42        // only widen deny matching, never narrow it.
43        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        // Preserve argv boundaries when checking a direct executable. Joining
111        // raw arguments would let punctuation inside an argument (for example
112        // Python's `;` or a quoted string) look like shell operators. Explicit
113        // shell wrappers are handled separately so their script still receives
114        // segment-level validation.
115        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    /// Evaluate deny/allow rules once per shell segment.
130    ///
131    /// Single source of truth for [`Self::allows_text`] and
132    /// [`Self::evaluate_with_resolution`], so the reported reason can never
133    /// disagree with the verdict. (The old resolution path re-matched the
134    /// whole command string for its reason while the verdict matched per
135    /// segment, so a denied stage in `cargo check | rm x` was reported as a
136    /// regex match instead of the deny-list hit.)
137    fn evaluate_policy(&self, cmd: &str) -> PolicyVerdict {
138        let segments = policy_segments(cmd);
139
140        // Deny takes precedence. Every shell segment is evaluated so chained,
141        // piped, or list-joined commands cannot ride on an earlier match. The
142        // first denying segment names its family; priority matches the
143        // historical reason order (prefix, glob, regex).
144        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 no allow rules defined, allow by default
157        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        // Check allow rules: each segment must independently match
165        // The harness's exact, statically validated pipefail setup only makes
166        // status stricter. It needs no executable grant; every pipeline stage
167        // still needs its own grant, and deny rules above include the setup.
168        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        // Name the first real stage's family as the representative reason (the
180        // pipefail setup line itself carries no grant). Single-stage commands,
181        // the common case, are exact.
182        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    /// Enhanced async evaluation with command resolution and caching
197    /// Returns (allowed, resolved_path, reason, decision)
198    pub fn evaluate_with_resolution(&self, command_text: &str) -> (bool, Option<PathBuf>, String, PermissionDecision) {
199        let cmd = command_text.trim();
200
201        // Check cache first
202        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        // Resolve command to actual path
212        let resolved_path = self.resolve_path(cmd);
213
214        // Verdict and reason come from the same per-segment evaluation, so
215        // they cannot disagree and no second whole-command regex pass runs.
216        let verdict = self.evaluate_policy(cmd);
217        let reason = verdict.reason.to_string();
218
219        // Cache the result (the cache stores only the verdict; the reason is
220        // the return value, so no command text is cloned into it).
221        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
243/// Return the script carried by an explicit shell invocation.
244///
245/// The exec tool accepts both a regular argv request (`["printf", "ok"]`) and
246/// an already-constructed shell request (`["/bin/sh", "-lc", "printf ok"]`).
247/// Command policy must inspect the script in the latter form; matching the
248/// wrapper itself would reject safe commands and could also hide a denied
249/// command behind the shell name.
250fn 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
274/// Return whether argv explicitly names a supported shell, even when it does
275/// not carry a `-c` script. Callers use this to preserve the user's shell
276/// boundary while selecting the policy representation.
277pub(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
292/// Single source of truth for the shells command policy understands.
293///
294/// Both [`shell_script_from_argv`] and [`is_shell_argv`] branch on this, so a
295/// newly supported shell cannot be added to one path but missed in the other
296/// (which would hide a denied command behind the shell name or reject a safe
297/// command). Input must already be lowercased.
298fn 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
307/// Split a command string into its shell segments so prefix/regex policy
308/// rules apply to every chained, piped, or list-joined command rather than
309/// only to the first. Falls back to the whole string when the parser cannot
310/// handle the syntax, preserving the previous whole-string behavior.
311fn 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                // The shell parser keeps leading `KEY=value` assignments as
317                // words so intent/activity classification can inspect injection
318                // keys. For policy matching they are the command's environment,
319                // not its executable: strip them so `FOO=bar rg …` still matches
320                // the allow rule for `rg` (shared helper, single source of truth).
321                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    /// Environment-assignment prefixes are the command's environment, not its
405    /// executable: `FOO=bar rg …` / `IFS= read …` must still match the allow
406    /// rule for the real program. Regression guard for the shell parser keeping
407    /// `KEY=value` words so intent/activity classification can inspect them.
408    #[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        // An explicit shell wrapper carries the prefix inside its script.
418        assert!(prefixed.allows_text("LANG=C rg foo"));
419
420        // A denied program stays denied even behind an assignment prefix.
421        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    /// A deny entry authored *with* an assignment prefix (`FOO=1 rm`) must
432    /// still match: deny entries are compared against segments whose leading
433    /// `KEY=value` words are stripped, so the entry is normalized the same way
434    /// at construction. Fail-safe direction: this can only widen deny
435    /// matching, never narrow it.
436    #[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        // Both prefix forms of the deny entry collapse onto the bare program.
445        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        // The allow rule for `rg` is unaffected by deny-entry normalization.
449        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        // Clear default allow_list to reduce noise
481        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        // Every supported shell is recognized with and without a `-c` script;
501        // near-miss names (`shim`) and regular programs are not shells.
502        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        // A bare shell names the boundary without carrying a script.
523        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            // The verdict is cached: a repeat reports the same outcome as a
558            // cache hit without re-evaluating.
559            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        // The denied stage is `rm`, even though the whole command starts with
570        // an allowed program: the old whole-string reason reported this as a
571        // regex match instead of the deny-list hit.
572        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(&regexed);
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}