Skip to main content

a3s_code_core/permissions/
interactive.rs

1//! Shared, conservative risk classification for interactive Code hosts.
2//!
3//! This module deliberately recognizes a small safe subset. Unknown or complex
4//! invocations require confirmation; only operations with catastrophic blast
5//! radius are denied outright. Hosts can layer their own mode semantics over the
6//! resulting allow/ask/deny decision without duplicating command heuristics.
7
8use serde::{Deserialize, Serialize};
9
10use super::{PermissionChecker, PermissionDecision};
11
12/// How an interactive host treats operations that would normally require HITL.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InteractiveApprovalMode {
16    /// Allow known-safe operations and prompt for ordinary side effects.
17    Default,
18    /// Allow known-safe operations and prompt for side effects.
19    Plan,
20    /// Streamline bounded workspace side effects while retaining HITL elsewhere.
21    Auto,
22}
23
24impl InteractiveApprovalMode {
25    pub fn from_name(value: &str) -> Self {
26        match value.trim().to_ascii_lowercase().as_str() {
27            "plan" => Self::Plan,
28            "auto" => Self::Auto,
29            _ => Self::Default,
30        }
31    }
32
33    fn apply(
34        self,
35        tool_name: &str,
36        args: &serde_json::Value,
37        decision: PermissionDecision,
38    ) -> PermissionDecision {
39        match (self, decision) {
40            (_, PermissionDecision::Deny) => PermissionDecision::Deny,
41            // Planning stays risk-aware rather than hiding useful escalation:
42            // side effects still require an explicit user decision.
43            (Self::Plan, PermissionDecision::Ask) => PermissionDecision::Ask,
44            (Self::Auto, PermissionDecision::Ask) if auto_mode_may_approve(tool_name, args) => {
45                PermissionDecision::Allow
46            }
47            (_, decision) => decision,
48        }
49    }
50}
51
52/// Shared Codex-style guardrail used by the terminal and web Code products.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct InteractiveToolGuardrail {
55    mode: InteractiveApprovalMode,
56    workspace: Option<std::path::PathBuf>,
57}
58
59impl InteractiveToolGuardrail {
60    pub const fn new(mode: InteractiveApprovalMode) -> Self {
61        Self {
62            mode,
63            workspace: None,
64        }
65    }
66
67    pub fn for_mode(mode: &str) -> Self {
68        Self::new(InteractiveApprovalMode::from_name(mode))
69    }
70
71    /// Add a local workspace root so existing symlink components can be checked.
72    pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
73        self.workspace = Some(workspace.into());
74        self
75    }
76
77    /// Return the conservative lexical risk decision before host mode semantics.
78    pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
79        classify_tool(tool_name, args)
80    }
81
82    fn workspace_boundary_decision(
83        &self,
84        tool_name: &str,
85        args: &serde_json::Value,
86    ) -> Option<PermissionDecision> {
87        let root = self.workspace.as_deref()?;
88        invocation_crosses_local_symlink(root, tool_name, args).then_some(PermissionDecision::Deny)
89    }
90}
91
92impl Default for InteractiveToolGuardrail {
93    fn default() -> Self {
94        Self::new(InteractiveApprovalMode::Default)
95    }
96}
97
98impl PermissionChecker for InteractiveToolGuardrail {
99    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
100        if let Some(decision) = self.workspace_boundary_decision(tool_name, args) {
101            return decision;
102        }
103        self.mode
104            .apply(tool_name, args, classify_tool(tool_name, args))
105    }
106}
107
108fn invocation_crosses_local_symlink(
109    root: &std::path::Path,
110    tool_name: &str,
111    args: &serde_json::Value,
112) -> bool {
113    if tool_name.eq_ignore_ascii_case("batch") {
114        return args
115            .get("invocations")
116            .and_then(serde_json::Value::as_array)
117            .is_some_and(|invocations| {
118                invocations.iter().any(|invocation| {
119                    let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
120                    else {
121                        return false;
122                    };
123                    let Some(tool_args) = invocation.get("args") else {
124                        return false;
125                    };
126                    invocation_crosses_local_symlink(root, tool, tool_args)
127                })
128            });
129    }
130
131    let tool = tool_name.to_ascii_lowercase();
132    if tool == "bash" {
133        return shell_path_crosses_symlink(root, args);
134    }
135    let field = match tool.as_str() {
136        "read" | "write" | "edit" | "patch" => "file_path",
137        "grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
138        _ => return false,
139    };
140    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
141        return false;
142    };
143    local_path_crosses_symlink(root, path)
144}
145
146fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
147    args.get("command")
148        .and_then(serde_json::Value::as_str)
149        .is_some_and(|command| {
150            command
151                .split_whitespace()
152                .map(clean_shell_token)
153                .filter(|token| !token.is_empty() && !token.starts_with('-'))
154                .any(|token| local_path_crosses_symlink(root, token))
155        })
156}
157
158fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
159    if path_is_outside_workspace(path) {
160        return false;
161    }
162    let mut current = root.to_path_buf();
163    for component in std::path::Path::new(path).components() {
164        match component {
165            std::path::Component::CurDir => continue,
166            std::path::Component::Normal(component) => current.push(component),
167            _ => return true,
168        }
169        match std::fs::symlink_metadata(&current) {
170            Ok(metadata) if metadata.file_type().is_symlink() => return true,
171            Ok(_) => {}
172            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
173            Err(_) => return true,
174        }
175    }
176    false
177}
178
179fn auto_mode_may_approve(tool_name: &str, args: &serde_json::Value) -> bool {
180    match tool_name.to_ascii_lowercase().as_str() {
181        // Workspace-confined edits and ordinary structured Git changes are the
182        // bounded operations that auto mode exists to streamline.
183        "write" | "edit" => bounded_file_target(args),
184        "patch" => bounded_file_target(args),
185        "git" => {
186            classify_git(args) == PermissionDecision::Ask
187                && git_call_is_known_bounded_mutation(args)
188        }
189        "batch" => batch_auto_approvable(args),
190        // Shell, delegation, runtime, dynamic scripts, skills, and unknown/MCP
191        // tools retain HITL because their side effects cannot be bounded here.
192        _ => false,
193    }
194}
195
196fn bounded_file_target(args: &serde_json::Value) -> bool {
197    args.get("file_path")
198        .and_then(serde_json::Value::as_str)
199        .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
200}
201
202fn batch_auto_approvable(args: &serde_json::Value) -> bool {
203    let Some(invocations) = args
204        .get("invocations")
205        .and_then(serde_json::Value::as_array)
206    else {
207        return false;
208    };
209    !invocations.is_empty()
210        && invocations.iter().all(|invocation| {
211            let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str) else {
212                return false;
213            };
214            let Some(tool_args) = invocation.get("args") else {
215                return false;
216            };
217            match classify_tool(tool, tool_args) {
218                PermissionDecision::Deny => false,
219                PermissionDecision::Allow => true,
220                PermissionDecision::Ask => auto_mode_may_approve(tool, tool_args),
221            }
222        })
223}
224
225fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
226    if git_requires_explicit_confirmation(args) {
227        return false;
228    }
229    match args.get("command").and_then(serde_json::Value::as_str) {
230        Some("branch") => valid_non_option_string(args, "name"),
231        Some("checkout") => valid_non_option_string(args, "ref"),
232        Some("stash") => {
233            args.get("message")
234                .and_then(serde_json::Value::as_str)
235                .is_some()
236                || args
237                    .get("include_untracked")
238                    .and_then(serde_json::Value::as_bool)
239                    .unwrap_or(false)
240        }
241        Some("remote") => args
242            .get("remote_name")
243            .and_then(serde_json::Value::as_str)
244            .is_some(),
245        Some("worktree") => matches!(
246            args.get("subcommand").and_then(serde_json::Value::as_str),
247            Some("add")
248        ),
249        _ => false,
250    }
251}
252
253fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
254    args.get("force").is_some_and(|value| value != false)
255}
256
257fn classify_tool(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
258    match tool_name.to_ascii_lowercase().as_str() {
259        "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
260        "grep" | "glob" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
261            classify_scoped_path(args, "path", PermissionDecision::Allow)
262        }
263        "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
264            PermissionDecision::Allow
265        }
266        "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
267        // Patch carries its target in a separate top-level field. A missing or
268        // boundary-crossing target must never be silently approved.
269        "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
270        "bash" => classify_bash(args),
271        "git" => classify_git(args),
272        "batch" => classify_batch(args),
273        // Delegation, scripts, skills, runtime calls, dynamic and MCP tools can
274        // perform nested or external side effects, so they need authorization.
275        _ => PermissionDecision::Ask,
276    }
277}
278
279fn classify_scoped_path(
280    args: &serde_json::Value,
281    field: &str,
282    safe_decision: PermissionDecision,
283) -> PermissionDecision {
284    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
285        // Some read-only tools have an optional path that defaults to the
286        // workspace root. A missing write target remains malformed and asks.
287        return if field == "path" {
288            safe_decision
289        } else {
290            PermissionDecision::Ask
291        };
292    };
293    if path.trim().is_empty() {
294        return if field == "path" {
295            safe_decision
296        } else {
297            PermissionDecision::Ask
298        };
299    }
300    if path_is_outside_workspace(path) {
301        PermissionDecision::Deny
302    } else {
303        safe_decision
304    }
305}
306
307fn classify_batch(args: &serde_json::Value) -> PermissionDecision {
308    let Some(invocations) = args
309        .get("invocations")
310        .and_then(serde_json::Value::as_array)
311    else {
312        return PermissionDecision::Ask;
313    };
314    if invocations.is_empty() {
315        return PermissionDecision::Ask;
316    }
317
318    let mut aggregate = PermissionDecision::Allow;
319    for invocation in invocations {
320        let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str) else {
321            return PermissionDecision::Ask;
322        };
323        let Some(tool_args) = invocation.get("args") else {
324            return PermissionDecision::Ask;
325        };
326        if !tool_args.is_object() {
327            return PermissionDecision::Ask;
328        }
329        let decision = if tool.eq_ignore_ascii_case("batch") {
330            classify_batch(tool_args)
331        } else {
332            classify_tool(tool, tool_args)
333        };
334        match decision {
335            PermissionDecision::Deny => return PermissionDecision::Deny,
336            PermissionDecision::Ask => aggregate = PermissionDecision::Ask,
337            PermissionDecision::Allow => {}
338        }
339    }
340    aggregate
341}
342
343fn classify_git(args: &serde_json::Value) -> PermissionDecision {
344    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
345        return PermissionDecision::Ask;
346    };
347    if args
348        .get("force")
349        .is_some_and(|value| value.as_bool() != Some(false))
350    {
351        return PermissionDecision::Ask;
352    }
353
354    match command {
355        "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
356        "log"
357            if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
358                && valid_optional_positive_integer(args, "limit")
359                && valid_optional_positive_integer(args, "max_count")
360                && valid_optional_string(args, "cursor") =>
361        {
362            PermissionDecision::Allow
363        }
364        "diff"
365            if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
366                && valid_optional_non_option_string(args, "target")
367                && valid_optional_nonnegative_integer(args, "byte_offset")
368                && valid_optional_positive_integer(args, "max_bytes") =>
369        {
370            PermissionDecision::Allow
371        }
372        "remote"
373            if only_git_keys(args, &["command", "remote_name", "cursor"])
374                && valid_optional_string(args, "remote_name")
375                && valid_optional_string(args, "cursor") =>
376        {
377            PermissionDecision::Allow
378        }
379        "branch"
380            if args.get("name").is_none()
381                && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
382                && valid_optional_positive_integer(args, "limit")
383                && valid_optional_positive_integer(args, "max_count")
384                && valid_optional_string(args, "cursor") =>
385        {
386            PermissionDecision::Allow
387        }
388        "stash"
389            if args.get("message").is_none()
390                && args.get("include_untracked").is_none()
391                && only_git_keys(args, &["command", "cursor"])
392                && valid_optional_string(args, "cursor") =>
393        {
394            PermissionDecision::Allow
395        }
396        "worktree"
397            if args
398                .get("subcommand")
399                .and_then(serde_json::Value::as_str)
400                .unwrap_or("list")
401                == "list"
402                && only_git_keys(args, &["command", "subcommand", "cursor"])
403                && valid_optional_string(args, "subcommand")
404                && valid_optional_string(args, "cursor") =>
405        {
406            PermissionDecision::Allow
407        }
408        _ => PermissionDecision::Ask,
409    }
410}
411
412fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
413    args.as_object().is_some_and(|object| {
414        object
415            .keys()
416            .all(|key| allowed.iter().any(|allowed| key == allowed))
417    })
418}
419
420fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
421    args.get(field)
422        .and_then(serde_json::Value::as_str)
423        .is_some_and(|value| {
424            let value = value.trim();
425            !value.is_empty() && !value.starts_with('-')
426        })
427}
428
429fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
430    args.get(field)
431        .is_none_or(|_| valid_non_option_string(args, field))
432}
433
434fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
435    args.get(field).is_none_or(serde_json::Value::is_string)
436}
437
438fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
439    args.get(field)
440        .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
441}
442
443fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
444    args.get(field).is_none_or(|value| value.as_u64().is_some())
445}
446
447fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
448    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
449        return PermissionDecision::Ask;
450    };
451    let command = command.trim();
452    if command.is_empty() {
453        return PermissionDecision::Ask;
454    }
455    if is_catastrophic_bash_command(command) {
456        PermissionDecision::Deny
457    } else if is_read_only_bash_command(command) {
458        PermissionDecision::Allow
459    } else {
460        PermissionDecision::Ask
461    }
462}
463
464fn is_catastrophic_bash_command(command: &str) -> bool {
465    let lower = normalize_shell(command).to_ascii_lowercase();
466    if lower == "sudo"
467        || lower.starts_with("sudo ")
468        || lower.starts_with("doas ")
469        || lower == "su"
470        || lower.starts_with("su ")
471        || lower.starts_with("su -")
472    {
473        return true;
474    }
475    if lower.contains("mkfs")
476        || lower.contains("diskutil erase")
477        || lower.contains(":(){")
478        || lower.contains("kill -9 -1")
479        || lower.starts_with("shutdown")
480        || lower.starts_with("reboot")
481    {
482        return true;
483    }
484    if (lower.contains("curl ") || lower.contains("wget "))
485        && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
486            .iter()
487            .any(|pipe| lower.contains(pipe))
488    {
489        return true;
490    }
491    if (lower.starts_with("dd ") || lower.contains(" dd "))
492        && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
493    {
494        return true;
495    }
496
497    lower.contains("rm -rf /")
498        || lower.contains("rm -fr /")
499        || lower.contains("rm -rf ~")
500        || lower.contains("rm -fr ~")
501        || lower.contains("rm -rf $home")
502        || lower.contains("rm -fr $home")
503        || lower.contains("rm -rf *")
504        || lower.contains("rm -fr *")
505        || lower == "rm -rf ."
506        || lower == "rm -fr ."
507}
508
509fn is_read_only_bash_command(command: &str) -> bool {
510    // The allow-list intentionally rejects shell quoting, expansion, globs, and
511    // non-space control whitespace. A tokenizer-aware sandbox can broaden this
512    // later; a string heuristic must fail closed.
513    if command
514        .chars()
515        .any(|character| character.is_whitespace() && character != ' ')
516        || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
517        || contains_unsafe_shell_syntax(command)
518    {
519        return false;
520    }
521    command
522        .split('|')
523        .all(|segment| is_read_only_bash_segment(segment.trim()))
524}
525
526fn contains_unsafe_shell_syntax(command: &str) -> bool {
527    command.contains("&&")
528        || command.contains("||")
529        || command.contains(';')
530        || command.contains('>')
531        || command.contains('<')
532        || command.contains('`')
533        || command.contains("$(")
534        || command.contains('&')
535        || command.contains('\n')
536        || command.contains('\r')
537        || command.contains('$')
538        || has_unscoped_path_token(command)
539}
540
541fn has_unscoped_path_token(command: &str) -> bool {
542    command
543        .split_whitespace()
544        .map(clean_shell_token)
545        .filter(|token| !token.is_empty())
546        .any(path_is_outside_workspace)
547}
548
549fn clean_shell_token(token: &str) -> &str {
550    token.trim_matches(|character: char| {
551        matches!(
552            character,
553            '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
554        )
555    })
556}
557
558fn path_is_outside_workspace(path: &str) -> bool {
559    let normalized = path.replace('\\', "/");
560    let path = normalized.trim();
561    let bytes = path.as_bytes();
562    if (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
563        || path.starts_with("//")
564        || path.starts_with('/')
565        || path.starts_with('~')
566        || path.starts_with("$HOME")
567        || path.starts_with("${HOME}")
568    {
569        return true;
570    }
571
572    let mut depth = 0_i32;
573    for component in path.split('/') {
574        match component {
575            "" | "." => {}
576            ".." if depth == 0 => return true,
577            ".." => depth -= 1,
578            _ => depth += 1,
579        }
580    }
581    false
582}
583
584fn is_read_only_bash_segment(segment: &str) -> bool {
585    let tokens: Vec<&str> = segment.split_whitespace().collect();
586    let Some(command) = tokens.first().copied().map(clean_shell_token) else {
587        return false;
588    };
589    let lower = segment.to_ascii_lowercase();
590
591    match command {
592        "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
593            tokens
594                .iter()
595                .skip(1)
596                .all(|value| !option_executes_or_writes(value))
597        }
598        "ls" => tokens.iter().skip(1).all(|value| {
599            !option_executes_or_writes(value)
600                && !short_option_contains(value, 'L')
601                && !short_option_contains(value, 'R')
602                && !matches!(*value, "--dereference" | "--recursive")
603        }),
604        "rg" => tokens.iter().skip(1).all(|value| {
605            !option_executes_or_writes(value)
606                && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
607                && !value.starts_with("--pre=")
608                && !value.starts_with("--hostname-bin=")
609        }),
610        "grep" => tokens.iter().skip(1).all(|value| {
611            !option_executes_or_writes(value)
612                && !matches!(
613                    *value,
614                    "-R" | "-r"
615                        | "--recursive"
616                        | "--dereference-recursive"
617                        | "--include"
618                        | "--exclude-from"
619                )
620        }),
621        "du" => tokens.iter().skip(1).all(|value| {
622            !short_option_contains(value, 'L')
623                && !matches!(*value, "--dereference")
624                && !option_executes_or_writes(value)
625        }),
626        "df" => tokens
627            .iter()
628            .skip(1)
629            .all(|value| !option_executes_or_writes(value)),
630        "date" => tokens.iter().skip(1).all(|value| {
631            !matches!(*value, "-s" | "--set")
632                && !value.starts_with("--set=")
633                && !option_executes_or_writes(value)
634        }),
635        "uname" => tokens
636            .iter()
637            .skip(1)
638            .all(|value| !option_executes_or_writes(value)),
639        "sort" => tokens.iter().skip(1).all(|value| {
640            !matches!(*value, "-o" | "--output" | "--compress-program")
641                && !value.starts_with("--output=")
642                && !value.starts_with("--compress-program=")
643                && !option_executes_or_writes(value)
644        }),
645        // uniq writes when a second positional operand is present. Conservatively
646        // allow only options and at most one positional input.
647        "uniq" => positional_argument_count(&tokens[1..]) <= 1,
648        // Keep only plain formatting output in the silent subset. Shell
649        // builtins can still carry surprising option semantics, so options ask.
650        "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
651        "find" => {
652            !tokens
653                .iter()
654                .skip(1)
655                .any(|value| matches!(*value, "-L" | "-H"))
656                && ![
657                    " -delete",
658                    " -exec",
659                    " -execdir",
660                    " -ok",
661                    " -okdir",
662                    " -fprint",
663                    " -fprint0",
664                    " -fprintf",
665                    " -follow",
666                    " -lname",
667                ]
668                .iter()
669                .any(|action| lower.contains(action))
670        }
671        "sed" => !tokens.iter().skip(1).any(|value| {
672            *value == "-i"
673                || value.starts_with("-i")
674                || value.starts_with("--in-place")
675                || option_executes_or_writes(value)
676        }),
677        "git" => is_read_only_git_segment(&tokens),
678        _ => false,
679    }
680}
681
682fn short_option_contains(value: &str, flag: char) -> bool {
683    value.starts_with('-')
684        && !value.starts_with("--")
685        && value.chars().skip(1).any(|candidate| candidate == flag)
686}
687
688fn option_executes_or_writes(value: &str) -> bool {
689    matches!(
690        value,
691        "--output" | "--exec" | "--command" | "--config" | "--files-from"
692    ) || value.starts_with("--output=")
693        || value.starts_with("--exec=")
694        || value.starts_with("--command=")
695        || value.starts_with("--config=")
696        || value.starts_with("--files-from=")
697}
698
699fn positional_argument_count(tokens: &[&str]) -> usize {
700    tokens
701        .iter()
702        .filter(|value| !value.starts_with('-'))
703        .count()
704}
705
706fn is_read_only_git_segment(tokens: &[&str]) -> bool {
707    if tokens.first().copied() != Some("git") {
708        return false;
709    }
710    let mut index = 1;
711    while index < tokens.len() {
712        match tokens[index] {
713            "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
714            // `-C` changes the filesystem boundary and is therefore never in
715            // the silent allow-list. Other global config/execution options are
716            // likewise left to confirmation.
717            value if value.starts_with('-') => return false,
718            _ => break,
719        }
720    }
721
722    let Some(subcommand) = tokens.get(index).copied() else {
723        return false;
724    };
725    let args = &tokens[index + 1..];
726    if args.iter().any(|value| {
727        matches!(
728            *value,
729            "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
730        ) || value.starts_with("--exec-path=")
731            || value.starts_with("--config-env=")
732    }) {
733        return false;
734    }
735
736    match subcommand {
737        "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
738            !args.iter().any(|value| {
739                option_executes_or_writes(value)
740                    || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
741                    || value.starts_with("--format=") && value.contains("%(rest)")
742            })
743        }
744        "remote" => match args.first() {
745            Some(value) => matches!(*value, "-v" | "show"),
746            None => true,
747        },
748        "branch" => args.iter().all(|value| {
749            matches!(
750                *value,
751                "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
752            )
753        }),
754        _ => false,
755    }
756}
757
758fn normalize_shell(command: &str) -> String {
759    command.split_whitespace().collect::<Vec<_>>().join(" ")
760}