syncable_cli/agent/tools/
shell.rs1use crate::agent::ui::confirmation::{confirm_shell_command, AllowedCommands, ConfirmationResult};
12use crate::agent::ui::shell_output::StreamingShellOutput;
13use rig::completion::ToolDefinition;
14use rig::tool::Tool;
15use serde::Deserialize;
16use serde_json::json;
17use std::io::{BufRead, BufReader};
18use std::path::PathBuf;
19use std::process::{Command, Stdio};
20use std::sync::Arc;
21
22const ALLOWED_COMMANDS: &[&str] = &[
24 "docker build",
26 "docker compose",
27 "docker-compose",
28 "terraform init",
30 "terraform validate",
31 "terraform plan",
32 "terraform fmt",
33 "helm lint",
35 "helm template",
36 "helm dependency",
37 "kubectl apply --dry-run",
39 "kubectl diff",
40 "make",
42 "npm run",
43 "cargo build",
44 "go build",
45 "python -m py_compile",
46 "hadolint",
48 "tflint",
49 "yamllint",
50 "shellcheck",
51];
52
53#[derive(Debug, Deserialize)]
54pub struct ShellArgs {
55 pub command: String,
57 pub working_dir: Option<String>,
59 pub timeout_secs: Option<u64>,
61}
62
63#[derive(Debug, thiserror::Error)]
64#[error("Shell error: {0}")]
65pub struct ShellError(String);
66
67#[derive(Debug, Clone)]
68pub struct ShellTool {
69 project_path: PathBuf,
70 allowed_commands: Arc<AllowedCommands>,
72 require_confirmation: bool,
74}
75
76impl ShellTool {
77 pub fn new(project_path: PathBuf) -> Self {
78 Self {
79 project_path,
80 allowed_commands: Arc::new(AllowedCommands::new()),
81 require_confirmation: true,
82 }
83 }
84
85 pub fn with_allowed_commands(project_path: PathBuf, allowed_commands: Arc<AllowedCommands>) -> Self {
87 Self {
88 project_path,
89 allowed_commands,
90 require_confirmation: true,
91 }
92 }
93
94 pub fn without_confirmation(mut self) -> Self {
96 self.require_confirmation = false;
97 self
98 }
99
100 fn is_command_allowed(&self, command: &str) -> bool {
101 let trimmed = command.trim();
102 ALLOWED_COMMANDS.iter().any(|allowed| {
103 trimmed.starts_with(allowed) || trimmed == *allowed
104 })
105 }
106
107 fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
108 let canonical_project = self.project_path.canonicalize()
109 .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
110
111 let target = match dir {
112 Some(d) => {
113 let path = PathBuf::from(d);
114 if path.is_absolute() {
115 path
116 } else {
117 self.project_path.join(path)
118 }
119 }
120 None => self.project_path.clone(),
121 };
122
123 let canonical_target = target.canonicalize()
124 .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
125
126 if !canonical_target.starts_with(&canonical_project) {
127 return Err(ShellError("Working directory must be within project".to_string()));
128 }
129
130 Ok(canonical_target)
131 }
132}
133
134impl Tool for ShellTool {
135 const NAME: &'static str = "shell";
136
137 type Error = ShellError;
138 type Args = ShellArgs;
139 type Output = String;
140
141 async fn definition(&self, _prompt: String) -> ToolDefinition {
142 ToolDefinition {
143 name: Self::NAME.to_string(),
144 description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
145
146Allowed commands:
147- Docker: docker build, docker compose
148- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
149- Helm: helm lint, helm template, helm dependency
150- Kubernetes: kubectl apply --dry-run, kubectl diff
151- Build: make, npm run, cargo build, go build
152- Linting: hadolint, tflint, yamllint, shellcheck
153
154Use this to validate generated configurations:
155- `docker build -t test .` - Validate Dockerfile
156- `terraform validate` - Validate Terraform configuration
157- `helm lint ./chart` - Validate Helm chart
158- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
159 parameters: json!({
160 "type": "object",
161 "properties": {
162 "command": {
163 "type": "string",
164 "description": "The shell command to execute (must be from allowed list)"
165 },
166 "working_dir": {
167 "type": "string",
168 "description": "Working directory relative to project root (default: project root)"
169 },
170 "timeout_secs": {
171 "type": "integer",
172 "description": "Timeout in seconds (default: 60, max: 300)"
173 }
174 },
175 "required": ["command"]
176 }),
177 }
178 }
179
180 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
181 if !self.is_command_allowed(&args.command) {
183 return Err(ShellError(format!(
184 "Command not allowed. Allowed commands are: {}",
185 ALLOWED_COMMANDS.join(", ")
186 )));
187 }
188
189 let working_dir = self.validate_working_dir(&args.working_dir)?;
191 let working_dir_str = working_dir.to_string_lossy().to_string();
192
193 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
195
196 let needs_confirmation = self.require_confirmation
198 && !self.allowed_commands.is_allowed(&args.command);
199
200 if needs_confirmation {
201 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
203
204 match confirmation {
205 ConfirmationResult::Proceed => {
206 }
208 ConfirmationResult::ProceedAlways(prefix) => {
209 self.allowed_commands.allow(prefix);
211 }
212 ConfirmationResult::Modify(feedback) => {
213 let result = json!({
215 "cancelled": true,
216 "reason": "User requested modification",
217 "user_feedback": feedback,
218 "original_command": args.command
219 });
220 return serde_json::to_string_pretty(&result)
221 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
222 }
223 ConfirmationResult::Cancel => {
224 let result = json!({
226 "cancelled": true,
227 "reason": "User cancelled the operation",
228 "original_command": args.command
229 });
230 return serde_json::to_string_pretty(&result)
231 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
232 }
233 }
234 }
235
236 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
238 stream_display.render();
239
240 let mut child = Command::new("sh")
242 .arg("-c")
243 .arg(&args.command)
244 .current_dir(&working_dir)
245 .stdout(Stdio::piped())
246 .stderr(Stdio::piped())
247 .spawn()
248 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
249
250 let stdout = child.stdout.take();
252 let stderr = child.stderr.take();
253
254 let mut stdout_content = String::new();
255 let mut stderr_content = String::new();
256
257 if let Some(stdout) = stdout {
259 let reader = BufReader::new(stdout);
260 for line in reader.lines() {
261 if let Ok(line) = line {
262 stdout_content.push_str(&line);
263 stdout_content.push('\n');
264 stream_display.push_line(&line);
265 }
266 }
267 }
268
269 if let Some(stderr) = stderr {
271 let reader = BufReader::new(stderr);
272 for line in reader.lines() {
273 if let Ok(line) = line {
274 stderr_content.push_str(&line);
275 stderr_content.push('\n');
276 stream_display.push_line(&line);
277 }
278 }
279 }
280
281 let status = child
283 .wait()
284 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
285
286 stream_display.finish(status.success(), status.code());
288
289 const MAX_OUTPUT: usize = 10000;
291 let stdout_truncated = if stdout_content.len() > MAX_OUTPUT {
292 format!(
293 "{}...\n[Output truncated, {} total bytes]",
294 &stdout_content[..MAX_OUTPUT],
295 stdout_content.len()
296 )
297 } else {
298 stdout_content
299 };
300
301 let stderr_truncated = if stderr_content.len() > MAX_OUTPUT {
302 format!(
303 "{}...\n[Output truncated, {} total bytes]",
304 &stderr_content[..MAX_OUTPUT],
305 stderr_content.len()
306 )
307 } else {
308 stderr_content
309 };
310
311 let result = json!({
312 "command": args.command,
313 "working_dir": working_dir_str,
314 "exit_code": status.code(),
315 "success": status.success(),
316 "stdout": stdout_truncated,
317 "stderr": stderr_truncated
318 });
319
320 serde_json::to_string_pretty(&result)
321 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
322 }
323}