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(crate) fn safe_readonly() -> Self {
53 Self {
54 safe_subcommands: None,
55 forbidden_options: vec![],
56 custom_check: None,
57 }
58 }
59
60 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 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(crate) fn new() -> Self {
87 Self { rules: Self::default_rules() }
88 }
89
90 fn default_rules() -> HashMap<String, CommandRule> {
92 let mut rules = HashMap::new();
93
94 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 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 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 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 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 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 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 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 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 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 if !rule.forbidden_options.is_empty() {
226 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 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 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 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 let branch_args = &command[idx + 1..];
281 let is_read_only = branch_args.iter().all(|arg| {
282 let arg = arg.as_str();
283 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 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 SafetyDecision::Deny("git branch with unknown flags requires approval".to_string())
336 }
337 }
338 _ => SafetyDecision::Unknown,
339 }
340 }
341
342 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 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 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 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 fn is_valid_sed_n_arg(arg: &str) -> bool {
395 let Some(core) = arg.strip_suffix('p') else {
397 return false;
398 };
399
400 let parts: Vec<&str> = core.split(',').collect();
402 match parts.as_slice() {
403 [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
405 [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}