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 building and validation. RESTRICTED to commands that CANNOT be done with native tools.
276
277**DO NOT use shell for linting - use NATIVE tools instead:**
278- Dockerfile linting → use `hadolint` tool (NOT shell hadolint)
279- docker-compose linting → use `dclint` tool (NOT shell docker-compose config)
280- Helm chart linting → use `helmlint` tool (NOT shell helm lint)
281- Kubernetes YAML linting → use `kubelint` tool (NOT shell kubectl/kubeval)
282
283**Use shell ONLY for:**
284- `docker build` - Actually building Docker images
285- `terraform init/validate/plan` - Terraform workflows
286- `make`, `npm run`, `cargo build` - Build commands
287- `git` commands - Version control operations
288
289The native linting tools return AI-optimized JSON with priorities and fix recommendations.
290Shell linting produces plain text that's harder to parse and act on."#.to_string(),
291            parameters: json!({
292                "type": "object",
293                "properties": {
294                    "command": {
295                        "type": "string",
296                        "description": "The shell command to execute (must be from allowed list)"
297                    },
298                    "working_dir": {
299                        "type": "string",
300                        "description": "Working directory relative to project root (default: project root)"
301                    },
302                    "timeout_secs": {
303                        "type": "integer",
304                        "description": "Timeout in seconds (default: 60, max: 300)"
305                    }
306                },
307                "required": ["command"]
308            }),
309        }
310    }
311
312    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
313        // In read-only mode (plan mode), only allow read-only commands
314        if self.read_only {
315            if !self.is_read_only_command(&args.command) {
316                let result = json!({
317                    "error": true,
318                    "reason": "Plan mode is active - only read-only commands allowed",
319                    "blocked_command": args.command,
320                    "allowed_commands": READ_ONLY_COMMANDS,
321                    "hint": "Exit plan mode (Shift+Tab) to run write commands"
322                });
323                return serde_json::to_string_pretty(&result)
324                    .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
325            }
326        } else {
327            // Validate command is allowed (standard mode)
328            if !self.is_command_allowed(&args.command) {
329                return Err(ShellError(format!(
330                    "Command not allowed. Allowed commands are: {}",
331                    ALLOWED_COMMANDS.join(", ")
332                )));
333            }
334        }
335
336        // Validate and get working directory
337        let working_dir = self.validate_working_dir(&args.working_dir)?;
338        let working_dir_str = working_dir.to_string_lossy().to_string();
339
340        // Set timeout (max 5 minutes)
341        let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
342
343        // Check if confirmation is needed
344        let needs_confirmation =
345            self.require_confirmation && !self.allowed_commands.is_allowed(&args.command);
346
347        if needs_confirmation {
348            // Show confirmation prompt
349            let confirmation = confirm_shell_command(&args.command, &working_dir_str);
350
351            match confirmation {
352                ConfirmationResult::Proceed => {
353                    // Continue with execution
354                }
355                ConfirmationResult::ProceedAlways(prefix) => {
356                    // Remember this command prefix for the session
357                    self.allowed_commands.allow(prefix);
358                }
359                ConfirmationResult::Modify(feedback) => {
360                    // Return feedback to the agent so it can try a different approach
361                    let result = json!({
362                        "cancelled": true,
363                        "reason": "User requested modification",
364                        "user_feedback": feedback,
365                        "original_command": args.command
366                    });
367                    return serde_json::to_string_pretty(&result)
368                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
369                }
370                ConfirmationResult::Cancel => {
371                    // User cancelled the operation
372                    let result = json!({
373                        "cancelled": true,
374                        "reason": "User cancelled the operation",
375                        "original_command": args.command
376                    });
377                    return serde_json::to_string_pretty(&result)
378                        .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
379                }
380            }
381        }
382
383        // Create streaming output display
384        let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
385        stream_display.render();
386
387        // Execute command with async streaming output
388        let mut child = Command::new("sh")
389            .arg("-c")
390            .arg(&args.command)
391            .current_dir(&working_dir)
392            .stdout(std::process::Stdio::piped())
393            .stderr(std::process::Stdio::piped())
394            .spawn()
395            .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
396
397        // Take ownership of stdout/stderr for async reading
398        let stdout = child.stdout.take();
399        let stderr = child.stderr.take();
400
401        // Channel for streaming output lines from both stdout and stderr
402        let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr)
403
404        // Spawn task to read stdout
405        let tx_stdout = tx.clone();
406        let stdout_handle = stdout.map(|stdout| {
407            tokio::spawn(async move {
408                let mut reader = BufReader::new(stdout).lines();
409                let mut content = String::new();
410                while let Ok(Some(line)) = reader.next_line().await {
411                    content.push_str(&line);
412                    content.push('\n');
413                    let _ = tx_stdout.send((line, false)).await;
414                }
415                content
416            })
417        });
418
419        // Spawn task to read stderr
420        let tx_stderr = tx;
421        let stderr_handle = stderr.map(|stderr| {
422            tokio::spawn(async move {
423                let mut reader = BufReader::new(stderr).lines();
424                let mut content = String::new();
425                while let Ok(Some(line)) = reader.next_line().await {
426                    content.push_str(&line);
427                    content.push('\n');
428                    let _ = tx_stderr.send((line, true)).await;
429                }
430                content
431            })
432        });
433
434        // Process incoming lines and update display in real-time on the main task
435        // Use tokio::select! to handle both the receiver and the reader completion
436        let mut stdout_content = String::new();
437        let mut stderr_content = String::new();
438
439        // Wait for readers while processing display updates
440        loop {
441            tokio::select! {
442                // Receive lines from either stdout or stderr
443                line_result = rx.recv() => {
444                    match line_result {
445                        Some((line, _is_stderr)) => {
446                            stream_display.push_line(&line);
447                        }
448                        None => {
449                            // Channel closed, all readers done
450                            break;
451                        }
452                    }
453                }
454            }
455        }
456
457        // Collect final content from reader handles
458        if let Some(handle) = stdout_handle {
459            stdout_content = handle.await.unwrap_or_default();
460        }
461        if let Some(handle) = stderr_handle {
462            stderr_content = handle.await.unwrap_or_default();
463        }
464
465        // Wait for command to complete
466        let status = child
467            .wait()
468            .await
469            .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
470
471        // Finalize display
472        stream_display.finish(status.success(), status.code());
473
474        // Apply smart truncation: prefix + suffix strategy
475        // This keeps the first N and last M lines, hiding the middle
476        let limits = TruncationLimits::default();
477        let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
478
479        let result = json!({
480            "command": args.command,
481            "working_dir": working_dir_str,
482            "exit_code": status.code(),
483            "success": status.success(),
484            "stdout": truncated.stdout,
485            "stderr": truncated.stderr,
486            "stdout_total_lines": truncated.stdout_total_lines,
487            "stderr_total_lines": truncated.stderr_total_lines,
488            "stdout_truncated": truncated.stdout_truncated,
489            "stderr_truncated": truncated.stderr_truncated
490        });
491
492        serde_json::to_string_pretty(&result)
493            .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
494    }
495}