syncable_cli/agent/tools/
shell.rs1use 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
31const ALLOWED_COMMANDS: &[&str] = &[
33 "docker build",
35 "docker compose",
36 "docker-compose",
37 "terraform init",
39 "terraform validate",
40 "terraform plan",
41 "terraform fmt",
42 "helm lint",
44 "helm template",
45 "helm dependency",
46 "kubectl apply --dry-run",
48 "kubectl diff",
49 "make",
51 "npm run",
52 "cargo build",
53 "go build",
54 "python -m py_compile",
55 "hadolint",
57 "tflint",
58 "yamllint",
59 "shellcheck",
60];
61
62const READ_ONLY_COMMANDS: &[&str] = &[
65 "ls",
67 "cat",
68 "head",
69 "tail",
70 "less",
71 "more",
72 "wc",
73 "file",
74 "grep",
76 "find",
77 "locate",
78 "which",
79 "whereis",
80 "git status",
82 "git log",
83 "git diff",
84 "git show",
85 "git branch",
86 "git remote",
87 "git tag",
88 "pwd",
90 "tree",
91 "uname",
93 "env",
94 "printenv",
95 "echo",
96 "hadolint",
98 "tflint",
99 "yamllint",
100 "shellcheck",
101];
102
103#[derive(Debug, Deserialize)]
104pub struct ShellArgs {
105 pub command: String,
107 pub working_dir: Option<String>,
109 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 allowed_commands: Arc<AllowedCommands>,
122 require_confirmation: bool,
124 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 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 pub fn without_confirmation(mut self) -> Self {
153 self.require_confirmation = false;
154 self
155 }
156
157 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 fn is_read_only_command(&self, command: &str) -> bool {
172 let trimmed = command.trim();
173
174 if trimmed.contains(" > ") || trimmed.contains(" >> ") {
176 return false;
177 }
178
179 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 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 for part in parts {
210 let part = part.trim();
211 if part.is_empty() {
212 continue;
213 }
214
215 if part.starts_with("cd ") || part == "cd" {
217 continue;
218 }
219
220 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 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 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 let working_dir = self.validate_working_dir(&args.working_dir)?;
337 let working_dir_str = working_dir.to_string_lossy().to_string();
338
339 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
341
342 let needs_confirmation =
344 self.require_confirmation && !self.allowed_commands.is_allowed(&args.command);
345
346 if needs_confirmation {
347 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
349
350 match confirmation {
351 ConfirmationResult::Proceed => {
352 }
354 ConfirmationResult::ProceedAlways(prefix) => {
355 self.allowed_commands.allow(prefix);
357 }
358 ConfirmationResult::Modify(feedback) => {
359 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 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 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
384 stream_display.render();
385
386 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 let stdout = child.stdout.take();
398 let stderr = child.stderr.take();
399
400 let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); 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 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 let mut stdout_content = String::new();
436 let mut stderr_content = String::new();
437
438 loop {
440 tokio::select! {
441 line_result = rx.recv() => {
443 match line_result {
444 Some((line, _is_stderr)) => {
445 stream_display.push_line(&line);
446 }
447 None => {
448 break;
450 }
451 }
452 }
453 }
454 }
455
456 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 let status = child
466 .wait()
467 .await
468 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
469
470 stream_display.finish(status.success(), status.code());
472
473 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}