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 (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(
140        project_path: PathBuf,
141        allowed_commands: Arc<AllowedCommands>,
142    ) -> Self {
143        Self {
144            project_path,
145            allowed_commands,
146            require_confirmation: true,
147            read_only: false,
148        }
149    }
150
151    /// Disable confirmation prompts (useful for scripted/batch mode)
152    pub fn without_confirmation(mut self) -> Self {
153        self.require_confirmation = false;
154        self
155    }
156
157    /// Enable read-only mode (for plan mode) - only allows read-only commands
158    pub fn with_read_only(mut self, read_only: bool) -> Self {
159        self.read_only = read_only;
160        self
161    }
162
163    fn is_command_allowed(&self, command: &str) -> bool {
164        let trimmed = command.trim();
165        ALLOWED_COMMANDS
166            .iter()
167            .any(|allowed| trimmed.starts_with(allowed) || trimmed == *allowed)
168    }
169
170    /// Check if a command is read-only (safe for plan mode)
171    fn is_read_only_command(&self, command: &str) -> bool {
172        let trimmed = command.trim();
173
174        // Block output redirection (writes to files)
175        if trimmed.contains(" > ") || trimmed.contains(" >> ") {
176            return false;
177        }
178
179        // Block dangerous commands explicitly
180        let dangerous = [
181            "rm ",
182            "rm\t",
183            "rmdir",
184            "mv ",
185            "cp ",
186            "mkdir ",
187            "touch ",
188            "chmod ",
189            "chown ",
190            "npm install",
191            "yarn install",
192            "pnpm install",
193        ];
194        for d in dangerous {
195            if trimmed.contains(d) {
196                return false;
197            }
198        }
199
200        // Split on && and || to check each command in chain
201        // Also split on | for pipes
202        let separators = ["&&", "||", "|", ";"];
203        let mut parts: Vec<&str> = vec![trimmed];
204        for sep in separators {
205            parts = parts.iter().flat_map(|p| p.split(sep)).collect();
206        }
207
208        // Each part must be a read-only command
209        for part in parts {
210            let part = part.trim();
211            if part.is_empty() {
212                continue;
213            }
214
215            // Skip "cd" commands - they don't modify anything
216            if part.starts_with("cd ") || part == "cd" {
217                continue;
218            }
219
220            // Check if this part starts with a read-only command
221            let is_allowed = READ_ONLY_COMMANDS
222                .iter()
223                .any(|allowed| part.starts_with(allowed) || part == *allowed);
224
225            if !is_allowed {
226                return false;
227            }
228        }
229
230        true
231    }
232
233    fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
234        let canonical_project = self
235            .project_path
236            .canonicalize()
237            .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
238
239        let target = match dir {
240            Some(d) => {
241                let path = PathBuf::from(d);
242                if path.is_absolute() {
243                    path
244                } else {
245                    self.project_path.join(path)
246                }
247            }
248            None => self.project_path.clone(),
249        };
250
251        let canonical_target = target
252            .canonicalize()
253            .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
254
255        if !canonical_target.starts_with(&canonical_project) {
256            return Err(ShellError(
257                "Working directory must be within project".to_string(),
258            ));
259        }
260
261        Ok(canonical_target)
262    }
263}
264
265impl Tool for ShellTool {
266    const NAME: &'static str = "shell";
267
268    type Error = ShellError;
269    type Args = ShellArgs;
270    type Output = String;
271
272    async fn definition(&self, _prompt: String) -> ToolDefinition {
273        ToolDefinition {
274            name: Self::NAME.to_string(),
275            description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
276
277Allowed commands:
278- Docker: docker build, docker compose
279- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
280- Helm: helm lint, helm template, helm dependency
281- Kubernetes: kubectl apply --dry-run, kubectl diff
282- Build: make, npm run, cargo build, go build
283- Linting: hadolint, tflint, yamllint, shellcheck
284
285Use this to validate generated configurations:
286- `docker build -t test .` - Validate Dockerfile
287- `terraform validate` - Validate Terraform configuration
288- `helm lint ./chart` - Validate Helm chart
289- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
290            parameters: json!({
291                "type": "object",
292                "properties": {
293                    "command": {
294                        "type": "string",
295                        "description": "The shell command to execute (must be from allowed list)"
296                    },
297                    "working_dir": {
298                        "type": "string",
299                        "description": "Working directory relative to project root (default: project root)"
300                    },
301                    "timeout_secs": {
302                        "type": "integer",
303                        "description": "Timeout in seconds (default: 60, max: 300)"
304                    }
305                },
306                "required": ["command"]
307            }),
308        }
309    }
310
311    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
312        // In read-only mode (plan mode), only allow read-only commands
313        if self.read_only {
314            if !self.is_read_only_command(&args.command) {
315                let result = json!({
316                    "error": true,
317                    "reason": "Plan mode is active - only read-only commands allowed",
318                    "blocked_command": args.command,
319                    "allowed_commands": READ_ONLY_COMMANDS,
320                    "hint": "Exit plan mode (Shift+Tab) to run write commands"
321                });
322                return serde_json::to_string_pretty(&result)
323                    .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
324            }
325        } else {
326            // Validate command is allowed (standard mode)
327            if !self.is_command_allowed(&args.command) {
328                return Err(ShellError(format!(
329                    "Command not allowed. Allowed commands are: {}",
330                    ALLOWED_COMMANDS.join(", ")
331                )));
332            }
333        }
334
335        // Validate and get working directory
336        let working_dir = self.validate_working_dir(&args.working_dir)?;
337        let working_dir_str = working_dir.to_string_lossy().to_string();
338
339        // Set timeout (max 5 minutes)
340        let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
341
342        // Check if confirmation is needed
343        let needs_confirmation =
344            self.require_confirmation && !self.allowed_commands.is_allowed(&args.command);
345
346        if needs_confirmation {
347            // Show confirmation prompt
348            let confirmation = confirm_shell_command(&args.command, &working_dir_str);
349
350            match confirmation {
351                ConfirmationResult::Proceed => {
352                    // Continue with execution
353                }
354                ConfirmationResult::ProceedAlways(prefix) => {
355                    // Remember this command prefix for the session
356                    self.allowed_commands.allow(prefix);
357                }
358                ConfirmationResult::Modify(feedback) => {
359                    // Return feedback to the agent so it can try a different approach
360                    let result = json!({
361                        "cancelled": true,
362                        "reason": "User requested modification",
363                        "user_feedback": feedback,
364                        "original_command": args.command
365                    });
366                    return serde_json::to_string_pretty(&result)
367                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
368                }
369                ConfirmationResult::Cancel => {
370                    // User cancelled the operation
371                    let result = json!({
372                        "cancelled": true,
373                        "reason": "User cancelled the operation",
374                        "original_command": args.command
375                    });
376                    return serde_json::to_string_pretty(&result)
377                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
378                }
379            }
380        }
381
382        // Create streaming output display
383        let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
384        stream_display.render();
385
386        // Execute command with async streaming output
387        let mut child = Command::new("sh")
388            .arg("-c")
389            .arg(&args.command)
390            .current_dir(&working_dir)
391            .stdout(std::process::Stdio::piped())
392            .stderr(std::process::Stdio::piped())
393            .spawn()
394            .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
395
396        // Take ownership of stdout/stderr for async reading
397        let stdout = child.stdout.take();
398        let stderr = child.stderr.take();
399
400        // Channel for streaming output lines from both stdout and stderr
401        let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr)
402
403        // Spawn task to read stdout
404        let tx_stdout = tx.clone();
405        let stdout_handle = stdout.map(|stdout| {
406            tokio::spawn(async move {
407                let mut reader = BufReader::new(stdout).lines();
408                let mut content = String::new();
409                while let Ok(Some(line)) = reader.next_line().await {
410                    content.push_str(&line);
411                    content.push('\n');
412                    let _ = tx_stdout.send((line, false)).await;
413                }
414                content
415            })
416        });
417
418        // Spawn task to read stderr
419        let tx_stderr = tx;
420        let stderr_handle = stderr.map(|stderr| {
421            tokio::spawn(async move {
422                let mut reader = BufReader::new(stderr).lines();
423                let mut content = String::new();
424                while let Ok(Some(line)) = reader.next_line().await {
425                    content.push_str(&line);
426                    content.push('\n');
427                    let _ = tx_stderr.send((line, true)).await;
428                }
429                content
430            })
431        });
432
433        // Process incoming lines and update display in real-time on the main task
434        // Use tokio::select! to handle both the receiver and the reader completion
435        let mut stdout_content = String::new();
436        let mut stderr_content = String::new();
437
438        // Wait for readers while processing display updates
439        loop {
440            tokio::select! {
441                // Receive lines from either stdout or stderr
442                line_result = rx.recv() => {
443                    match line_result {
444                        Some((line, _is_stderr)) => {
445                            stream_display.push_line(&line);
446                        }
447                        None => {
448                            // Channel closed, all readers done
449                            break;
450                        }
451                    }
452                }
453            }
454        }
455
456        // Collect final content from reader handles
457        if let Some(handle) = stdout_handle {
458            stdout_content = handle.await.unwrap_or_default();
459        }
460        if let Some(handle) = stderr_handle {
461            stderr_content = handle.await.unwrap_or_default();
462        }
463
464        // Wait for command to complete
465        let status = child
466            .wait()
467            .await
468            .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
469
470        // Finalize display
471        stream_display.finish(status.success(), status.code());
472
473        // Apply smart truncation: prefix + suffix strategy
474        // This keeps the first N and last M lines, hiding the middle
475        let limits = TruncationLimits::default();
476        let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
477
478        let result = json!({
479            "command": args.command,
480            "working_dir": working_dir_str,
481            "exit_code": status.code(),
482            "success": status.success(),
483            "stdout": truncated.stdout,
484            "stderr": truncated.stderr,
485            "stdout_total_lines": truncated.stdout_total_lines,
486            "stderr_total_lines": truncated.stderr_total_lines,
487            "stdout_truncated": truncated.stdout_truncated,
488            "stderr_truncated": truncated.stderr_truncated
489        });
490
491        serde_json::to_string_pretty(&result)
492            .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
493    }
494}