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//!
11//! ## Output Truncation
12//!
13//! Shell outputs are truncated using prefix/suffix strategy:
14//! - First 200 lines + last 200 lines are kept
15//! - Middle content is summarized with line count
16//! - Long lines (>2000 chars) are truncated
17
18use crate::agent::ui::confirmation::{confirm_shell_command, AllowedCommands, ConfirmationResult};
19use crate::agent::ui::shell_output::StreamingShellOutput;
20use super::truncation::{truncate_shell_output, TruncationLimits};
21use rig::completion::ToolDefinition;
22use rig::tool::Tool;
23use serde::Deserialize;
24use serde_json::json;
25use std::path::PathBuf;
26use std::sync::Arc;
27use tokio::io::{AsyncBufReadExt, BufReader};
28use tokio::process::Command;
29use tokio::sync::mpsc;
30
31/// Allowed command prefixes for security
32const ALLOWED_COMMANDS: &[&str] = &[
33    // Docker commands
34    "docker build",
35    "docker compose",
36    "docker-compose",
37    // Terraform commands
38    "terraform init",
39    "terraform validate",
40    "terraform plan",
41    "terraform fmt",
42    // Helm commands
43    "helm lint",
44    "helm template",
45    "helm dependency",
46    // Kubernetes commands (dry-run only)
47    "kubectl apply --dry-run",
48    "kubectl diff",
49    // Generic validation
50    "make",
51    "npm run",
52    "cargo build",
53    "go build",
54    "python -m py_compile",
55    // Linting
56    "hadolint",
57    "tflint",
58    "yamllint",
59    "shellcheck",
60];
61
62/// Read-only commands allowed in plan mode
63/// These commands only read/analyze and don't modify the filesystem
64const READ_ONLY_COMMANDS: &[&str] = &[
65    // File listing/reading
66    "ls",
67    "cat",
68    "head",
69    "tail",
70    "less",
71    "more",
72    "wc",
73    "file",
74    // Search/find
75    "grep",
76    "find",
77    "locate",
78    "which",
79    "whereis",
80    // Git read-only
81    "git status",
82    "git log",
83    "git diff",
84    "git show",
85    "git branch",
86    "git remote",
87    "git tag",
88    // Directory navigation
89    "pwd",
90    "tree",
91    // System info
92    "uname",
93    "env",
94    "printenv",
95    "echo",
96    // Code analysis
97    "hadolint",
98    "tflint",
99    "yamllint",
100    "shellcheck",
101];
102
103#[derive(Debug, Deserialize)]
104pub struct ShellArgs {
105    /// The command to execute
106    pub command: String,
107    /// Working directory (relative to project root)
108    pub working_dir: Option<String>,
109    /// Timeout in seconds (default: 60, max: 300)
110    pub timeout_secs: Option<u64>,
111}
112
113#[derive(Debug, thiserror::Error)]
114#[error("Shell error: {0}")]
115pub struct ShellError(String);
116
117#[derive(Debug, Clone)]
118pub struct ShellTool {
119    project_path: PathBuf,
120    /// Session-level allowed command prefixes (shared across tool instances)
121    allowed_commands: Arc<AllowedCommands>,
122    /// Whether to require confirmation before executing commands
123    require_confirmation: bool,
124    /// Whether in read-only mode (plan mode) - only allows read-only commands
125    read_only: bool,
126}
127
128impl ShellTool {
129    pub fn new(project_path: PathBuf) -> Self {
130        Self {
131            project_path,
132            allowed_commands: Arc::new(AllowedCommands::new()),
133            require_confirmation: true,
134            read_only: false,
135        }
136    }
137
138    /// Create with shared allowed commands state (for session persistence)
139    pub fn with_allowed_commands(project_path: PathBuf, allowed_commands: Arc<AllowedCommands>) -> Self {
140        Self {
141            project_path,
142            allowed_commands,
143            require_confirmation: true,
144            read_only: false,
145        }
146    }
147
148    /// Disable confirmation prompts (useful for scripted/batch mode)
149    pub fn without_confirmation(mut self) -> Self {
150        self.require_confirmation = false;
151        self
152    }
153
154    /// Enable read-only mode (for plan mode) - only allows read-only commands
155    pub fn with_read_only(mut self, read_only: bool) -> Self {
156        self.read_only = read_only;
157        self
158    }
159
160    fn is_command_allowed(&self, command: &str) -> bool {
161        let trimmed = command.trim();
162        ALLOWED_COMMANDS.iter().any(|allowed| {
163            trimmed.starts_with(allowed) || trimmed == *allowed
164        })
165    }
166
167    /// Check if a command is read-only (safe for plan mode)
168    fn is_read_only_command(&self, command: &str) -> bool {
169        let trimmed = command.trim();
170
171        // Block output redirection (writes to files)
172        if trimmed.contains(" > ") || trimmed.contains(" >> ") {
173            return false;
174        }
175
176        // Block dangerous commands explicitly
177        let dangerous = ["rm ", "rm\t", "rmdir", "mv ", "cp ", "mkdir ", "touch ", "chmod ", "chown ", "npm install", "yarn install", "pnpm install"];
178        for d in dangerous {
179            if trimmed.contains(d) {
180                return false;
181            }
182        }
183
184        // Split on && and || to check each command in chain
185        // Also split on | for pipes
186        let separators = ["&&", "||", "|", ";"];
187        let mut parts: Vec<&str> = vec![trimmed];
188        for sep in separators {
189            parts = parts.iter()
190                .flat_map(|p| p.split(sep))
191                .collect();
192        }
193
194        // Each part must be a read-only command
195        for part in parts {
196            let part = part.trim();
197            if part.is_empty() {
198                continue;
199            }
200
201            // Skip "cd" commands - they don't modify anything
202            if part.starts_with("cd ") || part == "cd" {
203                continue;
204            }
205
206            // Check if this part starts with a read-only command
207            let is_allowed = READ_ONLY_COMMANDS.iter().any(|allowed| {
208                part.starts_with(allowed) || part == *allowed
209            });
210
211            if !is_allowed {
212                return false;
213            }
214        }
215
216        true
217    }
218
219    fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
220        let canonical_project = self.project_path.canonicalize()
221            .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
222
223        let target = match dir {
224            Some(d) => {
225                let path = PathBuf::from(d);
226                if path.is_absolute() {
227                    path
228                } else {
229                    self.project_path.join(path)
230                }
231            }
232            None => self.project_path.clone(),
233        };
234
235        let canonical_target = target.canonicalize()
236            .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
237
238        if !canonical_target.starts_with(&canonical_project) {
239            return Err(ShellError("Working directory must be within project".to_string()));
240        }
241
242        Ok(canonical_target)
243    }
244}
245
246impl Tool for ShellTool {
247    const NAME: &'static str = "shell";
248
249    type Error = ShellError;
250    type Args = ShellArgs;
251    type Output = String;
252
253    async fn definition(&self, _prompt: String) -> ToolDefinition {
254        ToolDefinition {
255            name: Self::NAME.to_string(),
256            description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
257
258Allowed commands:
259- Docker: docker build, docker compose
260- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
261- Helm: helm lint, helm template, helm dependency
262- Kubernetes: kubectl apply --dry-run, kubectl diff
263- Build: make, npm run, cargo build, go build
264- Linting: hadolint, tflint, yamllint, shellcheck
265
266Use this to validate generated configurations:
267- `docker build -t test .` - Validate Dockerfile
268- `terraform validate` - Validate Terraform configuration
269- `helm lint ./chart` - Validate Helm chart
270- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
271            parameters: json!({
272                "type": "object",
273                "properties": {
274                    "command": {
275                        "type": "string",
276                        "description": "The shell command to execute (must be from allowed list)"
277                    },
278                    "working_dir": {
279                        "type": "string",
280                        "description": "Working directory relative to project root (default: project root)"
281                    },
282                    "timeout_secs": {
283                        "type": "integer",
284                        "description": "Timeout in seconds (default: 60, max: 300)"
285                    }
286                },
287                "required": ["command"]
288            }),
289        }
290    }
291
292    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
293        // In read-only mode (plan mode), only allow read-only commands
294        if self.read_only {
295            if !self.is_read_only_command(&args.command) {
296                let result = json!({
297                    "error": true,
298                    "reason": "Plan mode is active - only read-only commands allowed",
299                    "blocked_command": args.command,
300                    "allowed_commands": READ_ONLY_COMMANDS,
301                    "hint": "Exit plan mode (Shift+Tab) to run write commands"
302                });
303                return serde_json::to_string_pretty(&result)
304                    .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
305            }
306        } else {
307            // Validate command is allowed (standard mode)
308            if !self.is_command_allowed(&args.command) {
309                return Err(ShellError(format!(
310                    "Command not allowed. Allowed commands are: {}",
311                    ALLOWED_COMMANDS.join(", ")
312                )));
313            }
314        }
315
316        // Validate and get working directory
317        let working_dir = self.validate_working_dir(&args.working_dir)?;
318        let working_dir_str = working_dir.to_string_lossy().to_string();
319
320        // Set timeout (max 5 minutes)
321        let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
322
323        // Check if confirmation is needed
324        let needs_confirmation = self.require_confirmation
325            && !self.allowed_commands.is_allowed(&args.command);
326
327        if needs_confirmation {
328            // Show confirmation prompt
329            let confirmation = confirm_shell_command(&args.command, &working_dir_str);
330
331            match confirmation {
332                ConfirmationResult::Proceed => {
333                    // Continue with execution
334                }
335                ConfirmationResult::ProceedAlways(prefix) => {
336                    // Remember this command prefix for the session
337                    self.allowed_commands.allow(prefix);
338                }
339                ConfirmationResult::Modify(feedback) => {
340                    // Return feedback to the agent so it can try a different approach
341                    let result = json!({
342                        "cancelled": true,
343                        "reason": "User requested modification",
344                        "user_feedback": feedback,
345                        "original_command": args.command
346                    });
347                    return serde_json::to_string_pretty(&result)
348                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
349                }
350                ConfirmationResult::Cancel => {
351                    // User cancelled the operation
352                    let result = json!({
353                        "cancelled": true,
354                        "reason": "User cancelled the operation",
355                        "original_command": args.command
356                    });
357                    return serde_json::to_string_pretty(&result)
358                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
359                }
360            }
361        }
362
363        // Create streaming output display
364        let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
365        stream_display.render();
366
367        // Execute command with async streaming output
368        let mut child = Command::new("sh")
369            .arg("-c")
370            .arg(&args.command)
371            .current_dir(&working_dir)
372            .stdout(std::process::Stdio::piped())
373            .stderr(std::process::Stdio::piped())
374            .spawn()
375            .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
376
377        // Take ownership of stdout/stderr for async reading
378        let stdout = child.stdout.take();
379        let stderr = child.stderr.take();
380
381        // Channel for streaming output lines from both stdout and stderr
382        let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr)
383
384        // Spawn task to read stdout
385        let tx_stdout = tx.clone();
386        let stdout_handle = if let Some(stdout) = stdout {
387            Some(tokio::spawn(async move {
388                let mut reader = BufReader::new(stdout).lines();
389                let mut content = String::new();
390                while let Ok(Some(line)) = reader.next_line().await {
391                    content.push_str(&line);
392                    content.push('\n');
393                    let _ = tx_stdout.send((line, false)).await;
394                }
395                content
396            }))
397        } else {
398            None
399        };
400
401        // Spawn task to read stderr
402        let tx_stderr = tx;
403        let stderr_handle = if let Some(stderr) = stderr {
404            Some(tokio::spawn(async move {
405                let mut reader = BufReader::new(stderr).lines();
406                let mut content = String::new();
407                while let Ok(Some(line)) = reader.next_line().await {
408                    content.push_str(&line);
409                    content.push('\n');
410                    let _ = tx_stderr.send((line, true)).await;
411                }
412                content
413            }))
414        } else {
415            None
416        };
417
418        // Process incoming lines and update display in real-time on the main task
419        // Use tokio::select! to handle both the receiver and the reader completion
420        let mut stdout_content = String::new();
421        let mut stderr_content = String::new();
422
423        // Wait for readers while processing display updates
424        loop {
425            tokio::select! {
426                // Receive lines from either stdout or stderr
427                line_result = rx.recv() => {
428                    match line_result {
429                        Some((line, _is_stderr)) => {
430                            stream_display.push_line(&line);
431                        }
432                        None => {
433                            // Channel closed, all readers done
434                            break;
435                        }
436                    }
437                }
438            }
439        }
440
441        // Collect final content from reader handles
442        if let Some(handle) = stdout_handle {
443            stdout_content = handle.await.unwrap_or_default();
444        }
445        if let Some(handle) = stderr_handle {
446            stderr_content = handle.await.unwrap_or_default();
447        }
448
449        // Wait for command to complete
450        let status = child
451            .wait()
452            .await
453            .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
454
455        // Finalize display
456        stream_display.finish(status.success(), status.code());
457
458        // Apply smart truncation: prefix + suffix strategy
459        // This keeps the first N and last M lines, hiding the middle
460        let limits = TruncationLimits::default();
461        let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
462
463        let result = json!({
464            "command": args.command,
465            "working_dir": working_dir_str,
466            "exit_code": status.code(),
467            "success": status.success(),
468            "stdout": truncated.stdout,
469            "stderr": truncated.stderr,
470            "stdout_total_lines": truncated.stdout_total_lines,
471            "stderr_total_lines": truncated.stderr_total_lines,
472            "stdout_truncated": truncated.stdout_truncated,
473            "stderr_truncated": truncated.stderr_truncated
474        });
475
476        serde_json::to_string_pretty(&result)
477            .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
478    }
479}