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