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;
13
14use super::sandboxing::{Sandboxable, SandboxablePreference};
15use super::tool_handler::{
16    ShellToolCallParams, ToolCallError, ToolHandler, ToolInvocation, ToolKind, ToolOutput,
17    ToolPayload,
18};
19use crate::config::constants::tools;
20use crate::tools::shell::{ShellOutput as CoreShellOutput, ShellRunner};
21
22/// Default timeout for shell commands (30 seconds).
23const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000;
24
25/// Maximum timeout allowed (5 minutes).
26const MAX_SHELL_TIMEOUT_MS: u64 = 300_000;
27
28/// Handler for shell command execution.
29pub struct ShellHandler {
30    /// Default shell to use.
31    pub default_shell: String,
32    /// Environment variables to inherit.
33    pub inherit_env: bool,
34}
35
36impl Default for ShellHandler {
37    fn default() -> Self {
38        Self {
39            default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()),
40            inherit_env: true,
41        }
42    }
43}
44
45impl ShellHandler {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    pub fn with_shell(shell: impl Into<String>) -> Self {
51        Self {
52            default_shell: shell.into(),
53            inherit_env: true,
54        }
55    }
56
57    /// Parse shell parameters from payload.
58    fn parse_params(
59        &self,
60        invocation: &ToolInvocation,
61    ) -> Result<ShellToolCallParams, ToolCallError> {
62        match &invocation.payload {
63            ToolPayload::Function { arguments } => {
64                // Parse as simple shell command string and wrap in ShellToolCallParams
65                #[derive(Deserialize)]
66                struct SimpleShellArgs {
67                    command: String,
68                    workdir: Option<String>,
69                    timeout_ms: Option<u64>,
70                }
71                let simple: SimpleShellArgs = serde_json::from_str(arguments)
72                    .map_err(|e| ToolCallError::respond(format!("Invalid shell arguments: {e}")))?;
73                Ok(ShellToolCallParams {
74                    command: vec![simple.command],
75                    workdir: simple.workdir,
76                    timeout_ms: simple.timeout_ms,
77                    sandbox_permissions: None,
78                    justification: None,
79                })
80            }
81            ToolPayload::LocalShell { params } => Ok(params.clone()),
82            _ => Err(ToolCallError::respond(
83                "Invalid payload type for shell handler",
84            )),
85        }
86    }
87
88    /// Execute a shell command.
89    async fn execute_command(
90        &self,
91        params: &ShellToolCallParams,
92        cwd: &Path,
93        _env: Option<HashMap<String, String>>,
94    ) -> Result<CoreShellOutput, ToolCallError> {
95        let runner = ShellRunner::new(cwd.to_path_buf());
96        let command = params.command.join(" ");
97
98        let timeout_ms = params
99            .timeout_ms
100            .unwrap_or(DEFAULT_SHELL_TIMEOUT_MS)
101            .min(MAX_SHELL_TIMEOUT_MS);
102
103        // Execute with timeout
104        let result = tokio::time::timeout(Duration::from_millis(timeout_ms), runner.exec(&command))
105            .await
106            .map_err(|_| ToolCallError::Timeout(timeout_ms))?
107            .map_err(ToolCallError::Internal)?;
108
109        Ok(result)
110    }
111}
112
113impl Sandboxable for ShellHandler {
114    fn sandbox_preference(&self) -> SandboxablePreference {
115        SandboxablePreference::Require
116    }
117
118    fn escalate_on_failure(&self) -> bool {
119        true // Shell commands may need escalation
120    }
121}
122
123#[async_trait]
124impl ToolHandler for ShellHandler {
125    fn kind(&self) -> ToolKind {
126        ToolKind::Function
127    }
128
129    fn matches_kind(&self, payload: &ToolPayload) -> bool {
130        matches!(
131            payload,
132            ToolPayload::Function { .. } | ToolPayload::LocalShell { .. }
133        )
134    }
135
136    async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
137        // Shell commands are considered mutating by default
138        true
139    }
140
141    async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
142        let params = self.parse_params(&invocation)?;
143        let output = self
144            .execute_command(&params, &invocation.turn.cwd, None)
145            .await?;
146
147        // Sanitize output to remove any secrets before display/storage
148        let sanitized = output.sanitize_secrets();
149
150        // Format output
151        let mut content_text = String::new();
152        if !sanitized.stdout.is_empty() {
153            content_text.push_str(&sanitized.stdout);
154        }
155        if !sanitized.stderr.is_empty() {
156            if !content_text.is_empty() {
157                content_text.push('\n');
158            }
159            content_text.push_str("[stderr]\n");
160            content_text.push_str(&sanitized.stderr);
161        }
162        if sanitized.exit_code != 0 {
163            if !content_text.is_empty() {
164                content_text.push('\n');
165            }
166            content_text.push_str(&format!("[exit code: {}]", sanitized.exit_code));
167        }
168
169        if content_text.is_empty() {
170            content_text = "(no output)".to_string();
171        }
172
173        Ok(ToolOutput::with_success(
174            content_text,
175            sanitized.exit_code == 0,
176        ))
177    }
178}
179
180/// Create the shell tool specification.
181pub fn create_shell_tool() -> super::tool_handler::ToolSpec {
182    use super::tool_handler::{ResponsesApiTool, ToolSpec};
183
184    ToolSpec::Function(ResponsesApiTool {
185        name: tools::SHELL.to_string(),
186        description: "Execute a shell command and return its output.".to_string(),
187        parameters: json!({
188            "type": "object",
189            "properties": {
190                "command": {
191                    "type": "string",
192                    "description": "The shell command to execute"
193                },
194                "workdir": {
195                    "type": "string",
196                    "description": "Working directory for the command (optional)"
197                },
198                "timeout_ms": {
199                    "type": "number",
200                    "description": "Timeout in milliseconds (default: 30000, max: 300000)"
201                }
202            },
203            "required": ["command"],
204            "additionalProperties": false
205        }),
206        strict: false,
207    })
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn shell_handler_keeps_sandbox_retry_enabled() {
216        assert!(ShellHandler::new().escalate_on_failure());
217    }
218
219    #[tokio::test]
220    async fn test_shell_handler_echo() {
221        let handler = ShellHandler::new();
222
223        // Test that handler kind is correct
224        assert_eq!(handler.kind(), ToolKind::Function);
225    }
226
227    #[test]
228    fn test_shell_handler_matches_kind() {
229        let handler = ShellHandler::new();
230
231        assert!(handler.matches_kind(&ToolPayload::Function {
232            arguments: "{}".to_string()
233        }));
234
235        assert!(handler.matches_kind(&ToolPayload::LocalShell {
236            params: ShellToolCallParams {
237                command: vec!["echo".to_string(), "hello".to_string()],
238                workdir: None,
239                timeout_ms: None,
240                sandbox_permissions: None,
241                justification: None,
242            }
243        }));
244    }
245
246    #[tokio::test]
247    async fn test_shell_handler_is_mutating() {
248        // Shell commands are always mutating
249    }
250
251    #[test]
252    fn test_create_shell_tool_spec() {
253        let spec = create_shell_tool();
254
255        assert_eq!(spec.name(), "shell");
256    }
257}