1#![expect(
2 clippy::indexing_slicing,
3 reason = "Safe-command validators inspect arguments only after validating the relevant token shape and bounds."
4)]
5
6use hashbrown::HashMap;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum SafetyDecision {
30 Allow,
32 Deny(String),
34 Unknown,
36}
37
38#[derive(Clone)]
40pub struct SafeCommandRegistry {
41 rules: HashMap<String, CommandRule>,
42}
43
44#[derive(Clone)]
46pub struct CommandRule {
47 safe_subcommands: Option<rustc_hash::FxHashSet<String>>,
49 forbidden_options: Vec<String>,
51 custom_check: Option<fn(&[String]) -> SafetyDecision>,
53}
54
55impl CommandRule {
56 pub(crate) fn safe_readonly() -> Self {
58 Self {
59 safe_subcommands: None,
60 forbidden_options: vec![],
61 custom_check: None,
62 }
63 }
64
65 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 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 pub(crate) fn new() -> Self {
92 Self { rules: Self::default_rules() }
93 }
94
95 #[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 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 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 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 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 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 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 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 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 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 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 if !rule.forbidden_options.is_empty() {
235 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 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 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 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 let branch_args = &command[idx + 1..];
290 let is_read_only = branch_args.iter().all(|arg| {
291 let arg = arg.as_str();
292 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 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 SafetyDecision::Deny("git branch with unknown flags requires approval".to_string())
345 }
346 }
347 _ => SafetyDecision::Unknown,
348 }
349 }
350
351 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 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 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 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 fn is_valid_sed_n_arg(arg: &str) -> bool {
404 let Some(core) = arg.strip_suffix('p') else {
406 return false;
407 };
408
409 let parts: Vec<&str> = core.split(',').collect();
411 match parts.as_slice() {
412 [num] => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
414 [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}