syncable_cli/agent/tools/
shell.rs

1//! Shell tool for executing validation commands
2//!
3//! Provides a restricted shell tool for DevOps validation commands:
4//! - Docker build validation
5//! - Terraform validate/plan
6//! - Helm lint
7//! - Kubernetes dry-run
8//!
9//! Includes interactive confirmation before execution and streaming output display.
10
11use crate::agent::ui::confirmation::{confirm_shell_command, AllowedCommands, ConfirmationResult};
12use crate::agent::ui::shell_output::StreamingShellOutput;
13use rig::completion::ToolDefinition;
14use rig::tool::Tool;
15use serde::Deserialize;
16use serde_json::json;
17use std::path::PathBuf;
18use std::sync::Arc;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tokio::process::Command;
21use tokio::sync::mpsc;
22
23/// Allowed command prefixes for security
24const ALLOWED_COMMANDS: &[&str] = &[
25    // Docker commands
26    "docker build",
27    "docker compose",
28    "docker-compose",
29    // Terraform commands
30    "terraform init",
31    "terraform validate",
32    "terraform plan",
33    "terraform fmt",
34    // Helm commands
35    "helm lint",
36    "helm template",
37    "helm dependency",
38    // Kubernetes commands (dry-run only)
39    "kubectl apply --dry-run",
40    "kubectl diff",
41    // Generic validation
42    "make",
43    "npm run",
44    "cargo build",
45    "go build",
46    "python -m py_compile",
47    // Linting
48    "hadolint",
49    "tflint",
50    "yamllint",
51    "shellcheck",
52];
53
54#[derive(Debug, Deserialize)]
55pub struct ShellArgs {
56    /// The command to execute
57    pub command: String,
58    /// Working directory (relative to project root)
59    pub working_dir: Option<String>,
60    /// Timeout in seconds (default: 60, max: 300)
61    pub timeout_secs: Option<u64>,
62}
63
64#[derive(Debug, thiserror::Error)]
65#[error("Shell error: {0}")]
66pub struct ShellError(String);
67
68#[derive(Debug, Clone)]
69pub struct ShellTool {
70    project_path: PathBuf,
71    /// Session-level allowed command prefixes (shared across tool instances)
72    allowed_commands: Arc<AllowedCommands>,
73    /// Whether to require confirmation before executing commands
74    require_confirmation: bool,
75}
76
77impl ShellTool {
78    pub fn new(project_path: PathBuf) -> Self {
79        Self {
80            project_path,
81            allowed_commands: Arc::new(AllowedCommands::new()),
82            require_confirmation: true,
83        }
84    }
85
86    /// Create with shared allowed commands state (for session persistence)
87    pub fn with_allowed_commands(project_path: PathBuf, allowed_commands: Arc<AllowedCommands>) -> Self {
88        Self {
89            project_path,
90            allowed_commands,
91            require_confirmation: true,
92        }
93    }
94
95    /// Disable confirmation prompts (useful for scripted/batch mode)
96    pub fn without_confirmation(mut self) -> Self {
97        self.require_confirmation = false;
98        self
99    }
100
101    fn is_command_allowed(&self, command: &str) -> bool {
102        let trimmed = command.trim();
103        ALLOWED_COMMANDS.iter().any(|allowed| {
104            trimmed.starts_with(allowed) || trimmed == *allowed
105        })
106    }
107
108    fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
109        let canonical_project = self.project_path.canonicalize()
110            .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
111
112        let target = match dir {
113            Some(d) => {
114                let path = PathBuf::from(d);
115                if path.is_absolute() {
116                    path
117                } else {
118                    self.project_path.join(path)
119                }
120            }
121            None => self.project_path.clone(),
122        };
123
124        let canonical_target = target.canonicalize()
125            .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
126
127        if !canonical_target.starts_with(&canonical_project) {
128            return Err(ShellError("Working directory must be within project".to_string()));
129        }
130
131        Ok(canonical_target)
132    }
133}
134
135impl Tool for ShellTool {
136    const NAME: &'static str = "shell";
137
138    type Error = ShellError;
139    type Args = ShellArgs;
140    type Output = String;
141
142    async fn definition(&self, _prompt: String) -> ToolDefinition {
143        ToolDefinition {
144            name: Self::NAME.to_string(),
145            description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
146
147Allowed commands:
148- Docker: docker build, docker compose
149- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
150- Helm: helm lint, helm template, helm dependency
151- Kubernetes: kubectl apply --dry-run, kubectl diff
152- Build: make, npm run, cargo build, go build
153- Linting: hadolint, tflint, yamllint, shellcheck
154
155Use this to validate generated configurations:
156- `docker build -t test .` - Validate Dockerfile
157- `terraform validate` - Validate Terraform configuration
158- `helm lint ./chart` - Validate Helm chart
159- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
160            parameters: json!({
161                "type": "object",
162                "properties": {
163                    "command": {
164                        "type": "string",
165                        "description": "The shell command to execute (must be from allowed list)"
166                    },
167                    "working_dir": {
168                        "type": "string",
169                        "description": "Working directory relative to project root (default: project root)"
170                    },
171                    "timeout_secs": {
172                        "type": "integer",
173                        "description": "Timeout in seconds (default: 60, max: 300)"
174                    }
175                },
176                "required": ["command"]
177            }),
178        }
179    }
180
181    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
182        // Validate command is allowed
183        if !self.is_command_allowed(&args.command) {
184            return Err(ShellError(format!(
185                "Command not allowed. Allowed commands are: {}",
186                ALLOWED_COMMANDS.join(", ")
187            )));
188        }
189
190        // Validate and get working directory
191        let working_dir = self.validate_working_dir(&args.working_dir)?;
192        let working_dir_str = working_dir.to_string_lossy().to_string();
193
194        // Set timeout (max 5 minutes)
195        let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
196
197        // Check if confirmation is needed
198        let needs_confirmation = self.require_confirmation
199            && !self.allowed_commands.is_allowed(&args.command);
200
201        if needs_confirmation {
202            // Show confirmation prompt
203            let confirmation = confirm_shell_command(&args.command, &working_dir_str);
204
205            match confirmation {
206                ConfirmationResult::Proceed => {
207                    // Continue with execution
208                }
209                ConfirmationResult::ProceedAlways(prefix) => {
210                    // Remember this command prefix for the session
211                    self.allowed_commands.allow(prefix);
212                }
213                ConfirmationResult::Modify(feedback) => {
214                    // Return feedback to the agent so it can try a different approach
215                    let result = json!({
216                        "cancelled": true,
217                        "reason": "User requested modification",
218                        "user_feedback": feedback,
219                        "original_command": args.command
220                    });
221                    return serde_json::to_string_pretty(&result)
222                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
223                }
224                ConfirmationResult::Cancel => {
225                    // User cancelled the operation
226                    let result = json!({
227                        "cancelled": true,
228                        "reason": "User cancelled the operation",
229                        "original_command": args.command
230                    });
231                    return serde_json::to_string_pretty(&result)
232                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
233                }
234            }
235        }
236
237        // Create streaming output display
238        let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
239        stream_display.render();
240
241        // Execute command with async streaming output
242        let mut child = Command::new("sh")
243            .arg("-c")
244            .arg(&args.command)
245            .current_dir(&working_dir)
246            .stdout(std::process::Stdio::piped())
247            .stderr(std::process::Stdio::piped())
248            .spawn()
249            .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
250
251        // Take ownership of stdout/stderr for async reading
252        let stdout = child.stdout.take();
253        let stderr = child.stderr.take();
254
255        // Channel for streaming output lines from both stdout and stderr
256        let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr)
257
258        // Spawn task to read stdout
259        let tx_stdout = tx.clone();
260        let stdout_handle = if let Some(stdout) = stdout {
261            Some(tokio::spawn(async move {
262                let mut reader = BufReader::new(stdout).lines();
263                let mut content = String::new();
264                while let Ok(Some(line)) = reader.next_line().await {
265                    content.push_str(&line);
266                    content.push('\n');
267                    let _ = tx_stdout.send((line, false)).await;
268                }
269                content
270            }))
271        } else {
272            None
273        };
274
275        // Spawn task to read stderr
276        let tx_stderr = tx;
277        let stderr_handle = if let Some(stderr) = stderr {
278            Some(tokio::spawn(async move {
279                let mut reader = BufReader::new(stderr).lines();
280                let mut content = String::new();
281                while let Ok(Some(line)) = reader.next_line().await {
282                    content.push_str(&line);
283                    content.push('\n');
284                    let _ = tx_stderr.send((line, true)).await;
285                }
286                content
287            }))
288        } else {
289            None
290        };
291
292        // Process incoming lines and update display in real-time on the main task
293        // Use tokio::select! to handle both the receiver and the reader completion
294        let mut stdout_content = String::new();
295        let mut stderr_content = String::new();
296
297        // Wait for readers while processing display updates
298        loop {
299            tokio::select! {
300                // Receive lines from either stdout or stderr
301                line_result = rx.recv() => {
302                    match line_result {
303                        Some((line, _is_stderr)) => {
304                            stream_display.push_line(&line);
305                        }
306                        None => {
307                            // Channel closed, all readers done
308                            break;
309                        }
310                    }
311                }
312            }
313        }
314
315        // Collect final content from reader handles
316        if let Some(handle) = stdout_handle {
317            stdout_content = handle.await.unwrap_or_default();
318        }
319        if let Some(handle) = stderr_handle {
320            stderr_content = handle.await.unwrap_or_default();
321        }
322
323        // Wait for command to complete
324        let status = child
325            .wait()
326            .await
327            .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
328
329        // Finalize display
330        stream_display.finish(status.success(), status.code());
331
332        // Truncate output if too long
333        const MAX_OUTPUT: usize = 10000;
334        let stdout_truncated = if stdout_content.len() > MAX_OUTPUT {
335            format!(
336                "{}...\n[Output truncated, {} total bytes]",
337                &stdout_content[..MAX_OUTPUT],
338                stdout_content.len()
339            )
340        } else {
341            stdout_content
342        };
343
344        let stderr_truncated = if stderr_content.len() > MAX_OUTPUT {
345            format!(
346                "{}...\n[Output truncated, {} total bytes]",
347                &stderr_content[..MAX_OUTPUT],
348                stderr_content.len()
349            )
350        } else {
351            stderr_content
352        };
353
354        let result = json!({
355            "command": args.command,
356            "working_dir": working_dir_str,
357            "exit_code": status.code(),
358            "success": status.success(),
359            "stdout": stdout_truncated,
360            "stderr": stderr_truncated
361        });
362
363        serde_json::to_string_pretty(&result)
364            .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
365    }
366}