Skip to main content

vtcode_safety/command_safety/
safe_command_registry.rs

1#![expect(
2    clippy::indexing_slicing,
3    reason = "Safe-command validators inspect arguments only after validating the relevant token shape and bounds."
4)]
5
6//! Safe command registry: defines which commands and subcommands are safe to execute.
7//!
8//! This module implements the "safe-by-subcommand" pattern from Codex:
9//! Instead of blocking entire commands, we maintain granular allowlists
10//! of safe subcommands and forbid specific dangerous options.
11//!
12//! Example:
13//! ```text
14//! git branch     ✓ safe (read-only)
15//! git reset      ✗ dangerous (destructive)
16//! git status     ✓ safe (read-only)
17//!
18//! find .         ✓ safe
19//! find . -delete ✗ dangerous (has -delete option)
20//!
21//! cargo check    ✓ safe (read-only check)
22//! cargo clean    ✗ dangerous (destructive)
23//! ```
24
25use hashbrown::HashMap;
26
27/// Result of a command safety check
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[must_use = "command safety decisions must be enforced"]
30pub enum SafetyDecision {
31    /// Command is safe to execute
32    Allow,
33    /// Command is dangerous and should be blocked
34    Deny(String),
35    /// Safety status unknown; defer to policy evaluator
36    Unknown,
37}
38
39/// Registry of safe commands and their safe subcommands/options
40#[derive(Clone)]
41pub struct SafeCommandRegistry {
42    rules: HashMap<String, CommandRule>,
43}
44
45/// A rule for when a command is safe
46#[derive(Clone)]
47pub struct CommandRule {
48    /// If Some, only these subcommands are allowed
49    safe_subcommands: Option<rustc_hash::FxHashSet<String>>,
50    /// These options make a command unsafe (e.g., "-delete" for find)
51    forbidden_options: Vec<String>,
52    /// Custom validation function for complex logic
53    custom_check: Option<fn(&[String]) -> SafetyDecision>,
54}
55
56impl CommandRule {
57    /// Creates a read-only safe command rule
58    pub(crate) fn safe_readonly() -> Self {
59        Self {
60            safe_subcommands: None,
61            forbidden_options: vec![],
62            custom_check: None,
63        }
64    }
65
66    /// Creates a rule with allowed subcommands
67    pub(crate) fn with_allowed_subcommands(subcommands: Vec<&str>) -> Self {
68        Self {
69            safe_subcommands: Some(
70                subcommands
71                    .into_iter()
72                    .map(|s| s.to_string())
73                    .collect::<rustc_hash::FxHashSet<_>>(),
74            ),
75            forbidden_options: vec![],
76            custom_check: None,
77        }
78    }
79
80    /// Creates a rule with forbidden options
81    pub fn with_forbidden_options(options: Vec<&str>) -> Self {
82        Self {
83            safe_subcommands: None,
84            forbidden_options: options.into_iter().map(|s| s.to_string()).collect(),
85            custom_check: None,
86        }
87    }
88}
89
90impl SafeCommandRegistry {
91    /// Creates a new empty registry
92    pub(crate) fn new() -> Self {
93        Self { rules: Self::default_rules() }
94    }
95
96    /// Builds the default safe command rules (Codex patterns + VT Code extensions)
97    #[expect(
98        unused_results,
99        reason = "The built-in registry intentionally ignores prior values while constructing a fresh command map."
100    )]
101    fn default_rules() -> HashMap<String, CommandRule> {
102        let mut rules = HashMap::new();
103
104        // ──── Git (safe: status, log, diff, show; branch is conditionally safe) ────
105        // Note: git branch is NOT in the safe list because branch deletion (-d/-D/--delete)
106        // is destructive. Only read-only branch operations (--show-current, --list) are safe.
107        rules.insert(
108            "git".to_string(),
109            CommandRule {
110                safe_subcommands: Some(
111                    vec!["status", "log", "diff", "show"]
112                        .into_iter()
113                        .map(|s| s.to_string())
114                        .collect(),
115                ),
116                forbidden_options: vec![],
117                custom_check: Some(Self::check_git),
118            },
119        );
120
121        // ──── Cargo (safe: check, build, clippy, fmt --check) ────
122        rules.insert(
123            "cargo".to_string(),
124            CommandRule {
125                safe_subcommands: Some(vec!["check", "build", "clippy"].into_iter().map(|s| s.to_string()).collect()),
126                forbidden_options: vec![],
127                custom_check: Some(Self::check_cargo),
128            },
129        );
130
131        // ──── Find (forbid: -exec, -delete, -fls, -fprint*, -fprintf) ────
132        rules.insert(
133            "find".to_string(),
134            CommandRule {
135                safe_subcommands: None,
136                forbidden_options: vec![
137                    "-exec".to_string(),
138                    "-execdir".to_string(),
139                    "-ok".to_string(),
140                    "-okdir".to_string(),
141                    "-delete".to_string(),
142                    "-fls".to_string(),
143                    "-fprint".to_string(),
144                    "-fprint0".to_string(),
145                    "-fprintf".to_string(),
146                ],
147                custom_check: None,
148            },
149        );
150
151        // ──── Base64 (forbid: -o, --output) ────
152        rules.insert(
153            "base64".to_string(),
154            CommandRule {
155                safe_subcommands: None,
156                forbidden_options: vec!["-o".to_string(), "--output".to_string()],
157                custom_check: Some(Self::check_base64),
158            },
159        );
160
161        // ──── Sed (only allow -n {N|M,N}p pattern) ────
162        rules.insert(
163            "sed".to_string(),
164            CommandRule {
165                safe_subcommands: None,
166                forbidden_options: vec![],
167                custom_check: Some(Self::check_sed),
168            },
169        );
170
171        // ──── Ripgrep (forbid: --pre, --hostname-bin, -z, --search-zip) ────
172        rules.insert(
173            "rg".to_string(),
174            CommandRule {
175                safe_subcommands: None,
176                forbidden_options: vec![
177                    "--pre".to_string(),
178                    "--hostname-bin".to_string(),
179                    "--search-zip".to_string(),
180                    "-z".to_string(),
181                ],
182                custom_check: None,
183            },
184        );
185
186        // ──── Safe read-only tools ────
187        for cmd in &[
188            "cat", "ls", "pwd", "echo", "grep", "head", "tail", "wc", "tr", "cut", "paste", "sort", "uniq", "rev",
189            "seq", "expr", "uname", "whoami", "id", "stat", "which",
190        ] {
191            rules.insert(
192                cmd.to_string(),
193                CommandRule {
194                    safe_subcommands: None,
195                    forbidden_options: vec![],
196                    custom_check: None,
197                },
198            );
199        }
200
201        rules
202    }
203
204    /// Checks if a command is safe
205    pub(crate) fn is_safe(&self, command: &[String]) -> SafetyDecision {
206        if command.is_empty() {
207            return SafetyDecision::Unknown;
208        }
209
210        let cmd_name = Self::extract_command_name(&command[0]);
211        let Some(rule) = self.rules.get(cmd_name) else {
212            return SafetyDecision::Unknown;
213        };
214
215        // Run custom check if defined
216        if let Some(check_fn) = rule.custom_check {
217            let result = check_fn(command);
218            if result != SafetyDecision::Unknown {
219                return result;
220            }
221        }
222
223        // Check safe subcommands (if restricted list exists)
224        if let Some(ref safe_subs) = rule.safe_subcommands {
225            if command.len() < 2 {
226                return SafetyDecision::Deny(format!("Command {cmd_name} requires a subcommand"));
227            }
228            let subcommand = &command[1];
229            if !safe_subs.contains(subcommand) {
230                return SafetyDecision::Deny(format!("Subcommand {subcommand} not in safe list for {cmd_name}"));
231            }
232        }
233
234        // Check forbidden options
235        if !rule.forbidden_options.is_empty() {
236            // Pre-calculate forbidden prefixes to avoid allocations in the loop
237            let forbidden_with_eq: Vec<String> = rule.forbidden_options.iter().map(|opt| format!("{opt}=")).collect();
238
239            for arg in command {
240                for (forbidden, forbidden_eq) in rule.forbidden_options.iter().zip(forbidden_with_eq.iter()) {
241                    if arg == forbidden || arg.starts_with(forbidden_eq) {
242                        return SafetyDecision::Deny(format!("Option {forbidden} is not allowed for {cmd_name}"));
243                    }
244                }
245            }
246        }
247
248        SafetyDecision::Allow
249    }
250
251    /// Extract base command name from full path (e.g., "/usr/bin/git" -> "git")
252    fn extract_command_name(cmd: &str) -> &str {
253        std::path::Path::new(cmd)
254            .file_name()
255            .and_then(|osstr| osstr.to_str())
256            .unwrap_or(cmd)
257    }
258
259    // ──── Custom Checks ────
260
261    /// Git: allow status, log, diff, show; branch only for read-only operations
262    fn check_git(command: &[String]) -> SafetyDecision {
263        if command.len() < 2 {
264            return SafetyDecision::Unknown;
265        }
266
267        if command
268            .iter()
269            .skip(1)
270            .map(String::as_str)
271            .any(crate::command_safety::dangerous_commands::git_global_option_requires_prompt)
272        {
273            return SafetyDecision::Deny(
274                "git global options that redirect config, repository, or helper lookup are not allowed".to_string(),
275            );
276        }
277
278        // Use the shared git subcommand finder to skip global options
279        let subcommands = &["status", "log", "diff", "show", "branch"];
280        let Some((idx, subcommand)) =
281            crate::command_safety::dangerous_commands::find_git_subcommand(command, subcommands)
282        else {
283            return SafetyDecision::Unknown;
284        };
285
286        match subcommand {
287            "status" | "log" | "diff" | "show" => SafetyDecision::Allow,
288            "branch" => {
289                // Only allow read-only branch operations
290                let branch_args = &command[idx + 1..];
291                let is_read_only = branch_args.iter().all(|arg| {
292                    let arg = arg.as_str();
293                    // Safe: --show-current, --list, -l (list), -v (verbose), -a (all), -r (remote)
294                    // Unsafe: -d, -D, --delete, -m, -M, --move, -c, -C, --create
295                    matches!(
296                        arg,
297                        "--show-current"
298                            | "--list"
299                            | "-l"
300                            | "-v"
301                            | "-vv"
302                            | "-a"
303                            | "-r"
304                            | "--all"
305                            | "--remote"
306                            | "--verbose"
307                            | "--format"
308                    ) || arg.starts_with("--format=")
309                        || arg.starts_with("--sort=")
310                        || arg.starts_with("--contains=")
311                        || arg.starts_with("--no-contains=")
312                        || arg.starts_with("--merged=")
313                        || arg.starts_with("--no-merged=")
314                        || arg.starts_with("--points-at=")
315                });
316
317                // Also check for any delete/move/create flags
318                let has_dangerous_flag = branch_args.iter().any(|arg| {
319                    let arg = arg.as_str();
320                    matches!(
321                        arg,
322                        "-d" | "-D"
323                            | "--delete"
324                            | "-m"
325                            | "-M"
326                            | "--move"
327                            | "-c"
328                            | "-C"
329                            | "--create"
330                            | "--set-upstream"
331                            | "--set-upstream-to"
332                            | "--unset-upstream"
333                    ) || arg.starts_with("--delete=")
334                        || arg.starts_with("--move=")
335                        || arg.starts_with("--create=")
336                        || arg.starts_with("--set-upstream-to=")
337                });
338
339                if has_dangerous_flag {
340                    SafetyDecision::Deny("git branch with modification flags is not allowed".to_string())
341                } else if is_read_only || branch_args.is_empty() {
342                    SafetyDecision::Allow
343                } else {
344                    // Unknown flags - be conservative
345                    SafetyDecision::Deny("git branch with unknown flags requires approval".to_string())
346                }
347            }
348            _ => SafetyDecision::Unknown,
349        }
350    }
351
352    /// Cargo: allow check, build, clippy
353    fn check_cargo(command: &[String]) -> SafetyDecision {
354        if command.len() < 2 {
355            return SafetyDecision::Unknown;
356        }
357        match command[1].as_str() {
358            "check" | "build" | "clippy" => SafetyDecision::Allow,
359            "fmt" => {
360                // cargo fmt --check is safe (read-only)
361                if command.contains(&"--check".to_string()) {
362                    SafetyDecision::Allow
363                } else {
364                    SafetyDecision::Deny("cargo fmt without --check is not allowed".to_string())
365                }
366            }
367            _ => SafetyDecision::Deny(format!("cargo {} is not in safe subcommand list", command[1])),
368        }
369    }
370
371    /// Base64: forbid output redirection
372    fn check_base64(command: &[String]) -> SafetyDecision {
373        const UNSAFE_OPTIONS: &[&str] = &["-o", "--output"];
374
375        for arg in command.iter().skip(1) {
376            if UNSAFE_OPTIONS.contains(&arg.as_str()) {
377                return SafetyDecision::Deny(format!("base64 {arg} is not allowed (output redirection)"));
378            }
379            if arg.starts_with("--output=") || (arg.starts_with("-o") && arg != "-o") {
380                return SafetyDecision::Deny("base64 output redirection is not allowed".to_string());
381            }
382        }
383        SafetyDecision::Unknown
384    }
385
386    /// Sed: only allow `-n {N|M,N}p` pattern
387    fn check_sed(command: &[String]) -> SafetyDecision {
388        if command.len() <= 2 {
389            return SafetyDecision::Unknown;
390        }
391
392        if command.len() <= 4
393            && command.get(1).map(|s| s.as_str()) == Some("-n")
394            && let Some(pattern) = command.get(2)
395            && Self::is_valid_sed_n_arg(pattern)
396        {
397            return SafetyDecision::Allow;
398        }
399
400        SafetyDecision::Deny("sed only allows safe pattern: sed -n {N|M,N}p".to_string())
401    }
402
403    /// Helper: validate sed -n pattern
404    fn is_valid_sed_n_arg(arg: &str) -> bool {
405        // Pattern must end with 'p'
406        let Some(core) = arg.strip_suffix('p') else {
407            return false;
408        };
409
410        // Split on ',' and validate
411        let parts: Vec<&str> = core.split(',').collect();
412        match parts.as_slice() {
413            // Single number: e.g., "10"
414            [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
415            // Range: e.g., "1,5"
416            [a, b] => {
417                !a.is_empty()
418                    && !b.is_empty()
419                    && a.chars().all(|c| c.is_ascii_digit())
420                    && b.chars().all(|c| c.is_ascii_digit())
421            }
422            _ => false,
423        }
424    }
425}
426
427impl Default for SafeCommandRegistry {
428    fn default() -> Self {
429        Self::new()
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn git_status_is_safe() {
439        let registry = SafeCommandRegistry::new();
440        let cmd = vec!["git".to_string(), "status".to_string()];
441        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
442    }
443
444    #[test]
445    fn git_global_options_require_approval() {
446        let registry = SafeCommandRegistry::new();
447
448        for cmd in [
449            vec![
450                "git".to_string(),
451                "-c".to_string(),
452                "core.pager=cat".to_string(),
453                "show".to_string(),
454                "HEAD:foo.rs".to_string(),
455            ],
456            vec![
457                "git".to_string(),
458                "--config-env".to_string(),
459                "core.pager=PAGER".to_string(),
460                "show".to_string(),
461                "HEAD".to_string(),
462            ],
463            vec![
464                "git".to_string(),
465                "--git-dir=.evil-git".to_string(),
466                "diff".to_string(),
467                "HEAD~1..HEAD".to_string(),
468            ],
469            vec![
470                "git".to_string(),
471                "--work-tree".to_string(),
472                ".".to_string(),
473                "status".to_string(),
474            ],
475            vec![
476                "git".to_string(),
477                "--exec-path=.git/helpers".to_string(),
478                "show".to_string(),
479                "HEAD".to_string(),
480            ],
481            vec![
482                "git".to_string(),
483                "--namespace=attacker".to_string(),
484                "show".to_string(),
485                "HEAD".to_string(),
486            ],
487            vec![
488                "git".to_string(),
489                "--super-prefix=attacker/".to_string(),
490                "show".to_string(),
491                "HEAD".to_string(),
492            ],
493        ] {
494            assert!(
495                matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)),
496                "expected {cmd:?} to require approval due to unsafe git global option",
497            );
498        }
499    }
500
501    #[test]
502    fn git_reset_is_dangerous() {
503        let registry = SafeCommandRegistry::new();
504        let cmd = vec!["git".to_string(), "reset".to_string()];
505        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
506    }
507
508    #[test]
509    fn cargo_check_is_safe() {
510        let registry = SafeCommandRegistry::new();
511        let cmd = vec!["cargo".to_string(), "check".to_string()];
512        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
513    }
514
515    #[test]
516    fn cargo_clean_is_dangerous() {
517        let registry = SafeCommandRegistry::new();
518        let cmd = vec!["cargo".to_string(), "clean".to_string()];
519        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
520    }
521
522    #[test]
523    fn cargo_fmt_without_check_is_dangerous() {
524        let registry = SafeCommandRegistry::new();
525        let cmd = vec!["cargo".to_string(), "fmt".to_string()];
526        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
527    }
528
529    #[test]
530    fn cargo_fmt_with_check_is_safe() {
531        let registry = SafeCommandRegistry::new();
532        let cmd = vec!["cargo".to_string(), "fmt".to_string(), "--check".to_string()];
533        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
534    }
535
536    #[test]
537    fn find_without_dangerous_options_is_allowed() {
538        let registry = SafeCommandRegistry::new();
539        let cmd = vec!["find".to_string(), ".".to_string()];
540        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
541    }
542
543    #[test]
544    fn find_with_delete_is_dangerous() {
545        let registry = SafeCommandRegistry::new();
546        let cmd = vec!["find".to_string(), ".".to_string(), "-delete".to_string()];
547        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
548    }
549
550    #[test]
551    fn find_with_exec_is_dangerous() {
552        let registry = SafeCommandRegistry::new();
553        let cmd = vec![
554            "find".to_string(),
555            ".".to_string(),
556            "-exec".to_string(),
557            "rm".to_string(),
558        ];
559        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
560    }
561
562    #[test]
563    fn base64_without_output_is_allowed() {
564        let registry = SafeCommandRegistry::new();
565        let cmd = vec!["base64".to_string(), "file.txt".to_string()];
566        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
567    }
568
569    #[test]
570    fn base64_with_output_is_dangerous() {
571        let registry = SafeCommandRegistry::new();
572        let cmd = vec![
573            "base64".to_string(),
574            "file.txt".to_string(),
575            "-o".to_string(),
576            "output.txt".to_string(),
577        ];
578        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
579    }
580
581    #[test]
582    fn sed_n_single_line_is_safe() {
583        let registry = SafeCommandRegistry::new();
584        let cmd = vec!["sed".to_string(), "-n".to_string(), "10p".to_string()];
585        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
586    }
587
588    #[test]
589    fn sed_n_range_is_safe() {
590        let registry = SafeCommandRegistry::new();
591        let cmd = vec!["sed".to_string(), "-n".to_string(), "1,5p".to_string()];
592        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
593    }
594
595    #[test]
596    fn sed_without_n_is_allowed() {
597        let registry = SafeCommandRegistry::new();
598        let cmd = vec!["sed".to_string(), "s/foo/bar/g".to_string()];
599        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
600    }
601
602    #[test]
603    fn rg_with_pre_is_dangerous() {
604        let registry = SafeCommandRegistry::new();
605        let cmd = vec![
606            "rg".to_string(),
607            "--pre".to_string(),
608            "some_command".to_string(),
609            "pattern".to_string(),
610        ];
611        assert!(matches!(registry.is_safe(&cmd), SafetyDecision::Deny(_)));
612    }
613
614    #[test]
615    fn cat_is_always_safe() {
616        let registry = SafeCommandRegistry::new();
617        let cmd = vec!["cat".to_string(), "file.txt".to_string()];
618        assert_eq!(registry.is_safe(&cmd), SafetyDecision::Allow);
619    }
620
621    #[test]
622    fn extract_command_name_from_path() {
623        assert_eq!(SafeCommandRegistry::extract_command_name("/usr/bin/git"), "git");
624        assert_eq!(SafeCommandRegistry::extract_command_name("/usr/local/bin/cargo"), "cargo");
625        assert_eq!(SafeCommandRegistry::extract_command_name("git"), "git");
626    }
627}