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::path::PathBuf;
18use std::sync::Arc;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tokio::process::Command;
21use tokio::sync::mpsc;
22
23const ALLOWED_COMMANDS: &[&str] = &[
25 "docker build",
27 "docker compose",
28 "docker-compose",
29 "terraform init",
31 "terraform validate",
32 "terraform plan",
33 "terraform fmt",
34 "helm lint",
36 "helm template",
37 "helm dependency",
38 "kubectl apply --dry-run",
40 "kubectl diff",
41 "make",
43 "npm run",
44 "cargo build",
45 "go build",
46 "python -m py_compile",
47 "hadolint",
49 "tflint",
50 "yamllint",
51 "shellcheck",
52];
53
54#[derive(Debug, Deserialize)]
55pub struct ShellArgs {
56 pub command: String,
58 pub working_dir: Option<String>,
60 pub timeout_secs: Option<u64>,
62}
63
64#[derive(Debug, thiserror::Error)]
65#[error("Shell error: {0}")]
66pub struct ShellError(String);
67
68#[derive(Debug, Clone)]
69pub struct ShellTool {
70 project_path: PathBuf,
71 allowed_commands: Arc<AllowedCommands>,
73 require_confirmation: bool,
75}
76
77impl ShellTool {
78 pub fn new(project_path: PathBuf) -> Self {
79 Self {
80 project_path,
81 allowed_commands: Arc::new(AllowedCommands::new()),
82 require_confirmation: true,
83 }
84 }
85
86 pub fn with_allowed_commands(project_path: PathBuf, allowed_commands: Arc<AllowedCommands>) -> Self {
88 Self {
89 project_path,
90 allowed_commands,
91 require_confirmation: true,
92 }
93 }
94
95 pub fn without_confirmation(mut self) -> Self {
97 self.require_confirmation = false;
98 self
99 }
100
101 fn is_command_allowed(&self, command: &str) -> bool {
102 let trimmed = command.trim();
103 ALLOWED_COMMANDS.iter().any(|allowed| {
104 trimmed.starts_with(allowed) || trimmed == *allowed
105 })
106 }
107
108 fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
109 let canonical_project = self.project_path.canonicalize()
110 .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
111
112 let target = match dir {
113 Some(d) => {
114 let path = PathBuf::from(d);
115 if path.is_absolute() {
116 path
117 } else {
118 self.project_path.join(path)
119 }
120 }
121 None => self.project_path.clone(),
122 };
123
124 let canonical_target = target.canonicalize()
125 .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
126
127 if !canonical_target.starts_with(&canonical_project) {
128 return Err(ShellError("Working directory must be within project".to_string()));
129 }
130
131 Ok(canonical_target)
132 }
133}
134
135impl Tool for ShellTool {
136 const NAME: &'static str = "shell";
137
138 type Error = ShellError;
139 type Args = ShellArgs;
140 type Output = String;
141
142 async fn definition(&self, _prompt: String) -> ToolDefinition {
143 ToolDefinition {
144 name: Self::NAME.to_string(),
145 description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
146
147Allowed commands:
148- Docker: docker build, docker compose
149- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
150- Helm: helm lint, helm template, helm dependency
151- Kubernetes: kubectl apply --dry-run, kubectl diff
152- Build: make, npm run, cargo build, go build
153- Linting: hadolint, tflint, yamllint, shellcheck
154
155Use this to validate generated configurations:
156- `docker build -t test .` - Validate Dockerfile
157- `terraform validate` - Validate Terraform configuration
158- `helm lint ./chart` - Validate Helm chart
159- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
160 parameters: json!({
161 "type": "object",
162 "properties": {
163 "command": {
164 "type": "string",
165 "description": "The shell command to execute (must be from allowed list)"
166 },
167 "working_dir": {
168 "type": "string",
169 "description": "Working directory relative to project root (default: project root)"
170 },
171 "timeout_secs": {
172 "type": "integer",
173 "description": "Timeout in seconds (default: 60, max: 300)"
174 }
175 },
176 "required": ["command"]
177 }),
178 }
179 }
180
181 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
182 if !self.is_command_allowed(&args.command) {
184 return Err(ShellError(format!(
185 "Command not allowed. Allowed commands are: {}",
186 ALLOWED_COMMANDS.join(", ")
187 )));
188 }
189
190 let working_dir = self.validate_working_dir(&args.working_dir)?;
192 let working_dir_str = working_dir.to_string_lossy().to_string();
193
194 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
196
197 let needs_confirmation = self.require_confirmation
199 && !self.allowed_commands.is_allowed(&args.command);
200
201 if needs_confirmation {
202 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
204
205 match confirmation {
206 ConfirmationResult::Proceed => {
207 }
209 ConfirmationResult::ProceedAlways(prefix) => {
210 self.allowed_commands.allow(prefix);
212 }
213 ConfirmationResult::Modify(feedback) => {
214 let result = json!({
216 "cancelled": true,
217 "reason": "User requested modification",
218 "user_feedback": feedback,
219 "original_command": args.command
220 });
221 return serde_json::to_string_pretty(&result)
222 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
223 }
224 ConfirmationResult::Cancel => {
225 let result = json!({
227 "cancelled": true,
228 "reason": "User cancelled the operation",
229 "original_command": args.command
230 });
231 return serde_json::to_string_pretty(&result)
232 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
233 }
234 }
235 }
236
237 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
239 stream_display.render();
240
241 let mut child = Command::new("sh")
243 .arg("-c")
244 .arg(&args.command)
245 .current_dir(&working_dir)
246 .stdout(std::process::Stdio::piped())
247 .stderr(std::process::Stdio::piped())
248 .spawn()
249 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
250
251 let stdout = child.stdout.take();
253 let stderr = child.stderr.take();
254
255 let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); let tx_stdout = tx.clone();
260 let stdout_handle = if let Some(stdout) = stdout {
261 Some(tokio::spawn(async move {
262 let mut reader = BufReader::new(stdout).lines();
263 let mut content = String::new();
264 while let Ok(Some(line)) = reader.next_line().await {
265 content.push_str(&line);
266 content.push('\n');
267 let _ = tx_stdout.send((line, false)).await;
268 }
269 content
270 }))
271 } else {
272 None
273 };
274
275 let tx_stderr = tx;
277 let stderr_handle = if let Some(stderr) = stderr {
278 Some(tokio::spawn(async move {
279 let mut reader = BufReader::new(stderr).lines();
280 let mut content = String::new();
281 while let Ok(Some(line)) = reader.next_line().await {
282 content.push_str(&line);
283 content.push('\n');
284 let _ = tx_stderr.send((line, true)).await;
285 }
286 content
287 }))
288 } else {
289 None
290 };
291
292 let mut stdout_content = String::new();
295 let mut stderr_content = String::new();
296
297 loop {
299 tokio::select! {
300 line_result = rx.recv() => {
302 match line_result {
303 Some((line, _is_stderr)) => {
304 stream_display.push_line(&line);
305 }
306 None => {
307 break;
309 }
310 }
311 }
312 }
313 }
314
315 if let Some(handle) = stdout_handle {
317 stdout_content = handle.await.unwrap_or_default();
318 }
319 if let Some(handle) = stderr_handle {
320 stderr_content = handle.await.unwrap_or_default();
321 }
322
323 let status = child
325 .wait()
326 .await
327 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
328
329 stream_display.finish(status.success(), status.code());
331
332 const MAX_OUTPUT: usize = 10000;
334 let stdout_truncated = if stdout_content.len() > MAX_OUTPUT {
335 format!(
336 "{}...\n[Output truncated, {} total bytes]",
337 &stdout_content[..MAX_OUTPUT],
338 stdout_content.len()
339 )
340 } else {
341 stdout_content
342 };
343
344 let stderr_truncated = if stderr_content.len() > MAX_OUTPUT {
345 format!(
346 "{}...\n[Output truncated, {} total bytes]",
347 &stderr_content[..MAX_OUTPUT],
348 stderr_content.len()
349 )
350 } else {
351 stderr_content
352 };
353
354 let result = json!({
355 "command": args.command,
356 "working_dir": working_dir_str,
357 "exit_code": status.code(),
358 "success": status.success(),
359 "stdout": stdout_truncated,
360 "stderr": stderr_truncated
361 });
362
363 serde_json::to_string_pretty(&result)
364 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
365 }
366}