syncable_cli/agent/tools/
shell.rs1use rig::completion::ToolDefinition;
10use rig::tool::Tool;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::path::PathBuf;
14use std::process::{Command, Stdio};
15use std::time::Duration;
16
17const ALLOWED_COMMANDS: &[&str] = &[
19 "docker build",
21 "docker compose",
22 "docker-compose",
23 "terraform init",
25 "terraform validate",
26 "terraform plan",
27 "terraform fmt",
28 "helm lint",
30 "helm template",
31 "helm dependency",
32 "kubectl apply --dry-run",
34 "kubectl diff",
35 "make",
37 "npm run",
38 "cargo build",
39 "go build",
40 "python -m py_compile",
41 "hadolint",
43 "tflint",
44 "yamllint",
45 "shellcheck",
46];
47
48#[derive(Debug, Deserialize)]
49pub struct ShellArgs {
50 pub command: String,
52 pub working_dir: Option<String>,
54 pub timeout_secs: Option<u64>,
56}
57
58#[derive(Debug, thiserror::Error)]
59#[error("Shell error: {0}")]
60pub struct ShellError(String);
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ShellTool {
64 project_path: PathBuf,
65}
66
67impl ShellTool {
68 pub fn new(project_path: PathBuf) -> Self {
69 Self { project_path }
70 }
71
72 fn is_command_allowed(&self, command: &str) -> bool {
73 let trimmed = command.trim();
74 ALLOWED_COMMANDS.iter().any(|allowed| {
75 trimmed.starts_with(allowed) || trimmed == *allowed
76 })
77 }
78
79 fn validate_working_dir(&self, dir: &Option<String>) -> Result<PathBuf, ShellError> {
80 let canonical_project = self.project_path.canonicalize()
81 .map_err(|e| ShellError(format!("Invalid project path: {}", e)))?;
82
83 let target = match dir {
84 Some(d) => {
85 let path = PathBuf::from(d);
86 if path.is_absolute() {
87 path
88 } else {
89 self.project_path.join(path)
90 }
91 }
92 None => self.project_path.clone(),
93 };
94
95 let canonical_target = target.canonicalize()
96 .map_err(|e| ShellError(format!("Invalid working directory: {}", e)))?;
97
98 if !canonical_target.starts_with(&canonical_project) {
99 return Err(ShellError("Working directory must be within project".to_string()));
100 }
101
102 Ok(canonical_target)
103 }
104}
105
106impl Tool for ShellTool {
107 const NAME: &'static str = "shell";
108
109 type Error = ShellError;
110 type Args = ShellArgs;
111 type Output = String;
112
113 async fn definition(&self, _prompt: String) -> ToolDefinition {
114 ToolDefinition {
115 name: Self::NAME.to_string(),
116 description: r#"Execute shell commands for validation and building. This tool is restricted to safe DevOps commands.
117
118Allowed commands:
119- Docker: docker build, docker compose
120- Terraform: terraform init, terraform validate, terraform plan, terraform fmt
121- Helm: helm lint, helm template, helm dependency
122- Kubernetes: kubectl apply --dry-run, kubectl diff
123- Build: make, npm run, cargo build, go build
124- Linting: hadolint, tflint, yamllint, shellcheck
125
126Use this to validate generated configurations:
127- `docker build -t test .` - Validate Dockerfile
128- `terraform validate` - Validate Terraform configuration
129- `helm lint ./chart` - Validate Helm chart
130- `hadolint Dockerfile` - Lint Dockerfile"#.to_string(),
131 parameters: json!({
132 "type": "object",
133 "properties": {
134 "command": {
135 "type": "string",
136 "description": "The shell command to execute (must be from allowed list)"
137 },
138 "working_dir": {
139 "type": "string",
140 "description": "Working directory relative to project root (default: project root)"
141 },
142 "timeout_secs": {
143 "type": "integer",
144 "description": "Timeout in seconds (default: 60, max: 300)"
145 }
146 },
147 "required": ["command"]
148 }),
149 }
150 }
151
152 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
153 if !self.is_command_allowed(&args.command) {
155 return Err(ShellError(format!(
156 "Command not allowed. Allowed commands are: {}",
157 ALLOWED_COMMANDS.join(", ")
158 )));
159 }
160
161 let working_dir = self.validate_working_dir(&args.working_dir)?;
163
164 let timeout = Duration::from_secs(args.timeout_secs.unwrap_or(60).min(300));
166
167 let output = Command::new("sh")
169 .arg("-c")
170 .arg(&args.command)
171 .current_dir(&working_dir)
172 .stdout(Stdio::piped())
173 .stderr(Stdio::piped())
174 .spawn()
175 .map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
176
177 let output = output
179 .wait_with_output()
180 .map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
181
182 let stdout = String::from_utf8_lossy(&output.stdout);
183 let stderr = String::from_utf8_lossy(&output.stderr);
184
185 const MAX_OUTPUT: usize = 10000;
187 let stdout_truncated = if stdout.len() > MAX_OUTPUT {
188 format!("{}...\n[Output truncated, {} total bytes]", &stdout[..MAX_OUTPUT], stdout.len())
189 } else {
190 stdout.to_string()
191 };
192
193 let stderr_truncated = if stderr.len() > MAX_OUTPUT {
194 format!("{}...\n[Output truncated, {} total bytes]", &stderr[..MAX_OUTPUT], stderr.len())
195 } else {
196 stderr.to_string()
197 };
198
199 let result = json!({
200 "command": args.command,
201 "working_dir": working_dir.to_string_lossy(),
202 "exit_code": output.status.code(),
203 "success": output.status.success(),
204 "stdout": stdout_truncated,
205 "stderr": stderr_truncated
206 });
207
208 serde_json::to_string_pretty(&result)
209 .map_err(|e| ShellError(format!("Failed to serialize: {}", e)))
210 }
211}