syncable_cli/agent/tools/
shell.rs1use crate::agent::ui::confirmation::{confirm_shell_command, AllowedCommands, ConfirmationResult};
19use crate::agent::ui::shell_output::StreamingShellOutput;
20use super::truncation::{truncate_shell_output, TruncationLimits};
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(project_path: PathBuf, allowed_commands: Arc<AllowedCommands>) -> Self {
140 Self {
141 project_path,
142 allowed_commands,
143 require_confirmation: true,
144 read_only: false,
145 }
146 }
147
148 pub fn without_confirmation(mut self) -> Self {
150 self.require_confirmation = false;
151 self
152 }
153
154 pub fn with_read_only(mut self, read_only: bool) -> Self {
156 self.read_only = read_only;
157 self
158 }
159
160 fn is_command_allowed(&self, command: &str) -> bool {
161 let trimmed = command.trim();
162 ALLOWED_COMMANDS.iter().any(|allowed| {
163 trimmed.starts_with(allowed) || trimmed == *allowed
164 })
165 }
166
167 fn is_read_only_command(&self, command: &str) -> bool {
169 let trimmed = command.trim();
170
171 if trimmed.contains(" > ") || trimmed.contains(" >> ") {
173 return false;
174 }
175
176 let dangerous = ["rm ", "rm\t", "rmdir", "mv ", "cp ", "mkdir ", "touch ", "chmod ", "chown ", "npm install", "yarn install", "pnpm install"];
178 for d in dangerous {
179 if trimmed.contains(d) {
180 return false;
181 }
182 }
183
184 let separators = ["&&", "||", "|", ";"];
187 let mut parts: Vec<&str> = vec![trimmed];
188 for sep in separators {
189 parts = parts.iter()
190 .flat_map(|p| p.split(sep))
191 .collect();
192 }
193
194 for part in parts {
196 let part = part.trim();
197 if part.is_empty() {
198 continue;
199 }
200
201 if part.starts_with("cd ") || part == "cd" {
203 continue;
204 }
205
206 let is_allowed = READ_ONLY_COMMANDS.iter().any(|allowed| {
208 part.starts_with(allowed) || part == *allowed
209 });
210
211 if !is_allowed {
212 return false;
213 }
214 }
215
216 true
217 }
218
219 fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
220 let canonical_project = self.project_path.canonicalize()
221 .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
222
223 let target = match dir {
224 Some(d) => {
225 let path = PathBuf::from(d);
226 if path.is_absolute() {
227 path
228 } else {
229 self.project_path.join(path)
230 }
231 }
232 None => self.project_path.clone(),
233 };
234
235 let canonical_target = target.canonicalize()
236 .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
237
238 if !canonical_target.starts_with(&canonical_project) {
239 return Err(ShellError("Working directory must be within project".to_string()));
240 }
241
242 Ok(canonical_target)
243 }
244}
245
246impl Tool for ShellTool {
247 const NAME: &'static str = "shell";
248
249 type Error = ShellError;
250 type Args = ShellArgs;
251 type Output = String;
252
253 async fn definition(&self, _prompt: String) -> ToolDefinition {
254 ToolDefinition {
255 name: Self::NAME.to_string(),
256 description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
257
258Allowed commands:
259- Docker: docker build, docker compose
260- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
261- Helm: helm lint, helm template, helm dependency
262- Kubernetes: kubectl apply --dry-run, kubectl diff
263- Build: make, npm run, cargo build, go build
264- Linting: hadolint, tflint, yamllint, shellcheck
265
266Use this to validate generated configurations:
267- `docker build -t test .` - Validate Dockerfile
268- `terraform validate` - Validate Terraform configuration
269- `helm lint ./chart` - Validate Helm chart
270- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
271 parameters: json!({
272 "type": "object",
273 "properties": {
274 "command": {
275 "type": "string",
276 "description": "The shell command to execute (must be from allowed list)"
277 },
278 "working_dir": {
279 "type": "string",
280 "description": "Working directory relative to project root (default: project root)"
281 },
282 "timeout_secs": {
283 "type": "integer",
284 "description": "Timeout in seconds (default: 60, max: 300)"
285 }
286 },
287 "required": ["command"]
288 }),
289 }
290 }
291
292 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
293 if self.read_only {
295 if !self.is_read_only_command(&args.command) {
296 let result = json!({
297 "error": true,
298 "reason": "Plan mode is active - only read-only commands allowed",
299 "blocked_command": args.command,
300 "allowed_commands": READ_ONLY_COMMANDS,
301 "hint": "Exit plan mode (Shift+Tab) to run write commands"
302 });
303 return serde_json::to_string_pretty(&result)
304 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
305 }
306 } else {
307 if !self.is_command_allowed(&args.command) {
309 return Err(ShellError(format!(
310 "Command not allowed. Allowed commands are: {}",
311 ALLOWED_COMMANDS.join(", ")
312 )));
313 }
314 }
315
316 let working_dir = self.validate_working_dir(&args.working_dir)?;
318 let working_dir_str = working_dir.to_string_lossy().to_string();
319
320 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
322
323 let needs_confirmation = self.require_confirmation
325 && !self.allowed_commands.is_allowed(&args.command);
326
327 if needs_confirmation {
328 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
330
331 match confirmation {
332 ConfirmationResult::Proceed => {
333 }
335 ConfirmationResult::ProceedAlways(prefix) => {
336 self.allowed_commands.allow(prefix);
338 }
339 ConfirmationResult::Modify(feedback) => {
340 let result = json!({
342 "cancelled": true,
343 "reason": "User requested modification",
344 "user_feedback": feedback,
345 "original_command": args.command
346 });
347 return serde_json::to_string_pretty(&result)
348 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
349 }
350 ConfirmationResult::Cancel => {
351 let result = json!({
353 "cancelled": true,
354 "reason": "User cancelled the operation",
355 "original_command": args.command
356 });
357 return serde_json::to_string_pretty(&result)
358 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
359 }
360 }
361 }
362
363 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
365 stream_display.render();
366
367 let mut child = Command::new("sh")
369 .arg("-c")
370 .arg(&args.command)
371 .current_dir(&working_dir)
372 .stdout(std::process::Stdio::piped())
373 .stderr(std::process::Stdio::piped())
374 .spawn()
375 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
376
377 let stdout = child.stdout.take();
379 let stderr = child.stderr.take();
380
381 let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); let tx_stdout = tx.clone();
386 let stdout_handle = if let Some(stdout) = stdout {
387 Some(tokio::spawn(async move {
388 let mut reader = BufReader::new(stdout).lines();
389 let mut content = String::new();
390 while let Ok(Some(line)) = reader.next_line().await {
391 content.push_str(&line);
392 content.push('\n');
393 let _ = tx_stdout.send((line, false)).await;
394 }
395 content
396 }))
397 } else {
398 None
399 };
400
401 let tx_stderr = tx;
403 let stderr_handle = if let Some(stderr) = stderr {
404 Some(tokio::spawn(async move {
405 let mut reader = BufReader::new(stderr).lines();
406 let mut content = String::new();
407 while let Ok(Some(line)) = reader.next_line().await {
408 content.push_str(&line);
409 content.push('\n');
410 let _ = tx_stderr.send((line, true)).await;
411 }
412 content
413 }))
414 } else {
415 None
416 };
417
418 let mut stdout_content = String::new();
421 let mut stderr_content = String::new();
422
423 loop {
425 tokio::select! {
426 line_result = rx.recv() => {
428 match line_result {
429 Some((line, _is_stderr)) => {
430 stream_display.push_line(&line);
431 }
432 None => {
433 break;
435 }
436 }
437 }
438 }
439 }
440
441 if let Some(handle) = stdout_handle {
443 stdout_content = handle.await.unwrap_or_default();
444 }
445 if let Some(handle) = stderr_handle {
446 stderr_content = handle.await.unwrap_or_default();
447 }
448
449 let status = child
451 .wait()
452 .await
453 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
454
455 stream_display.finish(status.success(), status.code());
457
458 let limits = TruncationLimits::default();
461 let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
462
463 let result = json!({
464 "command": args.command,
465 "working_dir": working_dir_str,
466 "exit_code": status.code(),
467 "success": status.success(),
468 "stdout": truncated.stdout,
469 "stderr": truncated.stderr,
470 "stdout_total_lines": truncated.stdout_total_lines,
471 "stderr_total_lines": truncated.stderr_total_lines,
472 "stdout_truncated": truncated.stdout_truncated,
473 "stderr_truncated": truncated.stderr_truncated
474 });
475
476 serde_json::to_string_pretty(&result)
477 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
478 }
479}