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