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 reads and deny workspace mutations. Not an alias of default.
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. Plan denies bounded and high
46    /// mutations. Default requires confirmation for them, while auto
47    /// streamlines bounded mutations. Force also streamlines high-risk review
48    /// candidates. Critical rule denials are 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::Plan, ToolRiskLevel::Bounded | ToolRiskLevel::High) => ToolRiskAction::RuleDeny,
53            (Self::Auto | Self::Force, ToolRiskLevel::Bounded) => ToolRiskAction::Allow,
54            (_, ToolRiskLevel::Bounded) => ToolRiskAction::RequireConfirmation,
55            (Self::Force, ToolRiskLevel::High) => ToolRiskAction::Allow,
56            (_, ToolRiskLevel::High) => ToolRiskAction::ReviewByLlm,
57            (_, ToolRiskLevel::Critical) => ToolRiskAction::RuleDeny,
58        }
59    }
60
61    fn apply(self, assessment: &ToolRiskAssessment) -> PermissionDecision {
62        match self.action_for(assessment) {
63            ToolRiskAction::Allow => PermissionDecision::Allow,
64            ToolRiskAction::RequireConfirmation | ToolRiskAction::ReviewByLlm => {
65                // PermissionDecision remains backward compatible. Hosts that
66                // understand ToolRiskAction can distinguish human confirmation
67                // from LLM review through `InteractiveToolGuardrail::assess`.
68                PermissionDecision::Ask
69            }
70            ToolRiskAction::RuleDeny => PermissionDecision::Deny,
71        }
72    }
73}
74
75/// Shared Codex-style guardrail used by the terminal and web Code products.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct InteractiveToolGuardrail {
78    mode: InteractiveApprovalMode,
79    workspace: Option<std::path::PathBuf>,
80}
81
82impl InteractiveToolGuardrail {
83    pub const fn new(mode: InteractiveApprovalMode) -> Self {
84        Self {
85            mode,
86            workspace: None,
87        }
88    }
89
90    pub fn for_mode(mode: &str) -> Self {
91        Self::new(InteractiveApprovalMode::from_name(mode))
92    }
93
94    /// Add a local workspace root so absolute in-workspace paths are admitted
95    /// and existing symlink components can be checked.
96    pub fn with_workspace(mut self, workspace: impl Into<std::path::PathBuf>) -> Self {
97        self.workspace = Some(workspace.into());
98        self
99    }
100
101    /// Return the explainable risk assessment before host mode semantics.
102    ///
103    /// Static callers have no workspace root, so absolute paths fail closed.
104    /// Prefer [`Self::assess`] when a workspace is known.
105    pub fn risk_assessment(tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
106        assess_tool(tool_name, args, None)
107    }
108
109    /// Return the conservative legacy permission decision before mode semantics.
110    ///
111    /// This projection preserves existing host integrations. New hosts should
112    /// consume [`Self::risk_assessment`] and the mode's decision matrix when they
113    /// need to distinguish human confirmation from LLM review.
114    ///
115    /// Static callers have no workspace root, so absolute paths fail closed.
116    /// Prefer [`Self::check`] / [`Self::assess`] when a workspace is known.
117    pub fn risk_decision(tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
118        assessment_permission(&assess_tool(tool_name, args, None))
119    }
120
121    /// Return whether an exact Bash command matches the deterministic,
122    /// non-bypassable catastrophic-operation floor.
123    ///
124    /// Sandboxed hosts use this separately from the conservative lexical risk
125    /// projection: unknown shell syntax may be safe inside an enforced OS
126    /// boundary, while destructive system commands remain denied in every
127    /// execution mode.
128    pub fn is_catastrophic_bash_command(command: &str) -> bool {
129        is_catastrophic_bash_command(command)
130    }
131
132    /// Assess an invocation, including workspace path and symlink boundary checks.
133    pub fn assess(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAssessment {
134        if let Some(assessment) = self.workspace_boundary_assessment(tool_name, args) {
135            return assessment;
136        }
137        assess_tool(tool_name, args, self.workspace.as_deref())
138    }
139
140    /// Return the explicit routing action selected for this guardrail mode.
141    pub fn risk_action(&self, tool_name: &str, args: &serde_json::Value) -> ToolRiskAction {
142        self.mode.action_for(&self.assess(tool_name, args))
143    }
144
145    fn workspace_boundary_assessment(
146        &self,
147        tool_name: &str,
148        args: &serde_json::Value,
149    ) -> Option<ToolRiskAssessment> {
150        let root = self.workspace.as_deref()?;
151        invocation_crosses_local_symlink(root, tool_name, args).then(|| {
152            critical_assessment(
153                tool_risk_type(tool_name),
154                OperationTarget::OutsideWorkspace,
155                ImpactScope::Host,
156                Reversibility::Unknown,
157                EnvironmentSensitivity::Host,
158                ToolRiskReason::SymlinkBoundaryEscape,
159            )
160        })
161    }
162}
163
164impl Default for InteractiveToolGuardrail {
165    fn default() -> Self {
166        Self::new(InteractiveApprovalMode::Default)
167    }
168}
169
170impl PermissionChecker for InteractiveToolGuardrail {
171    fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
172        self.mode.apply(&self.assess(tool_name, args))
173    }
174}
175
176fn invocation_crosses_local_symlink(
177    root: &std::path::Path,
178    tool_name: &str,
179    args: &serde_json::Value,
180) -> bool {
181    if tool_name.eq_ignore_ascii_case("batch") {
182        return args
183            .get("invocations")
184            .and_then(serde_json::Value::as_array)
185            .is_some_and(|invocations| {
186                invocations.iter().any(|invocation| {
187                    let Some(tool) = invocation.get("tool").and_then(serde_json::Value::as_str)
188                    else {
189                        return false;
190                    };
191                    let Some(tool_args) = invocation.get("args") else {
192                        return false;
193                    };
194                    invocation_crosses_local_symlink(root, tool, tool_args)
195                })
196            });
197    }
198
199    let tool = tool_name.to_ascii_lowercase();
200    if tool == "bash" {
201        return shell_path_crosses_symlink(root, args);
202    }
203    if tool == "read" {
204        if let Some(path) = args.get("file_path").and_then(serde_json::Value::as_str) {
205            return local_path_crosses_symlink(root, path);
206        }
207        return args
208            .get("files")
209            .and_then(serde_json::Value::as_array)
210            .is_some_and(|files| {
211                files.iter().any(|entry| {
212                    entry
213                        .get("path")
214                        .and_then(serde_json::Value::as_str)
215                        .is_some_and(|path| local_path_crosses_symlink(root, path))
216                })
217            });
218    }
219    let field = match tool.as_str() {
220        "write" | "edit" | "patch" | "download" => "file_path",
221        "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => "path",
222        _ => return false,
223    };
224    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
225        return false;
226    };
227    local_path_crosses_symlink(root, path)
228}
229
230fn shell_path_crosses_symlink(root: &std::path::Path, args: &serde_json::Value) -> bool {
231    args.get("command")
232        .and_then(serde_json::Value::as_str)
233        .is_some_and(|command| {
234            command
235                .split_whitespace()
236                .map(clean_shell_token)
237                .filter(|token| !token.is_empty() && !token.starts_with('-'))
238                .any(|token| shell_token_path_crosses_symlink(root, token))
239        })
240}
241
242fn local_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
243    path_crosses_symlink(root, path, false)
244}
245
246fn shell_token_path_crosses_symlink(root: &std::path::Path, path: &str) -> bool {
247    path_crosses_symlink(root, path, true)
248}
249
250fn path_crosses_symlink(root: &std::path::Path, path: &str, stop_at_shell_glob: bool) -> bool {
251    if path_is_outside_workspace(path, Some(root)) {
252        return false;
253    }
254    let Some(relative) = workspace_relative_path(root, path) else {
255        return true;
256    };
257    let mut current = root.to_path_buf();
258    for component in std::path::Path::new(&relative).components() {
259        match component {
260            std::path::Component::CurDir => continue,
261            std::path::Component::Normal(component) => {
262                if stop_at_shell_glob
263                    && component
264                        .to_string_lossy()
265                        .contains(['*', '?', '[', ']', '{', '}'])
266                {
267                    // A glob is not a literal filesystem component. Prefixes
268                    // already visited above remain checked, while the lexical
269                    // Bash classifier routes the unresolved expansion to HITL.
270                    return false;
271                }
272                current.push(component);
273            }
274            _ => return true,
275        }
276        match std::fs::symlink_metadata(&current) {
277            Ok(metadata) if metadata.file_type().is_symlink() => return true,
278            Ok(_) => {}
279            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
280            Err(_) => return true,
281        }
282    }
283    false
284}
285
286pub(super) fn atomic_tool_is_bounded(
287    tool_name: &str,
288    args: &serde_json::Value,
289    workspace: Option<&std::path::Path>,
290) -> bool {
291    match tool_name.to_ascii_lowercase().as_str() {
292        // Workspace-confined edits and ordinary structured Git changes are the
293        // bounded operations that auto mode exists to streamline.
294        "write" | "edit" | "patch" => bounded_file_target(args, workspace),
295        // A missing destination is still bounded because download derives and
296        // sanitizes a workspace-relative filename from the response metadata.
297        "download" => args.get("file_path").is_none() || bounded_file_target(args, workspace),
298        "git" => {
299            classify_git(args) == PermissionDecision::Ask
300                && git_call_is_known_bounded_mutation(args)
301        }
302        // Shell, delegation, runtime, dynamic scripts, skills, and unknown/MCP
303        // tools retain HITL because their side effects cannot be bounded here.
304        _ => false,
305    }
306}
307
308fn bounded_file_target(args: &serde_json::Value, workspace: Option<&std::path::Path>) -> bool {
309    args.get("file_path")
310        .and_then(serde_json::Value::as_str)
311        .is_some_and(|path| !path.trim().is_empty() && !path_is_outside_workspace(path, workspace))
312}
313
314fn git_call_is_known_bounded_mutation(args: &serde_json::Value) -> bool {
315    if git_requires_explicit_confirmation(args) {
316        return false;
317    }
318    match args.get("command").and_then(serde_json::Value::as_str) {
319        Some("branch") => valid_non_option_string(args, "name"),
320        Some("checkout") => valid_non_option_string(args, "ref"),
321        Some("stash") => {
322            args.get("message")
323                .and_then(serde_json::Value::as_str)
324                .is_some()
325                || args
326                    .get("include_untracked")
327                    .and_then(serde_json::Value::as_bool)
328                    .unwrap_or(false)
329        }
330        Some("remote") => args
331            .get("remote_name")
332            .and_then(serde_json::Value::as_str)
333            .is_some(),
334        Some("worktree") => matches!(
335            args.get("subcommand").and_then(serde_json::Value::as_str),
336            Some("add")
337        ),
338        _ => false,
339    }
340}
341
342fn git_requires_explicit_confirmation(args: &serde_json::Value) -> bool {
343    args.get("force").is_some_and(|value| value != false)
344}
345
346pub(super) fn classify_atomic_tool(
347    tool_name: &str,
348    args: &serde_json::Value,
349    workspace: Option<&std::path::Path>,
350) -> PermissionDecision {
351    match tool_name.to_ascii_lowercase().as_str() {
352        "read" => classify_read(args, workspace),
353        "search" | "ls" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
354            classify_scoped_path(args, "path", PermissionDecision::Allow, workspace)
355        }
356        "web_search" | "web_fetch" | "search_skills" | "generate_object" | "update_plan"
357        | "ask_user" => PermissionDecision::Allow,
358        "write" | "edit" => {
359            classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace)
360        }
361        "download" if args.get("file_path").is_none() => PermissionDecision::Ask,
362        "download" => classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace),
363        // Patch carries its target in a separate top-level field. A missing or
364        // boundary-crossing target must never be silently approved.
365        "patch" => classify_scoped_path(args, "file_path", PermissionDecision::Ask, workspace),
366        "bash" => classify_bash(args, workspace),
367        "git" => classify_git(args),
368        // Delegation, scripts, skills, runtime calls, dynamic and MCP tools can
369        // perform nested or external side effects, so they need authorization.
370        _ => PermissionDecision::Ask,
371    }
372}
373
374fn classify_read(
375    args: &serde_json::Value,
376    workspace: Option<&std::path::Path>,
377) -> PermissionDecision {
378    if let Some(path) = args.get("file_path").and_then(serde_json::Value::as_str) {
379        return classify_path_value(path, PermissionDecision::Allow, workspace);
380    }
381    let Some(files) = args.get("files").and_then(serde_json::Value::as_array) else {
382        return PermissionDecision::Ask;
383    };
384    if files.is_empty() {
385        return PermissionDecision::Ask;
386    }
387    let mut decision = PermissionDecision::Allow;
388    for entry in files {
389        let Some(path) = entry.get("path").and_then(serde_json::Value::as_str) else {
390            return PermissionDecision::Ask;
391        };
392        decision = stricter_permission(
393            decision,
394            classify_path_value(path, PermissionDecision::Allow, workspace),
395        );
396    }
397    decision
398}
399
400fn stricter_permission(left: PermissionDecision, right: PermissionDecision) -> PermissionDecision {
401    use PermissionDecision::*;
402    match (left, right) {
403        (Deny, _) | (_, Deny) => Deny,
404        (Ask, _) | (_, Ask) => Ask,
405        (Allow, Allow) => Allow,
406    }
407}
408
409fn classify_scoped_path(
410    args: &serde_json::Value,
411    field: &str,
412    safe_decision: PermissionDecision,
413    workspace: Option<&std::path::Path>,
414) -> PermissionDecision {
415    let Some(path) = args.get(field).and_then(serde_json::Value::as_str) else {
416        // Some read-only tools have an optional path that defaults to the
417        // workspace root. A missing write target remains malformed and asks.
418        return if field == "path" {
419            safe_decision
420        } else {
421            PermissionDecision::Ask
422        };
423    };
424    if path.trim().is_empty() {
425        return if field == "path" {
426            safe_decision
427        } else {
428            PermissionDecision::Ask
429        };
430    }
431    classify_path_value(path, safe_decision, workspace)
432}
433
434fn classify_path_value(
435    path: &str,
436    safe_decision: PermissionDecision,
437    workspace: Option<&std::path::Path>,
438) -> PermissionDecision {
439    if path.trim().is_empty() {
440        return PermissionDecision::Ask;
441    }
442    if path_is_outside_workspace(path, workspace) {
443        PermissionDecision::Deny
444    } else {
445        safe_decision
446    }
447}
448
449fn classify_git(args: &serde_json::Value) -> PermissionDecision {
450    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
451        return PermissionDecision::Ask;
452    };
453    if args
454        .get("force")
455        .is_some_and(|value| value.as_bool() != Some(false))
456    {
457        return PermissionDecision::Ask;
458    }
459
460    match command {
461        "status" if only_git_keys(args, &["command"]) => PermissionDecision::Allow,
462        "log"
463            if only_git_keys(args, &["command", "limit", "max_count", "cursor"])
464                && valid_optional_positive_integer(args, "limit")
465                && valid_optional_positive_integer(args, "max_count")
466                && valid_optional_string(args, "cursor") =>
467        {
468            PermissionDecision::Allow
469        }
470        "diff"
471            if only_git_keys(args, &["command", "target", "byte_offset", "max_bytes"])
472                && valid_optional_non_option_string(args, "target")
473                && valid_optional_nonnegative_integer(args, "byte_offset")
474                && valid_optional_positive_integer(args, "max_bytes") =>
475        {
476            PermissionDecision::Allow
477        }
478        "remote"
479            if only_git_keys(args, &["command", "remote_name", "cursor"])
480                && valid_optional_string(args, "remote_name")
481                && valid_optional_string(args, "cursor") =>
482        {
483            PermissionDecision::Allow
484        }
485        "branch"
486            if args.get("name").is_none()
487                && only_git_keys(args, &["command", "limit", "max_count", "cursor"])
488                && valid_optional_positive_integer(args, "limit")
489                && valid_optional_positive_integer(args, "max_count")
490                && valid_optional_string(args, "cursor") =>
491        {
492            PermissionDecision::Allow
493        }
494        "stash"
495            if args.get("message").is_none()
496                && args.get("include_untracked").is_none()
497                && only_git_keys(args, &["command", "cursor"])
498                && valid_optional_string(args, "cursor") =>
499        {
500            PermissionDecision::Allow
501        }
502        "worktree"
503            if args
504                .get("subcommand")
505                .and_then(serde_json::Value::as_str)
506                .unwrap_or("list")
507                == "list"
508                && only_git_keys(args, &["command", "subcommand", "cursor"])
509                && valid_optional_string(args, "subcommand")
510                && valid_optional_string(args, "cursor") =>
511        {
512            PermissionDecision::Allow
513        }
514        _ => PermissionDecision::Ask,
515    }
516}
517
518fn only_git_keys(args: &serde_json::Value, allowed: &[&str]) -> bool {
519    args.as_object().is_some_and(|object| {
520        object
521            .keys()
522            .all(|key| allowed.iter().any(|allowed| key == allowed))
523    })
524}
525
526fn valid_non_option_string(args: &serde_json::Value, field: &str) -> bool {
527    args.get(field)
528        .and_then(serde_json::Value::as_str)
529        .is_some_and(|value| {
530            let value = value.trim();
531            !value.is_empty() && !value.starts_with('-')
532        })
533}
534
535fn valid_optional_non_option_string(args: &serde_json::Value, field: &str) -> bool {
536    args.get(field)
537        .is_none_or(|_| valid_non_option_string(args, field))
538}
539
540fn valid_optional_string(args: &serde_json::Value, field: &str) -> bool {
541    args.get(field).is_none_or(serde_json::Value::is_string)
542}
543
544fn valid_optional_positive_integer(args: &serde_json::Value, field: &str) -> bool {
545    args.get(field)
546        .is_none_or(|value| value.as_u64().is_some_and(|number| number > 0))
547}
548
549fn valid_optional_nonnegative_integer(args: &serde_json::Value, field: &str) -> bool {
550    args.get(field).is_none_or(|value| value.as_u64().is_some())
551}
552
553fn classify_bash(
554    args: &serde_json::Value,
555    workspace: Option<&std::path::Path>,
556) -> PermissionDecision {
557    let Some(command) = args.get("command").and_then(serde_json::Value::as_str) else {
558        return PermissionDecision::Ask;
559    };
560    let command = command.trim();
561    if command.is_empty() {
562        return PermissionDecision::Ask;
563    }
564    if is_catastrophic_bash_command(command) {
565        PermissionDecision::Deny
566    } else if is_read_only_bash_command(command, workspace) {
567        PermissionDecision::Allow
568    } else {
569        PermissionDecision::Ask
570    }
571}
572
573fn is_catastrophic_bash_command(command: &str) -> bool {
574    let lower = normalize_shell(command).to_ascii_lowercase();
575    if lower == "sudo"
576        || lower.starts_with("sudo ")
577        || lower.starts_with("doas ")
578        || lower == "su"
579        || lower.starts_with("su ")
580        || lower.starts_with("su -")
581    {
582        return true;
583    }
584    if shell_invokes_mkfs(command)
585        || lower.contains("diskutil erase")
586        || lower.contains(":(){")
587        || lower.contains("kill -9 -1")
588        || lower.starts_with("shutdown")
589        || lower.starts_with("reboot")
590    {
591        return true;
592    }
593    if (lower.contains("curl ") || lower.contains("wget "))
594        && ["| sh", "|sh", "| bash", "|bash", "| zsh", "|zsh"]
595            .iter()
596            .any(|pipe| lower.contains(pipe))
597    {
598        return true;
599    }
600    if (lower.starts_with("dd ") || lower.contains(" dd "))
601        && (lower.contains(" of=/dev/") || lower.contains("of=/dev/"))
602    {
603        return true;
604    }
605
606    lower.contains("rm -rf /")
607        || lower.contains("rm -fr /")
608        || lower.contains("rm -rf ~")
609        || lower.contains("rm -fr ~")
610        || lower.contains("rm -rf $home")
611        || lower.contains("rm -fr $home")
612        || lower.contains("rm -rf *")
613        || lower.contains("rm -fr *")
614        || lower == "rm -rf ."
615        || lower == "rm -fr ."
616}
617
618fn shell_invokes_mkfs(command: &str) -> bool {
619    command
620        .split(['|', ';', '&'])
621        .filter_map(|segment| segment.split_whitespace().next())
622        .map(clean_shell_token)
623        .filter_map(|executable| executable.rsplit('/').next())
624        .any(|executable| executable == "mkfs" || executable.starts_with("mkfs."))
625}
626
627fn is_read_only_bash_command(command: &str, workspace: Option<&std::path::Path>) -> bool {
628    // The allow-list intentionally rejects shell quoting, expansion, globs, and
629    // non-space control whitespace. A tokenizer-aware sandbox can broaden this
630    // later; a string heuristic must fail closed.
631    if command
632        .chars()
633        .any(|character| character.is_whitespace() && character != ' ')
634        || command.contains(['\'', '"', '*', '?', '[', ']', '{', '}'])
635        || contains_unsafe_shell_syntax(command, workspace)
636    {
637        return false;
638    }
639    command
640        .split('|')
641        .all(|segment| is_read_only_bash_segment(segment.trim()))
642}
643
644fn contains_unsafe_shell_syntax(command: &str, workspace: Option<&std::path::Path>) -> bool {
645    command.contains("&&")
646        || command.contains("||")
647        || command.contains(';')
648        || command.contains('>')
649        || command.contains('<')
650        || command.contains('`')
651        || command.contains("$(")
652        || command.contains('&')
653        || command.contains('\n')
654        || command.contains('\r')
655        || command.contains('$')
656        || has_unscoped_path_token(command, workspace)
657}
658
659fn has_unscoped_path_token(command: &str, workspace: Option<&std::path::Path>) -> bool {
660    command
661        .split_whitespace()
662        .map(clean_shell_token)
663        .filter(|token| !token.is_empty())
664        .any(|token| path_is_outside_workspace(token, workspace))
665}
666
667fn clean_shell_token(token: &str) -> &str {
668    token.trim_matches(|character: char| {
669        matches!(
670            character,
671            '\'' | '"' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ':'
672        )
673    })
674}
675
676/// Return true when `path` is outside the optional workspace root.
677///
678/// Without a workspace root, absolute paths and `..` escapes fail closed.
679/// With a root, absolute paths that normalize inside the workspace are admitted.
680fn path_is_outside_workspace(path: &str, workspace: Option<&std::path::Path>) -> bool {
681    let normalized = path.replace('\\', "/");
682    let path = normalized.trim();
683    if path.is_empty() {
684        return false;
685    }
686    if path.starts_with('~') || path.starts_with("$HOME") || path.starts_with("${HOME}") {
687        return true;
688    }
689
690    let Some(root) = workspace else {
691        return path_is_lexically_absolute(path) || relative_path_escapes(path);
692    };
693    path_escapes_workspace_root(root, path)
694}
695
696fn path_is_lexically_absolute(path: &str) -> bool {
697    let bytes = path.as_bytes();
698    (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
699        || path.starts_with("//")
700        || path.starts_with('/')
701}
702
703fn relative_path_escapes(path: &str) -> bool {
704    let mut depth = 0_i32;
705    for component in path.split('/') {
706        match component {
707            "" | "." => {}
708            ".." if depth == 0 => return true,
709            ".." => depth -= 1,
710            _ => depth += 1,
711        }
712    }
713    false
714}
715
716fn path_escapes_workspace_root(root: &std::path::Path, input: &str) -> bool {
717    let candidate = std::path::Path::new(input);
718    if candidate.is_absolute() || path_is_lexically_absolute(input) {
719        match (
720            normalize_abs_for_compare(root),
721            normalize_abs_for_compare(candidate),
722        ) {
723            (Ok(root_cmp), Ok(target_cmp)) => !target_cmp.starts_with(&root_cmp),
724            _ => true,
725        }
726    } else {
727        relative_path_escapes(input)
728    }
729}
730
731fn workspace_relative_path(root: &std::path::Path, path: &str) -> Option<String> {
732    let candidate = std::path::Path::new(path);
733    if !candidate.is_absolute() && !path_is_lexically_absolute(path) {
734        return Some(path.replace('\\', "/"));
735    }
736    let root_cmp = normalize_abs_for_compare(root).ok()?;
737    let target_cmp = normalize_abs_for_compare(candidate).ok()?;
738    let relative = target_cmp.strip_prefix(&root_cmp).ok()?;
739    Some(relative.to_string_lossy().replace('\\', "/"))
740}
741
742/// Canonicalize when possible; for missing leaf paths, canonicalize the
743/// deepest existing ancestor and reattach the suffix (macOS `/var` →
744/// `/private/var` must stay consistent with the workspace root).
745fn normalize_abs_for_compare(path: &std::path::Path) -> Result<std::path::PathBuf, ()> {
746    let lexical = normalize_abs_lexical(path)?;
747    if let Ok(canonical) = lexical.canonicalize() {
748        return Ok(canonical);
749    }
750
751    let mut current = lexical.as_path();
752    let mut suffix = Vec::new();
753    while !current.exists() {
754        let Some(file_name) = current.file_name() else {
755            return Ok(lexical);
756        };
757        suffix.push(file_name.to_os_string());
758        let Some(parent) = current.parent() else {
759            return Ok(lexical);
760        };
761        current = parent;
762    }
763
764    let mut normalized = current
765        .canonicalize()
766        .unwrap_or_else(|_| current.to_path_buf());
767    for part in suffix.iter().rev() {
768        normalized.push(part);
769    }
770    Ok(normalized)
771}
772
773fn normalize_abs_lexical(path: &std::path::Path) -> Result<std::path::PathBuf, ()> {
774    let mut out = std::path::PathBuf::new();
775    for component in path.components() {
776        match component {
777            std::path::Component::Prefix(prefix) => out.push(prefix.as_os_str()),
778            std::path::Component::RootDir => {
779                out.push(std::path::Path::new(std::path::MAIN_SEPARATOR_STR));
780            }
781            std::path::Component::CurDir => {}
782            std::path::Component::Normal(part) => out.push(part),
783            std::path::Component::ParentDir => {
784                if !out.pop() {
785                    return Err(());
786                }
787            }
788        }
789    }
790    if out.as_os_str().is_empty() {
791        Err(())
792    } else {
793        Ok(out)
794    }
795}
796
797fn is_read_only_bash_segment(segment: &str) -> bool {
798    let tokens: Vec<&str> = segment.split_whitespace().collect();
799    let Some(command) = tokens.first().copied().map(clean_shell_token) else {
800        return false;
801    };
802    let lower = segment.to_ascii_lowercase();
803
804    match command {
805        "pwd" | "cat" | "head" | "tail" | "wc" | "stat" | "file" | "cut" | "tr" | "whoami" => {
806            tokens
807                .iter()
808                .skip(1)
809                .all(|value| !option_executes_or_writes(value))
810        }
811        "ls" => tokens.iter().skip(1).all(|value| {
812            !option_executes_or_writes(value)
813                && !short_option_contains(value, 'L')
814                && !short_option_contains(value, 'R')
815                && !matches!(*value, "--dereference" | "--recursive")
816        }),
817        "rg" => tokens.iter().skip(1).all(|value| {
818            !option_executes_or_writes(value)
819                && !matches!(*value, "--pre" | "--hostname-bin" | "-L" | "--follow")
820                && !value.starts_with("--pre=")
821                && !value.starts_with("--hostname-bin=")
822        }),
823        "grep" => tokens.iter().skip(1).all(|value| {
824            !option_executes_or_writes(value)
825                && !short_option_contains(value, 'f')
826                && !matches!(
827                    *value,
828                    "-R" | "-r"
829                        | "--recursive"
830                        | "--dereference-recursive"
831                        | "--include"
832                        | "--exclude-from"
833                        | "--file"
834                )
835                && !value.starts_with("--exclude-from=")
836                && !value.starts_with("--file=")
837        }),
838        "du" => tokens.iter().skip(1).all(|value| {
839            !short_option_contains(value, 'L')
840                && !matches!(*value, "--dereference")
841                && !option_executes_or_writes(value)
842        }),
843        "df" => tokens
844            .iter()
845            .skip(1)
846            .all(|value| !option_executes_or_writes(value)),
847        "date" => tokens.iter().skip(1).all(|value| {
848            !matches!(*value, "-s" | "--set")
849                && !short_option_contains(value, 's')
850                && !value.starts_with("--set=")
851                && !option_executes_or_writes(value)
852        }),
853        "uname" => tokens
854            .iter()
855            .skip(1)
856            .all(|value| !option_executes_or_writes(value)),
857        "sort" => tokens.iter().skip(1).all(|value| {
858            !matches!(
859                *value,
860                "-o" | "-T" | "--output" | "--temporary-directory" | "--compress-program"
861            ) && !short_option_contains(value, 'o')
862                && !short_option_contains(value, 'T')
863                && !value.starts_with("--output=")
864                && !value.starts_with("--temporary-directory=")
865                && !value.starts_with("--compress-program=")
866                && !option_executes_or_writes(value)
867        }),
868        // uniq writes when a second positional operand is present. Conservatively
869        // allow only options and at most one positional input.
870        "uniq" => positional_argument_count(&tokens[1..]) <= 1,
871        // Keep only plain formatting output in the silent subset. Shell
872        // builtins can still carry surprising option semantics, so options ask.
873        "printf" | "echo" => tokens.iter().skip(1).all(|value| !value.starts_with('-')),
874        "find" => {
875            !tokens
876                .iter()
877                .skip(1)
878                .any(|value| matches!(*value, "-L" | "-H"))
879                && ![
880                    " -delete",
881                    " -exec",
882                    " -execdir",
883                    " -ok",
884                    " -okdir",
885                    " -fprint",
886                    " -fprint0",
887                    " -fprintf",
888                    " -fls",
889                    " -follow",
890                    " -lname",
891                ]
892                .iter()
893                .any(|action| lower.contains(action))
894        }
895        // Sed scripts can write files (`w`) or execute commands (`e`) without
896        // an option-level signal. Keep them behind HITL until a real parser can
897        // prove the script is read-only.
898        "sed" => false,
899        "git" => is_read_only_git_segment(&tokens),
900        _ => false,
901    }
902}
903
904fn short_option_contains(value: &str, flag: char) -> bool {
905    value.starts_with('-')
906        && !value.starts_with("--")
907        && value.chars().skip(1).any(|candidate| candidate == flag)
908}
909
910fn option_executes_or_writes(value: &str) -> bool {
911    matches!(
912        value,
913        "--output" | "--exec" | "--command" | "--config" | "--files-from"
914    ) || value.starts_with("--output=")
915        || value.starts_with("--exec=")
916        || value.starts_with("--command=")
917        || value.starts_with("--config=")
918        || value.starts_with("--files-from=")
919}
920
921fn positional_argument_count(tokens: &[&str]) -> usize {
922    tokens
923        .iter()
924        .filter(|value| !value.starts_with('-'))
925        .count()
926}
927
928fn is_read_only_git_segment(tokens: &[&str]) -> bool {
929    if tokens.first().copied() != Some("git") {
930        return false;
931    }
932    let mut index = 1;
933    while index < tokens.len() {
934        match tokens[index] {
935            "--no-pager" | "-P" | "--no-optional-locks" => index += 1,
936            // `-C` changes the filesystem boundary and is therefore never in
937            // the silent allow-list. Other global config/execution options are
938            // likewise left to confirmation.
939            value if value.starts_with('-') => return false,
940            _ => break,
941        }
942    }
943
944    let Some(subcommand) = tokens.get(index).copied() else {
945        return false;
946    };
947    let args = &tokens[index + 1..];
948    if args.iter().any(|value| {
949        matches!(
950            *value,
951            "--ext-diff" | "--textconv" | "--exec-path" | "--config-env"
952        ) || value.starts_with("--exec-path=")
953            || value.starts_with("--config-env=")
954    }) {
955        return false;
956    }
957
958    match subcommand {
959        "status" | "diff" | "log" | "show" | "blame" | "grep" | "ls-files" | "rev-parse" => {
960            !args.iter().any(|value| {
961                option_executes_or_writes(value)
962                    || matches!(*value, "--paginate" | "-p" | "--ext-diff" | "--textconv")
963                    || value.starts_with("--format=") && value.contains("%(rest)")
964            })
965        }
966        "remote" => match args.first() {
967            Some(value) => matches!(*value, "-v" | "show"),
968            None => true,
969        },
970        "branch" => args.iter().all(|value| {
971            matches!(
972                *value,
973                "--all" | "-a" | "--list" | "--show-current" | "--verbose" | "-v" | "-vv"
974            )
975        }),
976        _ => false,
977    }
978}
979
980fn normalize_shell(command: &str) -> String {
981    command.split_whitespace().collect::<Vec<_>>().join(" ")
982}
983
984#[cfg(test)]
985mod coverage_tests {
986    use super::*;
987    use crate::permissions::PermissionDecision;
988
989    #[test]
990    fn stricter_permission_and_bash_segment_helpers_cover_near_miss_branches() {
991        assert_eq!(
992            stricter_permission(PermissionDecision::Deny, PermissionDecision::Ask),
993            PermissionDecision::Deny
994        );
995        assert_eq!(
996            stricter_permission(PermissionDecision::Ask, PermissionDecision::Allow),
997            PermissionDecision::Ask
998        );
999
1000        assert!(!is_read_only_bash_segment(""));
1001        assert!(!is_read_only_bash_segment("ls -R"));
1002        assert!(!is_read_only_bash_segment("ls --recursive"));
1003        assert!(!is_read_only_bash_segment("ls --dereference"));
1004        assert!(!is_read_only_bash_segment("grep --exclude-from=x pattern"));
1005        assert!(!is_read_only_bash_segment("grep --file=x pattern"));
1006        assert!(!is_read_only_bash_segment("grep --recursive pattern"));
1007        assert!(!is_read_only_bash_segment("du -L ."));
1008        assert!(!is_read_only_bash_segment("du --dereference ."));
1009        assert!(is_read_only_bash_segment("df -h"));
1010        assert!(is_read_only_bash_segment("uname -a"));
1011        assert!(!is_read_only_bash_segment("cat --output=x"));
1012        assert!(option_executes_or_writes("--files-from"));
1013        assert!(option_executes_or_writes("--output=/tmp/x"));
1014
1015        assert!(!is_read_only_git_segment(&["status"]));
1016        assert!(!is_read_only_git_segment(&["git"]));
1017        assert!(is_read_only_git_segment(&["git", "--no-pager", "status"]));
1018        assert!(!is_read_only_git_segment(&["git", "-C", "/tmp", "status"]));
1019        assert!(!is_read_only_git_segment(&["git", "diff", "--ext-diff"]));
1020        assert!(is_read_only_git_segment(&["git", "remote", "-v"]));
1021        assert!(is_read_only_git_segment(&["git", "remote"]));
1022        assert!(is_read_only_git_segment(&[
1023            "git",
1024            "branch",
1025            "--show-current"
1026        ]));
1027        assert!(!is_read_only_git_segment(&[
1028            "git", "branch", "--delete", "x"
1029        ]));
1030    }
1031
1032    #[test]
1033    fn normalize_abs_lexical_handles_dot_dot_and_curdir() {
1034        let rooted = normalize_abs_lexical(std::path::Path::new("/tmp/a/../b/./c")).unwrap();
1035        assert!(rooted.ends_with("b/c") || rooted.ends_with("b\\c"));
1036
1037        assert!(normalize_abs_lexical(std::path::Path::new("/../")).is_err());
1038        assert!(!path_is_outside_workspace(
1039            "",
1040            Some(std::path::Path::new("/tmp"))
1041        ));
1042        assert!(path_is_outside_workspace(
1043            "/etc/passwd",
1044            Some(std::path::Path::new("/tmp"))
1045        ));
1046        assert!(!path_is_outside_workspace(
1047            "relative/path",
1048            Some(std::path::Path::new("/tmp"))
1049        ));
1050        assert!(relative_path_escapes("../escape"));
1051        assert!(!relative_path_escapes("safe/path"));
1052    }
1053
1054    #[test]
1055    fn invocation_crosses_local_symlink_skips_malformed_batch_entries() {
1056        let root = tempfile::tempdir().unwrap();
1057        assert!(!invocation_crosses_local_symlink(
1058            root.path(),
1059            "batch",
1060            &serde_json::json!({
1061                "invocations": [
1062                    {"args": {"file_path": "x"}},
1063                    {"tool": "read"},
1064                    {"tool": "unknown_tool", "args": {"file_path": "x"}}
1065                ]
1066            })
1067        ));
1068        assert!(!invocation_crosses_local_symlink(
1069            root.path(),
1070            "unknown",
1071            &serde_json::json!({})
1072        ));
1073    }
1074}