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