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 "kubectl get svc",
50 "kubectl get services",
51 "kubectl get pods",
52 "kubectl get namespaces",
53 "kubectl port-forward",
54 "kubectl config current-context",
55 "kubectl config get-contexts",
56 "kubectl describe",
57 "make",
59 "npm run",
60 "cargo build",
61 "go build",
62 "python -m py_compile",
63 "hadolint",
65 "tflint",
66 "yamllint",
67 "shellcheck",
68];
69
70const READ_ONLY_COMMANDS: &[&str] = &[
73 "ls",
75 "cat",
76 "head",
77 "tail",
78 "less",
79 "more",
80 "wc",
81 "file",
82 "grep",
84 "find",
85 "locate",
86 "which",
87 "whereis",
88 "git status",
90 "git log",
91 "git diff",
92 "git show",
93 "git branch",
94 "git remote",
95 "git tag",
96 "pwd",
98 "tree",
99 "uname",
101 "env",
102 "printenv",
103 "echo",
104 "hadolint",
106 "tflint",
107 "yamllint",
108 "shellcheck",
109 "kubectl get",
111 "kubectl describe",
112 "kubectl config",
113];
114
115#[derive(Debug, Deserialize)]
116pub struct ShellArgs {
117 pub command: String,
119 pub working_dir: Option<String>,
121 pub timeout_secs: Option<u64>,
123}
124
125#[derive(Debug, thiserror::Error)]
126#[error("Shell error: {0}")]
127pub struct ShellError(String);
128
129#[derive(Debug, Clone)]
130pub struct ShellTool {
131 project_path: PathBuf,
132 allowed_commands: Arc<AllowedCommands>,
134 require_confirmation: bool,
136 read_only: bool,
138}
139
140impl ShellTool {
141 pub fn new(project_path: PathBuf) -> Self {
142 Self {
143 project_path,
144 allowed_commands: Arc::new(AllowedCommands::new()),
145 require_confirmation: true,
146 read_only: false,
147 }
148 }
149
150 pub fn with_allowed_commands(
152 project_path: PathBuf,
153 allowed_commands: Arc<AllowedCommands>,
154 ) -> Self {
155 Self {
156 project_path,
157 allowed_commands,
158 require_confirmation: true,
159 read_only: false,
160 }
161 }
162
163 pub fn without_confirmation(mut self) -> Self {
165 self.require_confirmation = false;
166 self
167 }
168
169 pub fn with_read_only(mut self, read_only: bool) -> Self {
171 self.read_only = read_only;
172 self
173 }
174
175 fn is_command_allowed(&self, command: &str) -> bool {
176 let trimmed = command.trim();
177 ALLOWED_COMMANDS
178 .iter()
179 .any(|allowed| trimmed.starts_with(allowed) || trimmed == *allowed)
180 }
181
182 fn is_read_only_command(&self, command: &str) -> bool {
184 let trimmed = command.trim();
185
186 if trimmed.contains(" > ") || trimmed.contains(" >> ") {
188 return false;
189 }
190
191 let dangerous = [
193 "rm ",
194 "rm\t",
195 "rmdir",
196 "mv ",
197 "cp ",
198 "mkdir ",
199 "touch ",
200 "chmod ",
201 "chown ",
202 "npm install",
203 "yarn install",
204 "pnpm install",
205 ];
206 for d in dangerous {
207 if trimmed.contains(d) {
208 return false;
209 }
210 }
211
212 let separators = ["&&", "||", "|", ";"];
215 let mut parts: Vec<&str> = vec![trimmed];
216 for sep in separators {
217 parts = parts.iter().flat_map(|p| p.split(sep)).collect();
218 }
219
220 for part in parts {
222 let part = part.trim();
223 if part.is_empty() {
224 continue;
225 }
226
227 if part.starts_with("cd ") || part == "cd" {
229 continue;
230 }
231
232 let is_allowed = READ_ONLY_COMMANDS
234 .iter()
235 .any(|allowed| part.starts_with(allowed) || part == *allowed);
236
237 if !is_allowed {
238 return false;
239 }
240 }
241
242 true
243 }
244
245 fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
246 let canonical_project = self
247 .project_path
248 .canonicalize()
249 .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
250
251 let target = match dir {
252 Some(d) => {
253 let path = PathBuf::from(d);
254 if path.is_absolute() {
255 path
256 } else {
257 self.project_path.join(path)
258 }
259 }
260 None => self.project_path.clone(),
261 };
262
263 let canonical_target = target
264 .canonicalize()
265 .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
266
267 if !canonical_target.starts_with(&canonical_project) {
268 return Err(ShellError(
269 "Working directory must be within project".to_string(),
270 ));
271 }
272
273 Ok(canonical_target)
274 }
275}
276
277impl Tool for ShellTool {
278 const NAME: &'static str = "shell";
279
280 type Error = ShellError;
281 type Args = ShellArgs;
282 type Output = String;
283
284 async fn definition(&self, _prompt: String) -> ToolDefinition {
285 ToolDefinition {
286 name: Self::NAME.to_string(),
287 description: r#"Execute shell commands for building and validation. RESTRICTED to commands that CANNOT be done with native tools.
288
289**DO NOT use shell for linting - use NATIVE tools instead:**
290- Dockerfile linting → use `hadolint` tool (NOT shell hadolint)
291- docker-compose linting → use `dclint` tool (NOT shell docker-compose config)
292- Helm chart linting → use `helmlint` tool (NOT shell helm lint)
293- Kubernetes YAML linting → use `kubelint` tool (NOT shell kubectl/kubeval)
294
295**Use shell ONLY for:**
296- `docker build` - Actually building Docker images
297- `terraform init/validate/plan` - Terraform workflows
298- `make`, `npm run`, `cargo build` - Build commands
299- `git` commands - Version control operations
300
301The native linting tools return AI-optimized JSON with priorities and fix recommendations.
302Shell linting produces plain text that's harder to parse and act on."#.to_string(),
303 parameters: json!({
304 "type": "object",
305 "properties": {
306 "command": {
307 "type": "string",
308 "description": "The shell command to execute (must be from allowed list)"
309 },
310 "working_dir": {
311 "type": "string",
312 "description": "Working directory relative to project root (default: project root)"
313 },
314 "timeout_secs": {
315 "type": "integer",
316 "description": "Timeout in seconds (default: 60, max: 300)"
317 }
318 },
319 "required": ["command"]
320 }),
321 }
322 }
323
324 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
325 if self.read_only {
327 if !self.is_read_only_command(&args.command) {
328 let result = json!({
329 "error": true,
330 "reason": "Plan mode is active - only read-only commands allowed",
331 "blocked_command": args.command,
332 "allowed_commands": READ_ONLY_COMMANDS,
333 "hint": "Exit plan mode (Shift+Tab) to run write commands"
334 });
335 return serde_json::to_string_pretty(&result)
336 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
337 }
338 } else {
339 if !self.is_command_allowed(&args.command) {
341 return Err(ShellError(format!(
342 "Command not allowed. Allowed commands are: {}",
343 ALLOWED_COMMANDS.join(", ")
344 )));
345 }
346 }
347
348 let working_dir = self.validate_working_dir(&args.working_dir)?;
350 let working_dir_str = working_dir.to_string_lossy().to_string();
351
352 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
354
355 let needs_confirmation =
357 self.require_confirmation && !self.allowed_commands.is_allowed(&args.command);
358
359 if needs_confirmation {
360 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
362
363 match confirmation {
364 ConfirmationResult::Proceed => {
365 }
367 ConfirmationResult::ProceedAlways(prefix) => {
368 self.allowed_commands.allow(prefix);
370 }
371 ConfirmationResult::Modify(feedback) => {
372 let result = json!({
374 "cancelled": true,
375 "reason": "User requested modification",
376 "user_feedback": feedback,
377 "original_command": args.command
378 });
379 return serde_json::to_string_pretty(&result)
380 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
381 }
382 ConfirmationResult::Cancel => {
383 let result = json!({
385 "cancelled": true,
386 "reason": "User cancelled the operation",
387 "original_command": args.command
388 });
389 return serde_json::to_string_pretty(&result)
390 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
391 }
392 }
393 }
394
395 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
397 stream_display.render();
398
399 let mut child = Command::new("sh")
401 .arg("-c")
402 .arg(&args.command)
403 .current_dir(&working_dir)
404 .stdout(std::process::Stdio::piped())
405 .stderr(std::process::Stdio::piped())
406 .spawn()
407 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
408
409 let stdout = child.stdout.take();
411 let stderr = child.stderr.take();
412
413 let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); let tx_stdout = tx.clone();
418 let stdout_handle = stdout.map(|stdout| {
419 tokio::spawn(async move {
420 let mut reader = BufReader::new(stdout).lines();
421 let mut content = String::new();
422 while let Ok(Some(line)) = reader.next_line().await {
423 content.push_str(&line);
424 content.push('\n');
425 let _ = tx_stdout.send((line, false)).await;
426 }
427 content
428 })
429 });
430
431 let tx_stderr = tx;
433 let stderr_handle = stderr.map(|stderr| {
434 tokio::spawn(async move {
435 let mut reader = BufReader::new(stderr).lines();
436 let mut content = String::new();
437 while let Ok(Some(line)) = reader.next_line().await {
438 content.push_str(&line);
439 content.push('\n');
440 let _ = tx_stderr.send((line, true)).await;
441 }
442 content
443 })
444 });
445
446 let mut stdout_content = String::new();
449 let mut stderr_content = String::new();
450
451 loop {
453 tokio::select! {
454 line_result = rx.recv() => {
456 match line_result {
457 Some((line, _is_stderr)) => {
458 stream_display.push_line(&line);
459 }
460 None => {
461 break;
463 }
464 }
465 }
466 }
467 }
468
469 if let Some(handle) = stdout_handle {
471 stdout_content = handle.await.unwrap_or_default();
472 }
473 if let Some(handle) = stderr_handle {
474 stderr_content = handle.await.unwrap_or_default();
475 }
476
477 let status = child
479 .wait()
480 .await
481 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
482
483 stream_display.finish(status.success(), status.code());
485
486 let limits = TruncationLimits::default();
489 let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
490
491 let result = json!({
492 "command": args.command,
493 "working_dir": working_dir_str,
494 "exit_code": status.code(),
495 "success": status.success(),
496 "stdout": truncated.stdout,
497 "stderr": truncated.stderr,
498 "stdout_total_lines": truncated.stdout_total_lines,
499 "stderr_total_lines": truncated.stderr_total_lines,
500 "stdout_truncated": truncated.stdout_truncated,
501 "stderr_truncated": truncated.stderr_truncated
502 });
503
504 serde_json::to_string_pretty(&result)
505 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
506 }
507}