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
9use rig::completion::ToolDefinition;
10use rig::tool::Tool;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::path::PathBuf;
14use std::process::{Command, Stdio};
15use std::time::Duration;
16
17/// Allowed command prefixes for security
18const ALLOWED_COMMANDS: &[&str] = &[
19    // Docker commands
20    "docker build",
21    "docker compose",
22    "docker-compose",
23    // Terraform commands
24    "terraform init",
25    "terraform validate",
26    "terraform plan",
27    "terraform fmt",
28    // Helm commands
29    "helm lint",
30    "helm template",
31    "helm dependency",
32    // Kubernetes commands (dry-run only)
33    "kubectl apply --dry-run",
34    "kubectl diff",
35    // Generic validation
36    "make",
37    "npm run",
38    "cargo build",
39    "go build",
40    "python -m py_compile",
41    // Linting
42    "hadolint",
43    "tflint",
44    "yamllint",
45    "shellcheck",
46];
47
48#[derive(Debug, Deserialize)]
49pub struct ShellArgs {
50    /// The command to execute
51    pub command: String,
52    /// Working directory (relative to project root)
53    pub working_dir: Option<String>,
54    /// Timeout in seconds (default: 60, max: 300)
55    pub timeout_secs: Option<u64>,
56}
57
58#[derive(Debug, thiserror::Error)]
59#[error("Shell error: {0}")]
60pub struct ShellError(String);
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ShellTool {
64    project_path: PathBuf,
65}
66
67impl ShellTool {
68    pub fn new(project_path: PathBuf) -> Self {
69        Self { project_path }
70    }
71
72    fn is_command_allowed(&self, command: &str) -> bool {
73        let trimmed = command.trim();
74        ALLOWED_COMMANDS.iter().any(|allowed| {
75            trimmed.starts_with(allowed) || trimmed == *allowed
76        })
77    }
78
79    fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
80        let canonical_project = self.project_path.canonicalize()
81            .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
82
83        let target = match dir {
84            Some(d) => {
85                let path = PathBuf::from(d);
86                if path.is_absolute() {
87                    path
88                } else {
89                    self.project_path.join(path)
90                }
91            }
92            None => self.project_path.clone(),
93        };
94
95        let canonical_target = target.canonicalize()
96            .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
97
98        if !canonical_target.starts_with(&canonical_project) {
99            return Err(ShellError("Working directory must be within project".to_string()));
100        }
101
102        Ok(canonical_target)
103    }
104}
105
106impl Tool for ShellTool {
107    const NAME: &'static str = "shell";
108
109    type Error = ShellError;
110    type Args = ShellArgs;
111    type Output = String;
112
113    async fn definition(&self, _prompt: String) -> ToolDefinition {
114        ToolDefinition {
115            name: Self::NAME.to_string(),
116            description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
117
118Allowed commands:
119- Docker: docker build, docker compose
120- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
121- Helm: helm lint, helm template, helm dependency
122- Kubernetes: kubectl apply --dry-run, kubectl diff
123- Build: make, npm run, cargo build, go build
124- Linting: hadolint, tflint, yamllint, shellcheck
125
126Use this to validate generated configurations:
127- `docker build -t test .` - Validate Dockerfile
128- `terraform validate` - Validate Terraform configuration
129- `helm lint ./chart` - Validate Helm chart
130- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
131            parameters: json!({
132                "type": "object",
133                "properties": {
134                    "command": {
135                        "type": "string",
136                        "description": "The shell command to execute (must be from allowed list)"
137                    },
138                    "working_dir": {
139                        "type": "string",
140                        "description": "Working directory relative to project root (default: project root)"
141                    },
142                    "timeout_secs": {
143                        "type": "integer",
144                        "description": "Timeout in seconds (default: 60, max: 300)"
145                    }
146                },
147                "required": ["command"]
148            }),
149        }
150    }
151
152    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
153        // Validate command is allowed
154        if !self.is_command_allowed(&args.command) {
155            return Err(ShellError(format!(
156                "Command not allowed. Allowed commands are: {}",
157                ALLOWED_COMMANDS.join(", ")
158            )));
159        }
160
161        // Validate and get working directory
162        let working_dir = self.validate_working_dir(&args.working_dir)?;
163
164        // Set timeout (max 5 minutes)
165        let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(60).min(300));
166
167        // Execute command
168        let output = Command::new("sh")
169            .arg("-c")
170            .arg(&args.command)
171            .current_dir(&working_dir)
172            .stdout(Stdio::piped())
173            .stderr(Stdio::piped())
174            .spawn()
175            .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
176
177        // Wait for output with timeout
178        let output = output
179            .wait_with_output()
180            .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
181
182        let stdout = String::from_utf8_lossy(&output.stdout);
183        let stderr = String::from_utf8_lossy(&output.stderr);
184
185        // Truncate output if too long
186        const MAX_OUTPUT: usize = 10000;
187        let stdout_truncated = if stdout.len() > MAX_OUTPUT {
188            format!("{}...\n[Output truncated, {} total bytes]", &stdout[..MAX_OUTPUT], stdout.len())
189        } else {
190            stdout.to_string()
191        };
192
193        let stderr_truncated = if stderr.len() > MAX_OUTPUT {
194            format!("{}...\n[Output truncated, {} total bytes]", &stderr[..MAX_OUTPUT], stderr.len())
195        } else {
196            stderr.to_string()
197        };
198
199        let result = json!({
200            "command": args.command,
201            "working_dir": working_dir.to_string_lossy(),
202            "exit_code": output.status.code(),
203            "success": output.status.success(),
204            "stdout": stdout_truncated,
205            "stderr": stderr_truncated
206        });
207
208        serde_json::to_string_pretty(&result)
209            .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
210    }
211}