Skip to main content

vtcode_core/tools/source_inspection/
shell.rs

1use super::*;
2use crate::tools::tool_intent::{ShellActivity, classify_shell_activity};
3
4fn exemption_key(args: &Value, cwd: &Path) -> String {
5    let mut invocation = crate::tools::command_args::normalize_shell_args(args).unwrap_or_else(|_| args.clone());
6    // Approval/display metadata may be added by the normal dispatch pipeline;
7    // every execution-bearing field, including sandbox permissions, stays bound.
8    if let Some(object) = invocation.as_object_mut() {
9        for field in ["action", "confirm", "description", "max_tokens", "max_output_tokens"] {
10            object.remove(field);
11        }
12    }
13    revision(&format!("{}:{invocation}", cwd.display()))
14}
15
16/// An exact, single-use exemption owned by a trusted user-command dispatch.
17/// Dropping it also removes an unconsumed exemption after admission failure.
18pub struct TrustedCommandExemption {
19    inspection: SourceInspection,
20    key: String,
21}
22
23impl Drop for TrustedCommandExemption {
24    fn drop(&mut self) {
25        self.inspection.0.lock().exemptions.remove(&self.key);
26    }
27}
28
29impl SourceInspection {
30    /// Only trusted user-command entrypoints may call this. Tool approval and
31    /// model-supplied flags must never create an inspection exemption.
32    pub fn exempt_user_command(&self, args: &Value, cwd: &Path) {
33        let mut ledger = self.0.lock();
34        if ledger.exemptions.len() >= MAX_SUPPORTS {
35            ledger.exemptions.clear();
36        }
37        ledger.exemptions.insert(exemption_key(args, cwd));
38    }
39
40    pub(crate) fn scoped_user_command(&self, args: &Value, cwd: &Path) -> TrustedCommandExemption {
41        self.exempt_user_command(args, cwd);
42        TrustedCommandExemption {
43            inspection: self.clone(),
44            key: exemption_key(args, cwd),
45        }
46    }
47
48    pub(crate) fn check_shell(&self, args: &Value, cwd: &Path) -> Result<()> {
49        if self.mode() == InspectionMode::Off {
50            return Ok(());
51        }
52        if self.0.lock().exemptions.remove(&exemption_key(args, cwd)) {
53            return Ok(());
54        }
55        let mut environment_words = Vec::new();
56        match args.get("env") {
57            Some(Value::Object(values)) => {
58                environment_words.extend(values.iter().map(|(key, value)| format!("{key}={value}")))
59            }
60            Some(Value::Array(values)) => environment_words.extend(
61                values
62                    .iter()
63                    .filter_map(|value| Some(format!("{}={}", value.get("name")?.as_str()?, value.get("value")?))),
64            ),
65            _ => {}
66        }
67        environment_words.push("true".to_string());
68        let environment_injection = crate::tools::command_args::has_unsafe_readonly_options(&environment_words);
69        let unknown_shell = args.get("shell").and_then(Value::as_str).is_some_and(|shell| {
70            !matches!(
71                shell,
72                "/bin/bash"
73                    | "/bin/zsh"
74                    | "/bin/sh"
75                    | "/usr/bin/bash"
76                    | "/usr/bin/zsh"
77                    | "/usr/bin/sh"
78                    | "/usr/bin/fish"
79                    | "/opt/homebrew/bin/fish"
80                    | "bash"
81                    | "zsh"
82                    | "sh"
83                    | "pwsh"
84                    | "powershell"
85                    | "cmd"
86            )
87        });
88        let static_commands = crate::tools::command_args::shell_command_text_with_args(args)
89            .and_then(|command| crate::tools::tool_intent::readonly::static_shell_command_words(&command));
90        let safe_command_shape = static_commands.is_some_and(|commands| {
91            commands.iter().all(|words| {
92                !crate::tools::command_args::has_unsafe_readonly_options(words)
93                    && !crate::command_safety::command_requires_approval(words)
94            })
95        });
96        let rewriting = crate::tools::command_args::command_words(args)
97            .ok()
98            .flatten()
99            .is_some_and(|words| {
100                words.iter().any(|word| {
101                    matches!(word.as_str(), "--fix" | "--write" | "--apply" | "--bless" | "--update-snapshots")
102                        || word.starts_with("--fix=")
103                        || word.starts_with("--write=")
104                })
105            });
106        if safe_command_shape
107            && !environment_injection
108            && !unknown_shell
109            && !rewriting
110            && classify_shell_activity(crate::config::constants::tools::EXEC_COMMAND, args) != ShellActivity::Mutation
111        {
112            return Ok(());
113        }
114        let details = json!({"failure_kind":"source_inspection_shell_write", "message":
115            "Route authored file changes through apply_patch or guarded file tools. Unclassified potential shell writes require a trusted user exemption for the complete invocation and working directory; ordinary shell approval is insufficient."});
116        if self.mode() == InspectionMode::Warn {
117            tracing::warn!(%details, "Source inspection warning");
118            return Ok(());
119        }
120        Err(InspectionRequired { details }.into())
121    }
122}