Skip to main content

vtcode_core/command_safety/
safe_command_registry.rs

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