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