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" | "download" => "file_path",
182        "search" | "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        // A missing destination is still bounded because download derives and
230        // sanitizes a workspace-relative filename from the response metadata.
231        "download" => args.get("file_path").is_none() || bounded_file_target(args),
232        "git" => {
233            classify_git(args) == PermissionDecision::Ask
234                && git_call_is_known_bounded_mutation(args)
235        }
236        // Shell, delegation, runtime, dynamic scripts, skills, and unknown/MCP
237        // tools retain HITL because their side effects cannot be bounded here.
238        _ => false,
239    }
240}
241
242fn bounded_file_target(args: &serde_json::Value) -> bool {
243    args.get("file_path")
244        .and_then(serde_json::Value::as_str)
245        .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path))
246}
247
248fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
249    if git_requires_explicit_confirmation(args) {
250        return false;
251    }
252    match args.get("command").and_then(serde_json::Value::as_str) {
253        Some("branch") => valid_non_option_string(args, "name"),
254        Some("checkout") => valid_non_option_string(args, "ref"),
255        Some("stash") => {
256            args.get("message")
257                .and_then(serde_json::Value::as_str)
258                .is_some()
259                || args
260                    .get("include_untracked")
261                    .and_then(serde_json::Value::as_bool)
262                    .unwrap_or(false)
263        }
264        Some("remote") => args
265            .get("remote_name")
266            .and_then(serde_json::Value::as_str)
267            .is_some(),
268        Some("worktree") => matches!(
269            args.get("subcommand").and_then(serde_json::Value::as_str),
270            Some("add")
271        ),
272        _ => false,
273    }
274}
275
276fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
277    args.get("force").is_some_and(|value| value != false)
278}
279
280pub(super) fn classify_atomic_tool(
281    tool_name: &str,
282    args: &serde_json::Value,
283) -> PermissionDecision {
284    match tool_name.to_ascii_lowercase().as_str() {
285        "read" => classify_scoped_path(args, "file_path", PermissionDecision::Allow),
286        "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
287            classify_scoped_path(args, "path", PermissionDecision::Allow)
288        }
289        "web_search" | "web_fetch" | "search_skills" | "generate_object" => {
290            PermissionDecision::Allow
291        }
292        "write" | "edit" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
293        "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
294        "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
295        // Patch carries its target in a separate top-level field. A missing or
296        // boundary-crossing target must never be silently approved.
297        "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask),
298        "bash" => classify_bash(args),
299        "git" => classify_git(args),
300        // Delegation, scripts, skills, runtime calls, dynamic and MCP tools can
301        // perform nested or external side effects, so they need authorization.
302        _ => PermissionDecision::Ask,
303    }
304}
305
306fn classify_scoped_path(
307    args: &serde_json::Value,
308    field: &str,
309    safe_decision: PermissionDecision,
310) -> PermissionDecision {
311    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
312        // Some read-only tools have an optional path that defaults to the
313        // workspace root. A missing write target remains malformed and asks.
314        return if field == "path" {
315            safe_decision
316        } else {
317            PermissionDecision::Ask
318        };
319    };
320    if path.trim().is_empty() {
321        return if field == "path" {
322            safe_decision
323        } else {
324            PermissionDecision::Ask
325        };
326    }
327    if path_is_outside_workspace(path) {
328        PermissionDecision::Deny
329    } else {
330        safe_decision
331    }
332}
333
334fn classify_git(args: &serde_json::Value) -> PermissionDecision {
335    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
336        return PermissionDecision::Ask;
337    };
338    if args
339        .get("force")
340        .is_some_and(|value| value.as_bool() != Some(false))
341    {
342        return PermissionDecision::Ask;
343    }
344
345    match command {
346        "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
347        "log"
348            if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
349                && valid_optional_positive_integer(args, "limit")
350                && valid_optional_positive_integer(args, "max_count")
351                && valid_optional_string(args, "cursor") =>
352        {
353            PermissionDecision::Allow
354        }
355        "diff"
356            if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
357                && valid_optional_non_option_string(args, "target")
358                && valid_optional_nonnegative_integer(args, "byte_offset")
359                && valid_optional_positive_integer(args, "max_bytes") =>
360        {
361            PermissionDecision::Allow
362        }
363        "remote"
364            if only_git_keys(args, &["command", "remote_name", "cursor"])
365                && valid_optional_string(args, "remote_name")
366                && valid_optional_string(args, "cursor") =>
367        {
368            PermissionDecision::Allow
369        }
370        "branch"
371            if args.get("name").is_none()
372                && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
373                && valid_optional_positive_integer(args, "limit")
374                && valid_optional_positive_integer(args, "max_count")
375                && valid_optional_string(args, "cursor") =>
376        {
377            PermissionDecision::Allow
378        }
379        "stash"
380            if args.get("message").is_none()
381                && args.get("include_untracked").is_none()
382                && only_git_keys(args, &["command", "cursor"])
383                && valid_optional_string(args, "cursor") =>
384        {
385            PermissionDecision::Allow
386        }
387        "worktree"
388            if args
389                .get("subcommand")
390                .and_then(serde_json::Value::as_str)
391                .unwrap_or("list")
392                == "list"
393                && only_git_keys(args, &["command", "subcommand", "cursor"])
394                && valid_optional_string(args, "subcommand")
395                && valid_optional_string(args, "cursor") =>
396        {
397            PermissionDecision::Allow
398        }
399        _ => PermissionDecision::Ask,
400    }
401}
402
403fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
404    args.as_object().is_some_and(|object| {
405        object
406            .keys()
407            .all(|key| allowed.iter().any(|allowed| key == allowed))
408    })
409}
410
411fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
412    args.get(field)
413        .and_then(serde_json::Value::as_str)
414        .is_some_and(|value| {
415            let value = value.trim();
416            !value.is_empty() && !value.starts_with('-')
417        })
418}
419
420fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
421    args.get(field)
422        .is_none_or(|_| valid_non_option_string(args, field))
423}
424
425fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
426    args.get(field).is_none_or(serde_json::Value::is_string)
427}
428
429fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
430    args.get(field)
431        .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
432}
433
434fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
435    args.get(field).is_none_or(|value| value.as_u64().is_some())
436}
437
438fn classify_bash(args: &serde_json::Value) -> PermissionDecision {
439    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
440        return PermissionDecision::Ask;
441    };
442    let command = command.trim();
443    if command.is_empty() {
444        return PermissionDecision::Ask;
445    }
446    if is_catastrophic_bash_command(command) {
447        PermissionDecision::Deny
448    } else if is_read_only_bash_command(command) {
449        PermissionDecision::Allow
450    } else {
451        PermissionDecision::Ask
452    }
453}
454
455fn is_catastrophic_bash_command(command: &str) -> bool {
456    let lower = normalize_shell(command).to_ascii_lowercase();
457    if lower == "sudo"
458        || lower.starts_with("sudo ")
459        || lower.starts_with("doas ")
460        || lower == "su"
461        || lower.starts_with("su ")
462        || lower.starts_with("su -")
463    {
464        return true;
465    }
466    if shell_invokes_mkfs(command)
467        || lower.contains("diskutil erase")
468        || lower.contains(":(){")
469        || lower.contains("kill -9 -1")
470        || lower.starts_with("shutdown")
471        || lower.starts_with("reboot")
472    {
473        return true;
474    }
475    if (lower.contains("curl ") || lower.contains("wget "))
476        && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
477            .iter()
478            .any(|pipe| lower.contains(pipe))
479    {
480        return true;
481    }
482    if (lower.starts_with("dd ") || lower.contains(" dd "))
483        && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
484    {
485        return true;
486    }
487
488    lower.contains("rm -rf /")
489        || lower.contains("rm -fr /")
490        || lower.contains("rm -rf ~")
491        || lower.contains("rm -fr ~")
492        || lower.contains("rm -rf $home")
493        || lower.contains("rm -fr $home")
494        || lower.contains("rm -rf *")
495        || lower.contains("rm -fr *")
496        || lower == "rm -rf ."
497        || lower == "rm -fr ."
498}
499
500fn shell_invokes_mkfs(command: &str) -> bool {
501    command
502        .split(['|', ';', '&'])
503        .filter_map(|segment| segment.split_whitespace().next())
504        .map(clean_shell_token)
505        .filter_map(|executable| executable.rsplit('/').next())
506        .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
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                && !short_option_contains(value, 'f')
613                && !matches!(
614                    *value,
615                    "-R" | "-r"
616                        | "--recursive"
617                        | "--dereference-recursive"
618                        | "--include"
619                        | "--exclude-from"
620                        | "--file"
621                )
622                && !value.starts_with("--exclude-from=")
623                && !value.starts_with("--file=")
624        }),
625        "du" => tokens.iter().skip(1).all(|value| {
626            !short_option_contains(value, 'L')
627                && !matches!(*value, "--dereference")
628                && !option_executes_or_writes(value)
629        }),
630        "df" => tokens
631            .iter()
632            .skip(1)
633            .all(|value| !option_executes_or_writes(value)),
634        "date" => tokens.iter().skip(1).all(|value| {
635            !matches!(*value, "-s" | "--set")
636                && !short_option_contains(value, 's')
637                && !value.starts_with("--set=")
638                && !option_executes_or_writes(value)
639        }),
640        "uname" => tokens
641            .iter()
642            .skip(1)
643            .all(|value| !option_executes_or_writes(value)),
644        "sort" => tokens.iter().skip(1).all(|value| {
645            !matches!(
646                *value,
647                "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
648            ) && !short_option_contains(value, 'o')
649                && !short_option_contains(value, 'T')
650                && !value.starts_with("--output=")
651                && !value.starts_with("--temporary-directory=")
652                && !value.starts_with("--compress-program=")
653                && !option_executes_or_writes(value)
654        }),
655        // uniq writes when a second positional operand is present. Conservatively
656        // allow only options and at most one positional input.
657        "uniq" => positional_argument_count(&tokens[1..]) <= 1,
658        // Keep only plain formatting output in the silent subset. Shell
659        // builtins can still carry surprising option semantics, so options ask.
660        "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
661        "find" => {
662            !tokens
663                .iter()
664                .skip(1)
665                .any(|value| matches!(*value, "-L" | "-H"))
666                && ![
667                    " -delete",
668                    " -exec",
669                    " -execdir",
670                    " -ok",
671                    " -okdir",
672                    " -fprint",
673                    " -fprint0",
674                    " -fprintf",
675                    " -fls",
676                    " -follow",
677                    " -lname",
678                ]
679                .iter()
680                .any(|action| lower.contains(action))
681        }
682        // Sed scripts can write files (`w`) or execute commands (`e`) without
683        // an option-level signal. Keep them behind HITL until a real parser can
684        // prove the script is read-only.
685        "sed" => false,
686        "git" => is_read_only_git_segment(&tokens),
687        _ => false,
688    }
689}
690
691fn short_option_contains(value: &str, flag: char) -> bool {
692    value.starts_with('-')
693        && !value.starts_with("--")
694        && value.chars().skip(1).any(|candidate| candidate == flag)
695}
696
697fn option_executes_or_writes(value: &str) -> bool {
698    matches!(
699        value,
700        "--output" | "--exec" | "--command" | "--config" | "--files-from"
701    ) || value.starts_with("--output=")
702        || value.starts_with("--exec=")
703        || value.starts_with("--command=")
704        || value.starts_with("--config=")
705        || value.starts_with("--files-from=")
706}
707
708fn positional_argument_count(tokens: &[&str]) -> usize {
709    tokens
710        .iter()
711        .filter(|value| !value.starts_with('-'))
712        .count()
713}
714
715fn is_read_only_git_segment(tokens: &[&str]) -> bool {
716    if tokens.first().copied() != Some("git") {
717        return false;
718    }
719    let mut index = 1;
720    while index < tokens.len() {
721        match tokens[index] {
722            "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
723            // `-C` changes the filesystem boundary and is therefore never in
724            // the silent allow-list. Other global config/execution options are
725            // likewise left to confirmation.
726            value if value.starts_with('-') => return false,
727            _ => break,
728        }
729    }
730
731    let Some(subcommand) = tokens.get(index).copied() else {
732        return false;
733    };
734    let args = &tokens[index + 1..];
735    if args.iter().any(|value| {
736        matches!(
737            *value,
738            "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
739        ) || value.starts_with("--exec-path=")
740            || value.starts_with("--config-env=")
741    }) {
742        return false;
743    }
744
745    match subcommand {
746        "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
747            !args.iter().any(|value| {
748                option_executes_or_writes(value)
749                    || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
750                    || value.starts_with("--format=") && value.contains("%(rest)")
751            })
752        }
753        "remote" => match args.first() {
754            Some(value) => matches!(*value, "-v" | "show"),
755            None => true,
756        },
757        "branch" => args.iter().all(|value| {
758            matches!(
759                *value,
760                "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
761            )
762        }),
763        _ => false,
764    }
765}
766
767fn normalize_shell(command: &str) -> String {
768    command.split_whitespace().collect::<Vec<_>>().join(" ")
769}