vtcode_core/tools/source_inspection/
shell.rs1use 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 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
16pub 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 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}