1use hashbrown::HashMap;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum SafetyDecision {
25 Allow,
27 Deny(String),
29 Unknown,
31}
32
33#[derive(Clone)]
35pub struct SafeCommandRegistry {
36 rules: HashMap<String, CommandRule>,
37}
38
39#[derive(Clone)]
41pub struct CommandRule {
42 safe_subcommands: Option<rustc_hash::FxHashSet<String>>,
44 forbidden_options: Vec<String>,
46 custom_check: Option<fn(&[String]) -> SafetyDecision>,
48}
49
50impl CommandRule {
51 pub fn safe_readonly() -> Self {
53 Self {
54 safe_subcommands: None,
55 forbidden_options: vec![],
56 custom_check: None,
57 }
58 }
59
60 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 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 pub fn new() -> Self {
87 Self {
88 rules: Self::default_rules(),
89 }
90 }
91
92 fn default_rules() -> HashMap<String, CommandRule> {
94 let mut rules = HashMap::new();
95
96 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 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 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 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 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 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 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 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 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 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 if !rule.forbidden_options.is_empty() {
236 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 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 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 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 let branch_args = &command[idx + 1..];
301 let is_read_only = branch_args.iter().all(|arg| {
302 let arg = arg.as_str();
303 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 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 SafetyDecision::Deny(
358 "git branch with unknown flags requires approval".to_string(),
359 )
360 }
361 }
362 _ => SafetyDecision::Unknown,
363 }
364 }
365
366 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 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 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 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 fn is_valid_sed_n_arg(arg: &str) -> bool {
427 let Some(core) = arg.strip_suffix('p') else {
429 return false;
430 };
431
432 let parts: Vec<&str> = core.split(',').collect();
434 match parts.as_slice() {
435 [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
437 [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}