Skip to main content

vtcode_core/tools/handlers/
shell_handler.rs

1//! Shell command handler (from Codex pattern).
2//!
3//! Executes shell commands with sandbox support, timeout handling,
4//! and environment policy management.
5
6use 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
24/// Default timeout for shell commands (30 seconds).
25const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000;
26
27/// Maximum timeout allowed (5 minutes).
28const MAX_SHELL_TIMEOUT_MS: u64 = 300_000;
29
30/// Resolved shell invocation: shared command params plus the model-visible
31/// preview budget for this call.
32struct ResolvedShellCall {
33    params: ShellToolCallParams,
34    max_output_tokens: usize,
35}
36
37/// Handler for shell command execution.
38pub struct ShellHandler {
39    /// Default shell to use.
40    pub default_shell: String,
41    /// Environment variables to inherit.
42    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    /// Parse shell parameters from payload.
64    fn parse_params(&self, invocation: &ToolInvocation) -> Result<ResolvedShellCall, ToolCallError> {
65        match &invocation.payload {
66            ToolPayload::Function { arguments } => {
67                // Parse as simple shell command string and wrap in ShellToolCallParams
68                #[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    /// Execute a shell command.
98    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(&params.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                &current_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 // Shell commands may need escalation
160    }
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        // Shell commands are considered mutating by default
175        true
176    }
177
178    async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
179        let resolved = self.parse_params(&invocation)?;
180        let fallback = crate::tools::source_inspection::SourceInspection::default();
181        let requested_cwd = invocation.turn.resolve_path_ref(resolved.params.workdir.as_deref());
182        let cwd = vtcode_commons::paths::ensure_path_within_workspace_resolved(
183            &requested_cwd,
184            invocation.session.workspace_root(),
185        )
186        .await
187        .map_err(|error| ToolCallError::respond(error.to_string()))?;
188        let command = match resolved.params.command.as_slice() {
189            [script] => serde_json::Value::String(script.clone()),
190            argv => json!(argv),
191        };
192        let args = json!({"command": command});
193        invocation
194            .session
195            .source_inspection()
196            .unwrap_or(&fallback)
197            .check_shell(&args, &cwd)
198            .map_err(|error| ToolCallError::respond(error.to_string()))?;
199        let output = self.execute_command(&resolved.params, &cwd, None).await?;
200
201        // Sanitize output to remove any secrets before display/storage
202        let sanitized = output.sanitize_secrets();
203        let content_text = format_shell_output(&sanitized, resolved.max_output_tokens);
204
205        Ok(ToolOutput::with_success(content_text, sanitized.exit_code == 0))
206    }
207}
208
209/// Validate and resolve the caller-requested model-visible preview budget.
210fn resolve_max_output_tokens(requested: Option<u64>) -> Result<usize, ToolCallError> {
211    let Some(tokens) = requested else {
212        return Ok(DEFAULT_MAX_OUTPUT_TOKENS);
213    };
214    // u64 -> usize fails only on 32-bit targets where the value exceeds usize::MAX;
215    // the MAX_MAX_OUTPUT_TOKENS bound below is the real range check.
216    let Ok(tokens) = usize::try_from(tokens) else {
217        return Err(ToolCallError::respond(format!(
218            "max_output_tokens must be an integer between {MIN_MAX_OUTPUT_TOKENS} and {MAX_MAX_OUTPUT_TOKENS}"
219        )));
220    };
221    if !(MIN_MAX_OUTPUT_TOKENS..=MAX_MAX_OUTPUT_TOKENS).contains(&tokens) {
222        return Err(ToolCallError::respond(format!(
223            "max_output_tokens must be an integer between {MIN_MAX_OUTPUT_TOKENS} and {MAX_MAX_OUTPUT_TOKENS}"
224        )));
225    }
226    Ok(tokens)
227}
228
229/// Format sanitized shell output for the model, condensing oversized previews
230/// to a head/tail excerpt within the token budget.
231fn format_shell_output(sanitized: &CoreShellOutput, max_output_tokens: usize) -> String {
232    use std::fmt::Write as _;
233
234    let mut content_text = String::with_capacity(sanitized.stdout.len() + sanitized.stderr.len() + 32);
235    if !sanitized.stdout.is_empty() {
236        content_text.push_str(&sanitized.stdout);
237    }
238    if !sanitized.stderr.is_empty() {
239        if !content_text.is_empty() {
240            content_text.push('\n');
241        }
242        content_text.push_str("[stderr]\n");
243        content_text.push_str(&sanitized.stderr);
244    }
245    if sanitized.exit_code != 0 {
246        if !content_text.is_empty() {
247            content_text.push('\n');
248        }
249        let _ = write!(content_text, "[exit code: {}]", sanitized.exit_code);
250    }
251
252    if content_text.is_empty() {
253        return "(no output)".to_string();
254    }
255
256    let budget_bytes = max_output_tokens.saturating_mul(OUTPUT_PREVIEW_CHARS_PER_TOKEN).max(1);
257    if content_text.len() <= budget_bytes {
258        return content_text;
259    }
260
261    let head = budget_bytes / 2;
262    let tail = budget_bytes.saturating_sub(head);
263    vtcode_commons::preview::condense_text_bytes(&content_text, head, tail)
264}
265
266/// Create the shell tool specification.
267pub fn create_shell_tool() -> super::tool_handler::ToolSpec {
268    use super::tool_handler::{ResponsesApiTool, ToolSpec};
269
270    let parameters = vtcode_utility_tool_specs::with_max_output_tokens_parameter(json!({
271        "type": "object",
272        "properties": {
273            "command": {
274                "type": "string",
275                "description": "The shell command to execute"
276            },
277            "workdir": {
278                "type": "string",
279                "description": "Working directory for the command (optional)"
280            },
281            "timeout_ms": {
282                "type": "number",
283                "description": "Timeout in milliseconds (default: 30000, max: 300000)"
284            }
285        },
286        "required": ["command"],
287        "additionalProperties": false
288    }));
289
290    ToolSpec::Function(ResponsesApiTool {
291        name: tools::SHELL.to_string(),
292        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(),
293        parameters,
294        strict: false,
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::super::tool_handler::ToolSpec;
301    use super::*;
302
303    #[test]
304    fn shell_handler_keeps_sandbox_retry_enabled() {
305        assert!(ShellHandler::new().escalate_on_failure());
306    }
307
308    #[tokio::test]
309    async fn test_shell_handler_echo() {
310        let handler = ShellHandler::new();
311
312        // Test that handler kind is correct
313        assert_eq!(handler.kind(), ToolKind::Function);
314    }
315
316    #[tokio::test]
317    async fn direct_shell_handler_allows_reads_in_the_bound_cwd_and_rejects_authored_writes() {
318        use super::super::adapter::DefaultToolSession;
319        use super::super::tool_handler::{Constrained, ShellEnvironmentPolicy, ToolSession, TurnContext};
320        use std::sync::Arc;
321        let root = tempfile::tempdir().unwrap();
322        std::fs::create_dir(root.path().join("nested")).unwrap();
323        std::fs::write(root.path().join("sample.txt"), "root-only\n").unwrap();
324        std::fs::write(root.path().join("nested/sample.txt"), "nested-only\n").unwrap();
325        let session = Arc::new(DefaultToolSession::new(root.path().to_path_buf()));
326        session
327            .source_inspection()
328            .unwrap()
329            .set_mode(vtcode_config::core::agent::InspectionMode::Enforce);
330        let turn = Arc::new(TurnContext {
331            cwd: root.path().to_path_buf(),
332            turn_id: "direct".into(),
333            sub_id: None,
334            shell_environment_policy: ShellEnvironmentPolicy::default(),
335            approval_policy: Constrained::default(),
336            linux_sandbox_launcher: None,
337            sandbox_policy: Constrained::default(),
338        });
339        for (command, succeeds) in [("cat sample.txt", true), ("printf bad > sample.txt", false)] {
340            let invocation = ToolInvocation {
341                session: session.clone(),
342                turn: turn.clone(),
343                tracker: None,
344                call_id: command.into(),
345                tool_name: tools::SHELL.into(),
346                payload: ToolPayload::Function {
347                    arguments: json!({"command":command,"workdir":"nested"}).to_string(),
348                },
349            };
350            let result = ShellHandler::new().handle(invocation).await;
351            if succeeds {
352                let result = result.unwrap();
353                assert!(result.content().unwrap().contains("nested-only"));
354                assert!(!result.content().unwrap().contains("root-only"));
355            } else {
356                assert!(result.unwrap_err().to_string().contains("source_inspection_shell_write"));
357            }
358        }
359        assert_eq!(std::fs::read_to_string(root.path().join("nested/sample.txt")).unwrap(), "nested-only\n");
360    }
361
362    #[test]
363    fn test_shell_handler_matches_kind() {
364        let handler = ShellHandler::new();
365
366        assert!(handler.matches_kind(&ToolPayload::Function { arguments: "{}".to_string() }));
367
368        assert!(handler.matches_kind(&ToolPayload::LocalShell {
369            params: ShellToolCallParams {
370                command: vec!["echo".to_string(), "hello".to_string()],
371                workdir: None,
372                timeout_ms: None,
373                sandbox_permissions: None,
374                justification: None,
375            }
376        }));
377    }
378
379    #[tokio::test]
380    async fn test_shell_handler_is_mutating() {
381        // Shell commands are always mutating
382    }
383
384    #[test]
385    fn test_create_shell_tool_spec() {
386        let spec = create_shell_tool();
387
388        assert_eq!(spec.name(), "shell");
389    }
390
391    #[test]
392    fn shell_tool_schema_advertises_max_output_tokens() {
393        let spec = create_shell_tool();
394        let ToolSpec::Function(tool) = spec else {
395            panic!("shell tool must be a function spec");
396        };
397        assert!(
398            tool.parameters["properties"]
399                .get("max_output_tokens")
400                .is_some_and(|field| field["default"] == json!(DEFAULT_MAX_OUTPUT_TOKENS)),
401            "shell schema must advertise max_output_tokens with the shared default"
402        );
403    }
404
405    #[test]
406    fn resolve_max_output_tokens_defaults_and_bounds() {
407        assert_eq!(resolve_max_output_tokens(None).unwrap(), DEFAULT_MAX_OUTPUT_TOKENS);
408        assert_eq!(resolve_max_output_tokens(Some(1)).unwrap(), 1);
409        assert_eq!(resolve_max_output_tokens(Some(50_000)).unwrap(), 50_000);
410        assert!(resolve_max_output_tokens(Some(0)).is_err());
411        assert!(resolve_max_output_tokens(Some(50_001)).is_err());
412    }
413
414    #[test]
415    fn format_shell_output_leaves_small_output_unchanged() {
416        let output = CoreShellOutput {
417            stdout: "hello\n".to_string(),
418            stderr: String::new(),
419            exit_code: 0,
420        };
421        assert_eq!(format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS), "hello\n");
422    }
423
424    #[test]
425    fn format_shell_output_preserves_exit_code_on_small_failure() {
426        let output = CoreShellOutput {
427            stdout: "partial\n".to_string(),
428            stderr: "boom\n".to_string(),
429            exit_code: 2,
430        };
431        let formatted = format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS);
432        assert!(formatted.contains("[stderr]\n"));
433        assert!(formatted.contains("[exit code: 2]"));
434    }
435
436    #[test]
437    fn format_shell_output_condenses_oversized_content() {
438        let stdout = "x".repeat(10_000);
439        let output = CoreShellOutput {
440            stdout: stdout.clone(),
441            stderr: String::new(),
442            exit_code: 0,
443        };
444        // 10 tokens * 4 chars/token = 40 byte budget.
445        let formatted = format_shell_output(&output, 10);
446        assert!(formatted.len() < stdout.len());
447        assert!(formatted.contains("bytes omitted"));
448        assert!(formatted.starts_with("xxxx"));
449        assert!(formatted.ends_with("xxxx"));
450    }
451
452    #[test]
453    fn format_shell_output_empty_becomes_no_output_marker() {
454        let output = CoreShellOutput {
455            stdout: String::new(),
456            stderr: String::new(),
457            exit_code: 0,
458        };
459        assert_eq!(format_shell_output(&output, DEFAULT_MAX_OUTPUT_TOKENS), "(no output)");
460    }
461}