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    /// Return whether an exact Bash command matches the deterministic,
110    /// non-bypassable catastrophic-operation floor.
111    ///
112    /// Sandboxed hosts use this separately from the conservative lexical risk
113    /// projection: unknown shell syntax may be safe inside an enforced OS
114    /// boundary, while destructive system commands remain denied in every
115    /// execution mode.
116    pub fn is_catastrophic_bash_command(command: &str) -> bool {
117        is_catastrophic_bash_command(command)
118    }
119
120    /// Assess an invocation, including workspace symlink boundary checks.
121    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    /// Return the explicit routing action selected for this guardrail mode.
129    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| local_path_crosses_symlink(root, token))
211        })
212}
213
214fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
215    if path_is_outside_workspace(path) {
216        return false;
217    }
218    let mut current = root.to_path_buf();
219    for component in std::path::Path::new(path).components() {
220        match component {
221            std::path::Component::CurDir => continue,
222            std::path::Component::Normal(component) => current.push(component),
223            _ => return true,
224        }
225        match std::fs::symlink_metadata(&current) {
226            Ok(metadata) if metadata.file_type().is_symlink() => return true,
227            Ok(_) => {}
228            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
229            Err(_) => return true,
230        }
231    }
232    false
233}
234
235pub(super) fn atomic_tool_is_bounded(tool_name: &str, args: &serde_json::Value) -> bool {
236    match tool_name.to_ascii_lowercase().as_str() {
237        // Workspace-confined edits and ordinary structured Git changes are the
238        // bounded operations that auto mode exists to streamline.
239        "write" | "edit" | "patch" => bounded_file_target(args),
240        // A missing destination is still bounded because download derives and
241        // sanitizes a workspace-relative filename from the response metadata.
242        "download" => args.get("file_path").is_none() || bounded_file_target(args),
243        "git" => {
244            classify_git(args) == PermissionDecision::Ask
245                && git_call_is_known_bounded_mutation(args)
246        }
247        // Shell, delegation, runtime, dynamic scripts, skills, and unknown/MCP
248        // tools retain HITL because their side effects cannot be bounded here.
249        _ => false,
250    }
251}
252
253fn bounded_file_target(args: &serde_json::Value) -> bool {
254    args.get("file_path")
255        .and_then(serde_json::Value::as_str)
256        .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
257}
258
259fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
260    if git_requires_explicit_confirmation(args) {
261        return false;
262    }
263    match args.get("command").and_then(serde_json::Value::as_str) {
264        Some("branch") => valid_non_option_string(args, "name"),
265        Some("checkout") => valid_non_option_string(args, "ref"),
266        Some("stash") => {
267            args.get("message")
268                .and_then(serde_json::Value::as_str)
269                .is_some()
270                || args
271                    .get("include_untracked")
272                    .and_then(serde_json::Value::as_bool)
273                    .unwrap_or(false)
274        }
275        Some("remote") => args
276            .get("remote_name")
277            .and_then(serde_json::Value::as_str)
278            .is_some(),
279        Some("worktree") => matches!(
280            args.get("subcommand").and_then(serde_json::Value::as_str),
281            Some("add")
282        ),
283        _ => false,
284    }
285}
286
287fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
288    args.get("force").is_some_and(|value| value != false)
289}
290
291pub(super) fn classify_atomic_tool(
292    tool_name: &str,
293    args: &serde_json::Value,
294) -> PermissionDecision {
295    match tool_name.to_ascii_lowercase().as_str() {
296        "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
297        "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
298            classify_scoped_path(args, "path", PermissionDecision::Allow)
299        }
300        "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
301            PermissionDecision::Allow
302        }
303        "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
304        "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
305        "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
306        // Patch carries its target in a separate top-level field. A missing or
307        // boundary-crossing target must never be silently approved.
308        "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
309        "bash" => classify_bash(args),
310        "git" => classify_git(args),
311        // Delegation, scripts, skills, runtime calls, dynamic and MCP tools can
312        // perform nested or external side effects, so they need authorization.
313        _ => PermissionDecision::Ask,
314    }
315}
316
317fn classify_scoped_path(
318    args: &serde_json::Value,
319    field: &str,
320    safe_decision: PermissionDecision,
321) -> PermissionDecision {
322    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
323        // Some read-only tools have an optional path that defaults to the
324        // workspace root. A missing write target remains malformed and asks.
325        return if field == "path" {
326            safe_decision
327        } else {
328            PermissionDecision::Ask
329        };
330    };
331    if path.trim().is_empty() {
332        return if field == "path" {
333            safe_decision
334        } else {
335            PermissionDecision::Ask
336        };
337    }
338    if path_is_outside_workspace(path) {
339        PermissionDecision::Deny
340    } else {
341        safe_decision
342    }
343}
344
345fn classify_git(args: &serde_json::Value) -> PermissionDecision {
346    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
347        return PermissionDecision::Ask;
348    };
349    if args
350        .get("force")
351        .is_some_and(|value| value.as_bool() != Some(false))
352    {
353        return PermissionDecision::Ask;
354    }
355
356    match command {
357        "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
358        "log"
359            if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
360                && valid_optional_positive_integer(args, "limit")
361                && valid_optional_positive_integer(args, "max_count")
362                && valid_optional_string(args, "cursor") =>
363        {
364            PermissionDecision::Allow
365        }
366        "diff"
367            if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
368                && valid_optional_non_option_string(args, "target")
369                && valid_optional_nonnegative_integer(args, "byte_offset")
370                && valid_optional_positive_integer(args, "max_bytes") =>
371        {
372            PermissionDecision::Allow
373        }
374        "remote"
375            if only_git_keys(args, &["command", "remote_name", "cursor"])
376                && valid_optional_string(args, "remote_name")
377                && valid_optional_string(args, "cursor") =>
378        {
379            PermissionDecision::Allow
380        }
381        "branch"
382            if args.get("name").is_none()
383                && 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        "stash"
391            if args.get("message").is_none()
392                && args.get("include_untracked").is_none()
393                && only_git_keys(args, &["command", "cursor"])
394                && valid_optional_string(args, "cursor") =>
395        {
396            PermissionDecision::Allow
397        }
398        "worktree"
399            if args
400                .get("subcommand")
401                .and_then(serde_json::Value::as_str)
402                .unwrap_or("list")
403                == "list"
404                && only_git_keys(args, &["command", "subcommand", "cursor"])
405                && valid_optional_string(args, "subcommand")
406                && valid_optional_string(args, "cursor") =>
407        {
408            PermissionDecision::Allow
409        }
410        _ => PermissionDecision::Ask,
411    }
412}
413
414fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
415    args.as_object().is_some_and(|object| {
416        object
417            .keys()
418            .all(|key| allowed.iter().any(|allowed| key == allowed))
419    })
420}
421
422fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
423    args.get(field)
424        .and_then(serde_json::Value::as_str)
425        .is_some_and(|value| {
426            let value = value.trim();
427            !value.is_empty() && !value.starts_with('-')
428        })
429}
430
431fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
432    args.get(field)
433        .is_none_or(|_| valid_non_option_string(args, field))
434}
435
436fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
437    args.get(field).is_none_or(serde_json::Value::is_string)
438}
439
440fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
441    args.get(field)
442        .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
443}
444
445fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
446    args.get(field).is_none_or(|value| value.as_u64().is_some())
447}
448
449fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
450    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
451        return PermissionDecision::Ask;
452    };
453    let command = command.trim();
454    if command.is_empty() {
455        return PermissionDecision::Ask;
456    }
457    if is_catastrophic_bash_command(command) {
458        PermissionDecision::Deny
459    } else if is_read_only_bash_command(command) {
460        PermissionDecision::Allow
461    } else {
462        PermissionDecision::Ask
463    }
464}
465
466fn is_catastrophic_bash_command(command: &str) -> bool {
467    let lower = normalize_shell(command).to_ascii_lowercase();
468    if lower == "sudo"
469        || lower.starts_with("sudo ")
470        || lower.starts_with("doas ")
471        || lower == "su"
472        || lower.starts_with("su ")
473        || lower.starts_with("su -")
474    {
475        return true;
476    }
477    if shell_invokes_mkfs(command)
478        || lower.contains("diskutil erase")
479        || lower.contains(":(){")
480        || lower.contains("kill -9 -1")
481        || lower.starts_with("shutdown")
482        || lower.starts_with("reboot")
483    {
484        return true;
485    }
486    if (lower.contains("curl ") || lower.contains("wget "))
487        && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
488            .iter()
489            .any(|pipe| lower.contains(pipe))
490    {
491        return true;
492    }
493    if (lower.starts_with("dd ") || lower.contains(" dd "))
494        && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
495    {
496        return true;
497    }
498
499    lower.contains("rm -rf /")
500        || lower.contains("rm -fr /")
501        || lower.contains("rm -rf ~")
502        || lower.contains("rm -fr ~")
503        || lower.contains("rm -rf $home")
504        || lower.contains("rm -fr $home")
505        || lower.contains("rm -rf *")
506        || lower.contains("rm -fr *")
507        || lower == "rm -rf ."
508        || lower == "rm -fr ."
509}
510
511fn shell_invokes_mkfs(command: &str) -> bool {
512    command
513        .split(['|', ';', '&'])
514        .filter_map(|segment| segment.split_whitespace().next())
515        .map(clean_shell_token)
516        .filter_map(|executable| executable.rsplit('/').next())
517        .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
518}
519
520fn is_read_only_bash_command(command: &str) -> bool {
521    // The allow-list intentionally rejects shell quoting, expansion, globs, and
522    // non-space control whitespace. A tokenizer-aware sandbox can broaden this
523    // later; a string heuristic must fail closed.
524    if command
525        .chars()
526        .any(|character| character.is_whitespace() && character != ' ')
527        || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
528        || contains_unsafe_shell_syntax(command)
529    {
530        return false;
531    }
532    command
533        .split('|')
534        .all(|segment| is_read_only_bash_segment(segment.trim()))
535}
536
537fn contains_unsafe_shell_syntax(command: &str) -> bool {
538    command.contains("&&")
539        || command.contains("||")
540        || command.contains(';')
541        || command.contains('>')
542        || command.contains('<')
543        || command.contains('`')
544        || command.contains("$(")
545        || command.contains('&')
546        || command.contains('\n')
547        || command.contains('\r')
548        || command.contains('$')
549        || has_unscoped_path_token(command)
550}
551
552fn has_unscoped_path_token(command: &str) -> bool {
553    command
554        .split_whitespace()
555        .map(clean_shell_token)
556        .filter(|token| !token.is_empty())
557        .any(path_is_outside_workspace)
558}
559
560fn clean_shell_token(token: &str) -> &str {
561    token.trim_matches(|character: char| {
562        matches!(
563            character,
564            '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
565        )
566    })
567}
568
569fn path_is_outside_workspace(path: &str) -> bool {
570    let normalized = path.replace('\\', "/");
571    let path = normalized.trim();
572    let bytes = path.as_bytes();
573    if (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
574        || path.starts_with("//")
575        || path.starts_with('/')
576        || path.starts_with('~')
577        || path.starts_with("$HOME")
578        || path.starts_with("${HOME}")
579    {
580        return true;
581    }
582
583    let mut depth = 0_i32;
584    for component in path.split('/') {
585        match component {
586            "" | "." => {}
587            ".." if depth == 0 => return true,
588            ".." => depth -= 1,
589            _ => depth += 1,
590        }
591    }
592    false
593}
594
595fn is_read_only_bash_segment(segment: &str) -> bool {
596    let tokens: Vec<&str> = segment.split_whitespace().collect();
597    let Some(command) = tokens.first().copied().map(clean_shell_token) else {
598        return false;
599    };
600    let lower = segment.to_ascii_lowercase();
601
602    match command {
603        "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
604            tokens
605                .iter()
606                .skip(1)
607                .all(|value| !option_executes_or_writes(value))
608        }
609        "ls" => tokens.iter().skip(1).all(|value| {
610            !option_executes_or_writes(value)
611                && !short_option_contains(value, 'L')
612                && !short_option_contains(value, 'R')
613                && !matches!(*value, "--dereference" | "--recursive")
614        }),
615        "rg" => tokens.iter().skip(1).all(|value| {
616            !option_executes_or_writes(value)
617                && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
618                && !value.starts_with("--pre=")
619                && !value.starts_with("--hostname-bin=")
620        }),
621        "grep" => tokens.iter().skip(1).all(|value| {
622            !option_executes_or_writes(value)
623                && !short_option_contains(value, 'f')
624                && !matches!(
625                    *value,
626                    "-R" | "-r"
627                        | "--recursive"
628                        | "--dereference-recursive"
629                        | "--include"
630                        | "--exclude-from"
631                        | "--file"
632                )
633                && !value.starts_with("--exclude-from=")
634                && !value.starts_with("--file=")
635        }),
636        "du" => tokens.iter().skip(1).all(|value| {
637            !short_option_contains(value, 'L')
638                && !matches!(*value, "--dereference")
639                && !option_executes_or_writes(value)
640        }),
641        "df" => tokens
642            .iter()
643            .skip(1)
644            .all(|value| !option_executes_or_writes(value)),
645        "date" => tokens.iter().skip(1).all(|value| {
646            !matches!(*value, "-s" | "--set")
647                && !short_option_contains(value, 's')
648                && !value.starts_with("--set=")
649                && !option_executes_or_writes(value)
650        }),
651        "uname" => tokens
652            .iter()
653            .skip(1)
654            .all(|value| !option_executes_or_writes(value)),
655        "sort" => tokens.iter().skip(1).all(|value| {
656            !matches!(
657                *value,
658                "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
659            ) && !short_option_contains(value, 'o')
660                && !short_option_contains(value, 'T')
661                && !value.starts_with("--output=")
662                && !value.starts_with("--temporary-directory=")
663                && !value.starts_with("--compress-program=")
664                && !option_executes_or_writes(value)
665        }),
666        // uniq writes when a second positional operand is present. Conservatively
667        // allow only options and at most one positional input.
668        "uniq" => positional_argument_count(&tokens[1..]) <= 1,
669        // Keep only plain formatting output in the silent subset. Shell
670        // builtins can still carry surprising option semantics, so options ask.
671        "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
672        "find" => {
673            !tokens
674                .iter()
675                .skip(1)
676                .any(|value| matches!(*value, "-L" | "-H"))
677                && ![
678                    " -delete",
679                    " -exec",
680                    " -execdir",
681                    " -ok",
682                    " -okdir",
683                    " -fprint",
684                    " -fprint0",
685                    " -fprintf",
686                    " -fls",
687                    " -follow",
688                    " -lname",
689                ]
690                .iter()
691                .any(|action| lower.contains(action))
692        }
693        // Sed scripts can write files (`w`) or execute commands (`e`) without
694        // an option-level signal. Keep them behind HITL until a real parser can
695        // prove the script is read-only.
696        "sed" => false,
697        "git" => is_read_only_git_segment(&tokens),
698        _ => false,
699    }
700}
701
702fn short_option_contains(value: &str, flag: char) -> bool {
703    value.starts_with('-')
704        && !value.starts_with("--")
705        && value.chars().skip(1).any(|candidate| candidate == flag)
706}
707
708fn option_executes_or_writes(value: &str) -> bool {
709    matches!(
710        value,
711        "--output" | "--exec" | "--command" | "--config" | "--files-from"
712    ) || value.starts_with("--output=")
713        || value.starts_with("--exec=")
714        || value.starts_with("--command=")
715        || value.starts_with("--config=")
716        || value.starts_with("--files-from=")
717}
718
719fn positional_argument_count(tokens: &[&str]) -> usize {
720    tokens
721        .iter()
722        .filter(|value| !value.starts_with('-'))
723        .count()
724}
725
726fn is_read_only_git_segment(tokens: &[&str]) -> bool {
727    if tokens.first().copied() != Some("git") {
728        return false;
729    }
730    let mut index = 1;
731    while index < tokens.len() {
732        match tokens[index] {
733            "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
734            // `-C` changes the filesystem boundary and is therefore never in
735            // the silent allow-list. Other global config/execution options are
736            // likewise left to confirmation.
737            value if value.starts_with('-') => return false,
738            _ => break,
739        }
740    }
741
742    let Some(subcommand) = tokens.get(index).copied() else {
743        return false;
744    };
745    let args = &tokens[index + 1..];
746    if args.iter().any(|value| {
747        matches!(
748            *value,
749            "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
750        ) || value.starts_with("--exec-path=")
751            || value.starts_with("--config-env=")
752    }) {
753        return false;
754    }
755
756    match subcommand {
757        "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
758            !args.iter().any(|value| {
759                option_executes_or_writes(value)
760                    || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
761                    || value.starts_with("--format=") && value.contains("%(rest)")
762            })
763        }
764        "remote" => match args.first() {
765            Some(value) => matches!(*value, "-v" | "show"),
766            None => true,
767        },
768        "branch" => args.iter().all(|value| {
769            matches!(
770                *value,
771                "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
772            )
773        }),
774        _ => false,
775    }
776}
777
778fn normalize_shell(command: &str) -> String {
779    command.split_whitespace().collect::<Vec<_>>().join(" ")
780}