vtcode_core/tools/handlers/
shell_handler.rs1use hashbrown::HashMap;
7use std::path::Path;
8use std::time::Duration;
9
10use async_trait::async_trait;
11use serde::Deserialize;
12use serde_json::json;
13use vtcode_utility_tool_specs::{DEFAULT_MAX_OUTPUT_TOKENS, MAX_MAX_OUTPUT_TOKENS, MIN_MAX_OUTPUT_TOKENS};
14
15use super::sandboxing::{Sandboxable, SandboxablePreference};
16use super::tool_handler::{
17 ShellToolCallParams, ToolCallError, ToolHandler, ToolInvocation, ToolKind, ToolOutput, ToolPayload,
18};
19use crate::config::constants::tools;
20use crate::tools::output_limits::OUTPUT_PREVIEW_CHARS_PER_TOKEN;
21use crate::tools::shell::{ShellOutput as CoreShellOutput, ShellRunner};
22use crate::tools::validation::commands;
23
24const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000;
26
27const MAX_SHELL_TIMEOUT_MS: u64 = 300_000;
29
30struct ResolvedShellCall {
33 params: ShellToolCallParams,
34 max_output_tokens: usize,
35}
36
37pub struct ShellHandler {
39 pub default_shell: String,
41 pub inherit_env: bool,
43}
44
45impl Default for ShellHandler {
46 fn default() -> Self {
47 Self {
48 default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
49 inherit_env: true,
50 }
51 }
52}
53
54impl ShellHandler {
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn with_shell(shell: impl Into<String>) -> Self {
60 Self { default_shell: shell.into(), inherit_env: true }
61 }
62
63 fn parse_params(&self, invocation: &ToolInvocation) -> Result<ResolvedShellCall, ToolCallError> {
65 match &invocation.payload {
66 ToolPayload::Function { arguments } => {
67 #[derive(Deserialize)]
69 struct SimpleShellArgs {
70 command: String,
71 workdir: Option<String>,
72 timeout_ms: Option<u64>,
73 max_output_tokens: Option<u64>,
74 }
75 let simple: SimpleShellArgs = serde_json::from_str(arguments)
76 .map_err(|e| ToolCallError::respond(format!("Invalid shell arguments: {e}")))?;
77 let max_output_tokens = resolve_max_output_tokens(simple.max_output_tokens)?;
78 Ok(ResolvedShellCall {
79 params: ShellToolCallParams {
80 command: vec![simple.command],
81 workdir: simple.workdir,
82 timeout_ms: simple.timeout_ms,
83 sandbox_permissions: None,
84 justification: None,
85 },
86 max_output_tokens,
87 })
88 }
89 ToolPayload::LocalShell { params } => Ok(ResolvedShellCall {
90 params: params.clone(),
91 max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS,
92 }),
93 _ => Err(ToolCallError::respond("Invalid payload type for shell handler")),
94 }
95 }
96
97 async fn execute_command(
99 &self,
100 params: &ShellToolCallParams,
101 cwd: &Path,
102 env: Option<HashMap<String, String>>,
103 ) -> Result<CoreShellOutput, ToolCallError> {
104 let timeout_ms = params.timeout_ms.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS).min(MAX_SHELL_TIMEOUT_MS);
105 let result = if let Some((program, args)) = params.command.split_first().filter(|_| params.command.len() > 1) {
106 commands::validate_command_argv(¶ms.command).map_err(ToolCallError::Internal)?;
107 let canonical_cwd = vtcode_commons::paths::canonicalize_async(cwd)
108 .await
109 .map_err(|error| ToolCallError::Internal(error.into()))?;
110 let directory = tokio::task::spawn_blocking({
111 let canonical_cwd = canonical_cwd.clone();
112 move || vtcode_commons::fs::bound_file::open_directory_handle(&canonical_cwd)
113 })
114 .await
115 .map_err(|error| ToolCallError::Internal(error.into()))?
116 .map_err(|error| ToolCallError::Internal(error.into()))?;
117 let mut command = tokio::process::Command::new(program);
118 command.args(args).current_dir(&canonical_cwd);
119 vtcode_commons::fs::bound_file::set_command_working_directory(command.as_std_mut(), &directory)
120 .map_err(|error| ToolCallError::Internal(error.into()))?;
121 let current_env = env.unwrap_or_else(|| std::env::vars().collect());
122 let sanitized_env = crate::sandboxing::build_sanitized_env(
123 ¤t_env,
124 true,
125 false,
126 "shell-handler",
127 &[canonical_cwd.as_path()],
128 );
129 command.env_clear().envs(sanitized_env);
130 let output = tokio::time::timeout(Duration::from_millis(timeout_ms), command.output())
131 .await
132 .map_err(|_error| ToolCallError::Timeout(timeout_ms))?
133 .map_err(|error| ToolCallError::Internal(error.into()))?;
134 CoreShellOutput {
135 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
136 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
137 exit_code: output.status.code().unwrap_or(-1),
138 }
139 } else {
140 let command = params.command.first().map(String::as_str).unwrap_or_default();
141 commands::validate_shell_script(command).map_err(ToolCallError::Internal)?;
142 let runner = ShellRunner::new(cwd.to_path_buf());
143 tokio::time::timeout(Duration::from_millis(timeout_ms), runner.exec(command))
144 .await
145 .map_err(|_error| ToolCallError::Timeout(timeout_ms))?
146 .map_err(ToolCallError::Internal)?
147 };
148
149 Ok(result)
150 }
151}
152
153impl Sandboxable for ShellHandler {
154 fn sandbox_preference(&self) -> SandboxablePreference {
155 SandboxablePreference::Require
156 }
157
158 fn escalate_on_failure(&self) -> bool {
159 true }
161}
162
163#[async_trait]
164impl ToolHandler for ShellHandler {
165 fn kind(&self) -> ToolKind {
166 ToolKind::Function
167 }
168
169 fn matches_kind(&self, payload: &ToolPayload) -> bool {
170 matches!(payload, ToolPayload::Function { .. } | ToolPayload::LocalShell { .. })
171 }
172
173 async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
174 true
176 }
177
178 async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
179 let resolved = self.parse_params(&invocation)?;
180 let output = self.execute_command(&resolved.params, &invocation.turn.cwd, None).await?;
181
182 let sanitized = output.sanitize_secrets();
184 let content_text = format_shell_output(&sanitized, resolved.max_output_tokens);
185
186 Ok(ToolOutput::with_success(content_text, sanitized.exit_code == 0))
187 }
188}
189
190fn resolve_max_output_tokens(requested: Option<u64>) -> Result<usize, ToolCallError> {
192 let Some(tokens) = requested else {
193 return Ok(DEFAULT_MAX_OUTPUT_TOKENS);
194 };
195 let Ok(tokens) = usize::try_from(tokens) else {
198 return Err(ToolCallError::respond(format!(
199 "max_output_tokens must be an integer between {MIN_MAX_OUTPUT_TOKENS} and {MAX_MAX_OUTPUT_TOKENS}"
200 )));
201 };
202 if !(MIN_MAX_OUTPUT_TOKENS..=MAX_MAX_OUTPUT_TOKENS).contains(&tokens) {
203 return Err(ToolCallError::respond(format!(
204 "max_output_tokens must be an integer between {MIN_MAX_OUTPUT_TOKENS} and {MAX_MAX_OUTPUT_TOKENS}"
205 )));
206 }
207 Ok(tokens)
208}
209
210fn format_shell_output(sanitized: &CoreShellOutput, max_output_tokens: usize) -> String {
213 use std::fmt::Write as _;
214
215 let mut content_text = String::with_capacity(sanitized.stdout.len() + sanitized.stderr.len() + 32);
216 if !sanitized.stdout.is_empty() {
217 content_text.push_str(&sanitized.stdout);
218 }
219 if !sanitized.stderr.is_empty() {
220 if !content_text.is_empty() {
221 content_text.push('\n');
222 }
223 content_text.push_str("[stderr]\n");
224 content_text.push_str(&sanitized.stderr);
225 }
226 if sanitized.exit_code != 0 {
227 if !content_text.is_empty() {
228 content_text.push('\n');
229 }
230 let _ = write!(content_text, "[exit code: {}]", sanitized.exit_code);
231 }
232
233 if content_text.is_empty() {
234 return "(no output)".to_string();
235 }
236
237 let budget_bytes = max_output_tokens.saturating_mul(OUTPUT_PREVIEW_CHARS_PER_TOKEN).max(1);
238 if content_text.len() <= budget_bytes {
239 return content_text;
240 }
241
242 let head = budget_bytes / 2;
243 let tail = budget_bytes.saturating_sub(head);
244 vtcode_commons::preview::condense_text_bytes(&content_text, head, tail)
245}
246
247pub fn create_shell_tool() -> super::tool_handler::ToolSpec {
249 use super::tool_handler::{ResponsesApiTool, ToolSpec};
250
251 let parameters = vtcode_utility_tool_specs::with_max_output_tokens_parameter(json!({
252 "type": "object",
253 "properties": {
254 "command": {
255 "type": "string",
256 "description": "The shell command to execute"
257 },
258 "workdir": {
259 "type": "string",
260 "description": "Working directory for the command (optional)"
261 },
262 "timeout_ms": {
263 "type": "number",
264 "description": "Timeout in milliseconds (default: 30000, max: 300000)"
265 }
266 },
267 "required": ["command"],
268 "additionalProperties": false
269 }));
270
271 ToolSpec::Function(ResponsesApiTool {
272 name: tools::SHELL.to_string(),
273 description: "Execute a shell command and return its output. Default timeout 30s (max 300s). All commands run through the active sandbox policy. Do NOT use for interactive/long-running processes (e.g., dev servers, watchers) — use spawn_background_subprocess instead.".to_string(),
274 parameters,
275 strict: false,
276 })
277}
278
279#[cfg(test)]
280mod tests {
281 use super::super::tool_handler::ToolSpec;
282 use super::*;
283
284 #[test]
285 fn shell_handler_keeps_sandbox_retry_enabled() {
286 assert!(ShellHandler::new().escalate_on_failure());
287 }
288
289 #[tokio::test]
290 async fn test_shell_handler_echo() {
291 let handler = ShellHandler::new();
292
293 assert_eq!(handler.kind(), ToolKind::Function);
295 }
296
297 #[test]
298 fn test_shell_handler_matches_kind() {
299 let handler = ShellHandler::new();
300
301 assert!(handler.matches_kind(&ToolPayload::Function { arguments: "{}".to_string() }));
302
303 assert!(handler.matches_kind(&ToolPayload::LocalShell {
304 params: ShellToolCallParams {
305 command: vec!["echo".to_string(), "hello".to_string()],
306 workdir: None,
307 timeout_ms: None,
308 sandbox_permissions: None,
309 justification: None,
310 }
311 }));
312 }
313
314 #[tokio::test]
315 async fn test_shell_handler_is_mutating() {
316 }
318
319 #[test]
320 fn test_create_shell_tool_spec() {
321 let spec = create_shell_tool();
322
323 assert_eq!(spec.name(), "shell");
324 }
325
326 #[test]
327 fn shell_tool_schema_advertises_max_output_tokens() {
328 let spec = create_shell_tool();
329 let ToolSpec::Function(tool) = spec else {
330 panic!("shell tool must be a function spec");
331 };
332 assert!(
333 tool.parameters["properties"]
334 .get("max_output_tokens")
335 .is_some_and(|field| field["default"] == json!(DEFAULT_MAX_OUTPUT_TOKENS)),
336 "shell schema must advertise max_output_tokens with the shared default"
337 );
338 }
339
340 #[test]
341 fn resolve_max_output_tokens_defaults_and_bounds() {
342 assert_eq!(resolve_max_output_tokens(None).unwrap(), DEFAULT_MAX_OUTPUT_TOKENS);
343 assert_eq!(resolve_max_output_tokens(Some(1)).unwrap(), 1);
344 assert_eq!(resolve_max_output_tokens(Some(50_000)).unwrap(), 50_000);
345 assert!(resolve_max_output_tokens(Some(0)).is_err());
346 assert!(resolve_max_output_tokens(Some(50_001)).is_err());
347 }
348
349 #[test]
350 fn format_shell_output_leaves_small_output_unchanged() {
351 let output = CoreShellOutput {
352 stdout: "hello\n".to_string(),
353 stderr: String::new(),
354 exit_code: 0,
355 };
356 assert_eq!(format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS), "hello\n");
357 }
358
359 #[test]
360 fn format_shell_output_preserves_exit_code_on_small_failure() {
361 let output = CoreShellOutput {
362 stdout: "partial\n".to_string(),
363 stderr: "boom\n".to_string(),
364 exit_code: 2,
365 };
366 let formatted = format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS);
367 assert!(formatted.contains("[stderr]\n"));
368 assert!(formatted.contains("[exit code: 2]"));
369 }
370
371 #[test]
372 fn format_shell_output_condenses_oversized_content() {
373 let stdout = "x".repeat(10_000);
374 let output = CoreShellOutput {
375 stdout: stdout.clone(),
376 stderr: String::new(),
377 exit_code: 0,
378 };
379 let formatted = format_shell_output(&output, 10);
381 assert!(formatted.len() < stdout.len());
382 assert!(formatted.contains("bytes omitted"));
383 assert!(formatted.starts_with("xxxx"));
384 assert!(formatted.ends_with("xxxx"));
385 }
386
387 #[test]
388 fn format_shell_output_empty_becomes_no_output_marker() {
389 let output = CoreShellOutput {
390 stdout: String::new(),
391 stderr: String::new(),
392 exit_code: 0,
393 };
394 assert_eq!(format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS), "(no output)");
395 }
396}