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