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