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