1mod assessment;
9
10use serde::{Deserialize, Serialize};
11
12use super::{
13 EnvironmentSensitivity, ImpactScope, OperationTarget, PermissionChecker, PermissionDecision,
14 Reversibility, ToolRiskAction, ToolRiskAssessment, ToolRiskLevel, ToolRiskReason,
15};
16use assessment::{assess_tool, assessment_permission, critical_assessment, tool_risk_type};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum InteractiveApprovalMode {
22 Default,
24 Plan,
26 Auto,
28 Force,
31}
32
33impl InteractiveApprovalMode {
34 pub fn from_name(value: &str) -> Self {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "plan" => Self::Plan,
37 "auto" => Self::Auto,
38 "force" | "yolo" => Self::Force,
39 _ => Self::Default,
40 }
41 }
42
43 pub const fn action_for(self, assessment: &ToolRiskAssessment) -> ToolRiskAction {
50 match (self, assessment.level) {
51 (_, ToolRiskLevel::Routine) => ToolRiskAction::Allow,
52 (Self::Auto | Self::Force, ToolRiskLevel::Bounded) => ToolRiskAction::Allow,
53 (_, ToolRiskLevel::Bounded) => ToolRiskAction::RequireConfirmation,
54 (Self::Force, ToolRiskLevel::High) => ToolRiskAction::Allow,
55 (_, ToolRiskLevel::High) => ToolRiskAction::ReviewByLlm,
56 (_, ToolRiskLevel::Critical) => ToolRiskAction::RuleDeny,
57 }
58 }
59
60 fn apply(self, assessment: &ToolRiskAssessment) -> PermissionDecision {
61 match self.action_for(assessment) {
62 ToolRiskAction::Allow => PermissionDecision::Allow,
63 ToolRiskAction::RequireConfirmation | ToolRiskAction::ReviewByLlm => {
64 PermissionDecision::Ask
68 }
69 ToolRiskAction::RuleDeny => PermissionDecision::Deny,
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InteractiveToolGuardrail {
77 mode: InteractiveApprovalMode,
78 workspace: Option<std::path::PathBuf>,
79}
80
81impl InteractiveToolGuardrail {
82 pub const fn new(mode: InteractiveApprovalMode) -> Self {
83 Self {
84 mode,
85 workspace: None,
86 }
87 }
88
89 pub fn for_mode(mode: &str) -> Self {
90 Self::new(InteractiveApprovalMode::from_name(mode))
91 }
92
93 pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
95 self.workspace = Some(workspace.into());
96 self
97 }
98
99 pub fn risk_assessment(tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
101 assess_tool(tool_name, args)
102 }
103
104 pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
110 assessment_permission(&assess_tool(tool_name, args))
111 }
112
113 pub fn is_catastrophic_bash_command(command: &str) -> bool {
121 is_catastrophic_bash_command(command)
122 }
123
124 pub fn assess(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
126 if let Some(assessment) = self.workspace_boundary_assessment(tool_name, args) {
127 return assessment;
128 }
129 assess_tool(tool_name, args)
130 }
131
132 pub fn risk_action(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAction {
134 self.mode.action_for(&self.assess(tool_name, args))
135 }
136
137 fn workspace_boundary_assessment(
138 &self,
139 tool_name: &str,
140 args: &serde_json::Value,
141 ) -> Option<ToolRiskAssessment> {
142 let root = self.workspace.as_deref()?;
143 invocation_crosses_local_symlink(root, tool_name, args).then(|| {
144 critical_assessment(
145 tool_risk_type(tool_name),
146 OperationTarget::OutsideWorkspace,
147 ImpactScope::Host,
148 Reversibility::Unknown,
149 EnvironmentSensitivity::Host,
150 ToolRiskReason::SymlinkBoundaryEscape,
151 )
152 })
153 }
154}
155
156impl Default for InteractiveToolGuardrail {
157 fn default() -> Self {
158 Self::new(InteractiveApprovalMode::Default)
159 }
160}
161
162impl PermissionChecker for InteractiveToolGuardrail {
163 fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
164 self.mode.apply(&self.assess(tool_name, args))
165 }
166}
167
168fn invocation_crosses_local_symlink(
169 root: &std::path::Path,
170 tool_name: &str,
171 args: &serde_json::Value,
172) -> bool {
173 if tool_name.eq_ignore_ascii_case("batch") {
174 return args
175 .get("invocations")
176 .and_then(serde_json::Value::as_array)
177 .is_some_and(|invocations| {
178 invocations.iter().any(|invocation| {
179 let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
180 else {
181 return false;
182 };
183 let Some(tool_args) = invocation.get("args") else {
184 return false;
185 };
186 invocation_crosses_local_symlink(root, tool, tool_args)
187 })
188 });
189 }
190
191 let tool = tool_name.to_ascii_lowercase();
192 if tool == "bash" {
193 return shell_path_crosses_symlink(root, args);
194 }
195 let field = match tool.as_str() {
196 "read" | "write" | "edit" | "patch" | "download" => "file_path",
197 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
198 _ => return false,
199 };
200 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
201 return false;
202 };
203 local_path_crosses_symlink(root, path)
204}
205
206fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
207 args.get("command")
208 .and_then(serde_json::Value::as_str)
209 .is_some_and(|command| {
210 command
211 .split_whitespace()
212 .map(clean_shell_token)
213 .filter(|token| !token.is_empty() && !token.starts_with('-'))
214 .any(|token| shell_token_path_crosses_symlink(root, token))
215 })
216}
217
218fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
219 path_crosses_symlink(root, path, false)
220}
221
222fn shell_token_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
223 path_crosses_symlink(root, path, true)
224}
225
226fn path_crosses_symlink(root: &std::path::Path, path: &str, stop_at_shell_glob: bool) -> bool {
227 if path_is_outside_workspace(path) {
228 return false;
229 }
230 let mut current = root.to_path_buf();
231 for component in std::path::Path::new(path).components() {
232 match component {
233 std::path::Component::CurDir => continue,
234 std::path::Component::Normal(component) => {
235 if stop_at_shell_glob
236 && component
237 .to_string_lossy()
238 .contains(['*', '?', '[', ']', '{', '}'])
239 {
240 return false;
244 }
245 current.push(component);
246 }
247 _ => return true,
248 }
249 match std::fs::symlink_metadata(¤t) {
250 Ok(metadata) if metadata.file_type().is_symlink() => return true,
251 Ok(_) => {}
252 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
253 Err(_) => return true,
254 }
255 }
256 false
257}
258
259pub(super) fn atomic_tool_is_bounded(tool_name: &str, args: &serde_json::Value) -> bool {
260 match tool_name.to_ascii_lowercase().as_str() {
261 "write" | "edit" | "patch" => bounded_file_target(args),
264 "download" => args.get("file_path").is_none() || bounded_file_target(args),
267 "git" => {
268 classify_git(args) == PermissionDecision::Ask
269 && git_call_is_known_bounded_mutation(args)
270 }
271 _ => false,
274 }
275}
276
277fn bounded_file_target(args: &serde_json::Value) -> bool {
278 args.get("file_path")
279 .and_then(serde_json::Value::as_str)
280 .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
281}
282
283fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
284 if git_requires_explicit_confirmation(args) {
285 return false;
286 }
287 match args.get("command").and_then(serde_json::Value::as_str) {
288 Some("branch") => valid_non_option_string(args, "name"),
289 Some("checkout") => valid_non_option_string(args, "ref"),
290 Some("stash") => {
291 args.get("message")
292 .and_then(serde_json::Value::as_str)
293 .is_some()
294 || args
295 .get("include_untracked")
296 .and_then(serde_json::Value::as_bool)
297 .unwrap_or(false)
298 }
299 Some("remote") => args
300 .get("remote_name")
301 .and_then(serde_json::Value::as_str)
302 .is_some(),
303 Some("worktree") => matches!(
304 args.get("subcommand").and_then(serde_json::Value::as_str),
305 Some("add")
306 ),
307 _ => false,
308 }
309}
310
311fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
312 args.get("force").is_some_and(|value| value != false)
313}
314
315pub(super) fn classify_atomic_tool(
316 tool_name: &str,
317 args: &serde_json::Value,
318) -> PermissionDecision {
319 match tool_name.to_ascii_lowercase().as_str() {
320 "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
321 "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
322 classify_scoped_path(args, "path", PermissionDecision::Allow)
323 }
324 "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
325 PermissionDecision::Allow
326 }
327 "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
328 "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
329 "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
330 "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
333 "bash" => classify_bash(args),
334 "git" => classify_git(args),
335 _ => PermissionDecision::Ask,
338 }
339}
340
341fn classify_scoped_path(
342 args: &serde_json::Value,
343 field: &str,
344 safe_decision: PermissionDecision,
345) -> PermissionDecision {
346 let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
347 return if field == "path" {
350 safe_decision
351 } else {
352 PermissionDecision::Ask
353 };
354 };
355 if path.trim().is_empty() {
356 return if field == "path" {
357 safe_decision
358 } else {
359 PermissionDecision::Ask
360 };
361 }
362 if path_is_outside_workspace(path) {
363 PermissionDecision::Deny
364 } else {
365 safe_decision
366 }
367}
368
369fn classify_git(args: &serde_json::Value) -> PermissionDecision {
370 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
371 return PermissionDecision::Ask;
372 };
373 if args
374 .get("force")
375 .is_some_and(|value| value.as_bool() != Some(false))
376 {
377 return PermissionDecision::Ask;
378 }
379
380 match command {
381 "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
382 "log"
383 if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
384 && valid_optional_positive_integer(args, "limit")
385 && valid_optional_positive_integer(args, "max_count")
386 && valid_optional_string(args, "cursor") =>
387 {
388 PermissionDecision::Allow
389 }
390 "diff"
391 if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
392 && valid_optional_non_option_string(args, "target")
393 && valid_optional_nonnegative_integer(args, "byte_offset")
394 && valid_optional_positive_integer(args, "max_bytes") =>
395 {
396 PermissionDecision::Allow
397 }
398 "remote"
399 if only_git_keys(args, &["command", "remote_name", "cursor"])
400 && valid_optional_string(args, "remote_name")
401 && valid_optional_string(args, "cursor") =>
402 {
403 PermissionDecision::Allow
404 }
405 "branch"
406 if args.get("name").is_none()
407 && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
408 && valid_optional_positive_integer(args, "limit")
409 && valid_optional_positive_integer(args, "max_count")
410 && valid_optional_string(args, "cursor") =>
411 {
412 PermissionDecision::Allow
413 }
414 "stash"
415 if args.get("message").is_none()
416 && args.get("include_untracked").is_none()
417 && only_git_keys(args, &["command", "cursor"])
418 && valid_optional_string(args, "cursor") =>
419 {
420 PermissionDecision::Allow
421 }
422 "worktree"
423 if args
424 .get("subcommand")
425 .and_then(serde_json::Value::as_str)
426 .unwrap_or("list")
427 == "list"
428 && only_git_keys(args, &["command", "subcommand", "cursor"])
429 && valid_optional_string(args, "subcommand")
430 && valid_optional_string(args, "cursor") =>
431 {
432 PermissionDecision::Allow
433 }
434 _ => PermissionDecision::Ask,
435 }
436}
437
438fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
439 args.as_object().is_some_and(|object| {
440 object
441 .keys()
442 .all(|key| allowed.iter().any(|allowed| key == allowed))
443 })
444}
445
446fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
447 args.get(field)
448 .and_then(serde_json::Value::as_str)
449 .is_some_and(|value| {
450 let value = value.trim();
451 !value.is_empty() && !value.starts_with('-')
452 })
453}
454
455fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
456 args.get(field)
457 .is_none_or(|_| valid_non_option_string(args, field))
458}
459
460fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
461 args.get(field).is_none_or(serde_json::Value::is_string)
462}
463
464fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
465 args.get(field)
466 .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
467}
468
469fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
470 args.get(field).is_none_or(|value| value.as_u64().is_some())
471}
472
473fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
474 let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
475 return PermissionDecision::Ask;
476 };
477 let command = command.trim();
478 if command.is_empty() {
479 return PermissionDecision::Ask;
480 }
481 if is_catastrophic_bash_command(command) {
482 PermissionDecision::Deny
483 } else if is_read_only_bash_command(command) {
484 PermissionDecision::Allow
485 } else {
486 PermissionDecision::Ask
487 }
488}
489
490fn is_catastrophic_bash_command(command: &str) -> bool {
491 let lower = normalize_shell(command).to_ascii_lowercase();
492 if lower == "sudo"
493 || lower.starts_with("sudo ")
494 || lower.starts_with("doas ")
495 || lower == "su"
496 || lower.starts_with("su ")
497 || lower.starts_with("su -")
498 {
499 return true;
500 }
501 if shell_invokes_mkfs(command)
502 || lower.contains("diskutil erase")
503 || lower.contains(":(){")
504 || lower.contains("kill -9 -1")
505 || lower.starts_with("shutdown")
506 || lower.starts_with("reboot")
507 {
508 return true;
509 }
510 if (lower.contains("curl ") || lower.contains("wget "))
511 && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
512 .iter()
513 .any(|pipe| lower.contains(pipe))
514 {
515 return true;
516 }
517 if (lower.starts_with("dd ") || lower.contains(" dd "))
518 && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
519 {
520 return true;
521 }
522
523 lower.contains("rm -rf /")
524 || lower.contains("rm -fr /")
525 || lower.contains("rm -rf ~")
526 || lower.contains("rm -fr ~")
527 || lower.contains("rm -rf $home")
528 || lower.contains("rm -fr $home")
529 || lower.contains("rm -rf *")
530 || lower.contains("rm -fr *")
531 || lower == "rm -rf ."
532 || lower == "rm -fr ."
533}
534
535fn shell_invokes_mkfs(command: &str) -> bool {
536 command
537 .split(['|', ';', '&'])
538 .filter_map(|segment| segment.split_whitespace().next())
539 .map(clean_shell_token)
540 .filter_map(|executable| executable.rsplit('/').next())
541 .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
542}
543
544fn is_read_only_bash_command(command: &str) -> bool {
545 if command
549 .chars()
550 .any(|character| character.is_whitespace() && character != ' ')
551 || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
552 || contains_unsafe_shell_syntax(command)
553 {
554 return false;
555 }
556 command
557 .split('|')
558 .all(|segment| is_read_only_bash_segment(segment.trim()))
559}
560
561fn contains_unsafe_shell_syntax(command: &str) -> bool {
562 command.contains("&&")
563 || command.contains("||")
564 || command.contains(';')
565 || command.contains('>')
566 || command.contains('<')
567 || command.contains('`')
568 || command.contains("$(")
569 || command.contains('&')
570 || command.contains('\n')
571 || command.contains('\r')
572 || command.contains('$')
573 || has_unscoped_path_token(command)
574}
575
576fn has_unscoped_path_token(command: &str) -> bool {
577 command
578 .split_whitespace()
579 .map(clean_shell_token)
580 .filter(|token| !token.is_empty())
581 .any(path_is_outside_workspace)
582}
583
584fn clean_shell_token(token: &str) -> &str {
585 token.trim_matches(|character: char| {
586 matches!(
587 character,
588 '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
589 )
590 })
591}
592
593fn path_is_outside_workspace(path: &str) -> bool {
594 let normalized = path.replace('\\', "/");
595 let path = normalized.trim();
596 let bytes = path.as_bytes();
597 if (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
598 || path.starts_with("//")
599 || path.starts_with('/')
600 || path.starts_with('~')
601 || path.starts_with("$HOME")
602 || path.starts_with("${HOME}")
603 {
604 return true;
605 }
606
607 let mut depth = 0_i32;
608 for component in path.split('/') {
609 match component {
610 "" | "." => {}
611 ".." if depth == 0 => return true,
612 ".." => depth -= 1,
613 _ => depth += 1,
614 }
615 }
616 false
617}
618
619fn is_read_only_bash_segment(segment: &str) -> bool {
620 let tokens: Vec<&str> = segment.split_whitespace().collect();
621 let Some(command) = tokens.first().copied().map(clean_shell_token) else {
622 return false;
623 };
624 let lower = segment.to_ascii_lowercase();
625
626 match command {
627 "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
628 tokens
629 .iter()
630 .skip(1)
631 .all(|value| !option_executes_or_writes(value))
632 }
633 "ls" => tokens.iter().skip(1).all(|value| {
634 !option_executes_or_writes(value)
635 && !short_option_contains(value, 'L')
636 && !short_option_contains(value, 'R')
637 && !matches!(*value, "--dereference" | "--recursive")
638 }),
639 "rg" => tokens.iter().skip(1).all(|value| {
640 !option_executes_or_writes(value)
641 && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
642 && !value.starts_with("--pre=")
643 && !value.starts_with("--hostname-bin=")
644 }),
645 "grep" => tokens.iter().skip(1).all(|value| {
646 !option_executes_or_writes(value)
647 && !short_option_contains(value, 'f')
648 && !matches!(
649 *value,
650 "-R" | "-r"
651 | "--recursive"
652 | "--dereference-recursive"
653 | "--include"
654 | "--exclude-from"
655 | "--file"
656 )
657 && !value.starts_with("--exclude-from=")
658 && !value.starts_with("--file=")
659 }),
660 "du" => tokens.iter().skip(1).all(|value| {
661 !short_option_contains(value, 'L')
662 && !matches!(*value, "--dereference")
663 && !option_executes_or_writes(value)
664 }),
665 "df" => tokens
666 .iter()
667 .skip(1)
668 .all(|value| !option_executes_or_writes(value)),
669 "date" => tokens.iter().skip(1).all(|value| {
670 !matches!(*value, "-s" | "--set")
671 && !short_option_contains(value, 's')
672 && !value.starts_with("--set=")
673 && !option_executes_or_writes(value)
674 }),
675 "uname" => tokens
676 .iter()
677 .skip(1)
678 .all(|value| !option_executes_or_writes(value)),
679 "sort" => tokens.iter().skip(1).all(|value| {
680 !matches!(
681 *value,
682 "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
683 ) && !short_option_contains(value, 'o')
684 && !short_option_contains(value, 'T')
685 && !value.starts_with("--output=")
686 && !value.starts_with("--temporary-directory=")
687 && !value.starts_with("--compress-program=")
688 && !option_executes_or_writes(value)
689 }),
690 "uniq" => positional_argument_count(&tokens[1..]) <= 1,
693 "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
696 "find" => {
697 !tokens
698 .iter()
699 .skip(1)
700 .any(|value| matches!(*value, "-L" | "-H"))
701 && ![
702 " -delete",
703 " -exec",
704 " -execdir",
705 " -ok",
706 " -okdir",
707 " -fprint",
708 " -fprint0",
709 " -fprintf",
710 " -fls",
711 " -follow",
712 " -lname",
713 ]
714 .iter()
715 .any(|action| lower.contains(action))
716 }
717 "sed" => false,
721 "git" => is_read_only_git_segment(&tokens),
722 _ => false,
723 }
724}
725
726fn short_option_contains(value: &str, flag: char) -> bool {
727 value.starts_with('-')
728 && !value.starts_with("--")
729 && value.chars().skip(1).any(|candidate| candidate == flag)
730}
731
732fn option_executes_or_writes(value: &str) -> bool {
733 matches!(
734 value,
735 "--output" | "--exec" | "--command" | "--config" | "--files-from"
736 ) || value.starts_with("--output=")
737 || value.starts_with("--exec=")
738 || value.starts_with("--command=")
739 || value.starts_with("--config=")
740 || value.starts_with("--files-from=")
741}
742
743fn positional_argument_count(tokens: &[&str]) -> usize {
744 tokens
745 .iter()
746 .filter(|value| !value.starts_with('-'))
747 .count()
748}
749
750fn is_read_only_git_segment(tokens: &[&str]) -> bool {
751 if tokens.first().copied() != Some("git") {
752 return false;
753 }
754 let mut index = 1;
755 while index < tokens.len() {
756 match tokens[index] {
757 "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
758 value if value.starts_with('-') => return false,
762 _ => break,
763 }
764 }
765
766 let Some(subcommand) = tokens.get(index).copied() else {
767 return false;
768 };
769 let args = &tokens[index + 1..];
770 if args.iter().any(|value| {
771 matches!(
772 *value,
773 "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
774 ) || value.starts_with("--exec-path=")
775 || value.starts_with("--config-env=")
776 }) {
777 return false;
778 }
779
780 match subcommand {
781 "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
782 !args.iter().any(|value| {
783 option_executes_or_writes(value)
784 || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
785 || value.starts_with("--format=") && value.contains("%(rest)")
786 })
787 }
788 "remote" => match args.first() {
789 Some(value) => matches!(*value, "-v" | "show"),
790 None => true,
791 },
792 "branch" => args.iter().all(|value| {
793 matches!(
794 *value,
795 "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
796 )
797 }),
798 _ => false,
799 }
800}
801
802fn normalize_shell(command: &str) -> String {
803 command.split_whitespace().collect::<Vec<_>>().join(" ")
804}