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