Skip to main content

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