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 building and validation. RESTRICTED to commands that CANNOT be done with native tools.
276
277**DO NOT use shell for linting - use NATIVE tools instead:**
278- Dockerfile linting → use `hadolint` tool (NOT shell hadolint)
279- docker-compose linting → use `dclint` tool (NOT shell docker-compose config)
280- Helm chart linting → use `helmlint` tool (NOT shell helm lint)
281- Kubernetes YAML linting → use `kubelint` tool (NOT shell kubectl/kubeval)
282
283**Use shell ONLY for:**
284- `docker build` - Actually building Docker images
285- `terraform init/validate/plan` - Terraform workflows
286- `make`, `npm run`, `cargo build` - Build commands
287- `git` commands - Version control operations
288
289The native linting tools return AI-optimized JSON with priorities and fix recommendations.
290Shell linting produces plain text that's harder to parse and act on."#.to_string(),
291 parameters: json!({
292 "type": "object",
293 "properties": {
294 "command": {
295 "type": "string",
296 "description": "The shell command to execute (must be from allowed list)"
297 },
298 "working_dir": {
299 "type": "string",
300 "description": "Working directory relative to project root (default: project root)"
301 },
302 "timeout_secs": {
303 "type": "integer",
304 "description": "Timeout in seconds (default: 60, max: 300)"
305 }
306 },
307 "required": ["command"]
308 }),
309 }
310 }
311
312 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
313 if self.read_only {
315 if !self.is_read_only_command(&args.command) {
316 let result = json!({
317 "error": true,
318 "reason": "Plan mode is active - only read-only commands allowed",
319 "blocked_command": args.command,
320 "allowed_commands": READ_ONLY_COMMANDS,
321 "hint": "Exit plan mode (Shift+Tab) to run write commands"
322 });
323 return serde_json::to_string_pretty(&result)
324 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
325 }
326 } else {
327 if !self.is_command_allowed(&args.command) {
329 return Err(ShellError(format!(
330 "Command not allowed. Allowed commands are: {}",
331 ALLOWED_COMMANDS.join(", ")
332 )));
333 }
334 }
335
336 let working_dir = self.validate_working_dir(&args.working_dir)?;
338 let working_dir_str = working_dir.to_string_lossy().to_string();
339
340 let timeout_secs = args.timeout_secs.unwrap_or(60).min(300);
342
343 let needs_confirmation =
345 self.require_confirmation && !self.allowed_commands.is_allowed(&args.command);
346
347 if needs_confirmation {
348 let confirmation = confirm_shell_command(&args.command, &working_dir_str);
350
351 match confirmation {
352 ConfirmationResult::Proceed => {
353 }
355 ConfirmationResult::ProceedAlways(prefix) => {
356 self.allowed_commands.allow(prefix);
358 }
359 ConfirmationResult::Modify(feedback) => {
360 let result = json!({
362 "cancelled": true,
363 "reason": "User requested modification",
364 "user_feedback": feedback,
365 "original_command": args.command
366 });
367 return serde_json::to_string_pretty(&result)
368 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
369 }
370 ConfirmationResult::Cancel => {
371 let result = json!({
373 "cancelled": true,
374 "reason": "User cancelled the operation",
375 "original_command": args.command
376 });
377 return serde_json::to_string_pretty(&result)
378 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)));
379 }
380 }
381 }
382
383 let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
385 stream_display.render();
386
387 let mut child = Command::new("sh")
389 .arg("-c")
390 .arg(&args.command)
391 .current_dir(&working_dir)
392 .stdout(std::process::Stdio::piped())
393 .stderr(std::process::Stdio::piped())
394 .spawn()
395 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
396
397 let stdout = child.stdout.take();
399 let stderr = child.stderr.take();
400
401 let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); let tx_stdout = tx.clone();
406 let stdout_handle = stdout.map(|stdout| {
407 tokio::spawn(async move {
408 let mut reader = BufReader::new(stdout).lines();
409 let mut content = String::new();
410 while let Ok(Some(line)) = reader.next_line().await {
411 content.push_str(&line);
412 content.push('\n');
413 let _ = tx_stdout.send((line, false)).await;
414 }
415 content
416 })
417 });
418
419 let tx_stderr = tx;
421 let stderr_handle = stderr.map(|stderr| {
422 tokio::spawn(async move {
423 let mut reader = BufReader::new(stderr).lines();
424 let mut content = String::new();
425 while let Ok(Some(line)) = reader.next_line().await {
426 content.push_str(&line);
427 content.push('\n');
428 let _ = tx_stderr.send((line, true)).await;
429 }
430 content
431 })
432 });
433
434 let mut stdout_content = String::new();
437 let mut stderr_content = String::new();
438
439 loop {
441 tokio::select! {
442 line_result = rx.recv() => {
444 match line_result {
445 Some((line, _is_stderr)) => {
446 stream_display.push_line(&line);
447 }
448 None => {
449 break;
451 }
452 }
453 }
454 }
455 }
456
457 if let Some(handle) = stdout_handle {
459 stdout_content = handle.await.unwrap_or_default();
460 }
461 if let Some(handle) = stderr_handle {
462 stderr_content = handle.await.unwrap_or_default();
463 }
464
465 let status = child
467 .wait()
468 .await
469 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
470
471 stream_display.finish(status.success(), status.code());
473
474 let limits = TruncationLimits::default();
477 let truncated = truncate_shell_output(&stdout_content, &stderr_content, &limits);
478
479 let result = json!({
480 "command": args.command,
481 "working_dir": working_dir_str,
482 "exit_code": status.code(),
483 "success": status.success(),
484 "stdout": truncated.stdout,
485 "stderr": truncated.stderr,
486 "stdout_total_lines": truncated.stdout_total_lines,
487 "stderr_total_lines": truncated.stderr_total_lines,
488 "stdout_truncated": truncated.stdout_truncated,
489 "stderr_truncated": truncated.stderr_truncated
490 });
491
492 serde_json::to_string_pretty(&result)
493 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
494 }
495}