zeroclaw_tools/
gemini_cli.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use std::time::Duration;
5use tokio::process::Command;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::policy::ToolOperation;
9use zeroclaw_config::schema::GeminiCliConfig;
10
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16pub struct GeminiCliTool {
25 security: Arc<SecurityPolicy>,
26 config: GeminiCliConfig,
27}
28
29impl GeminiCliTool {
30 pub fn new(security: Arc<SecurityPolicy>, config: GeminiCliConfig) -> Self {
31 Self { security, config }
32 }
33}
34
35#[async_trait]
36impl Tool for GeminiCliTool {
37 fn name(&self) -> &str {
38 "gemini_cli"
39 }
40
41 fn description(&self) -> &str {
42 "Delegate a coding task to Gemini CLI (gemini -p). Supports file editing and shell execution. Use for complex coding work that benefits from Gemini CLI's full agent loop."
43 }
44
45 fn parameters_schema(&self) -> serde_json::Value {
46 json!({
47 "type": "object",
48 "properties": {
49 "prompt": {
50 "type": "string",
51 "description": "The coding task to delegate to Gemini CLI"
52 },
53 "working_directory": {
54 "type": "string",
55 "description": "Working directory within the workspace (must be inside workspace_dir)"
56 }
57 },
58 "required": ["prompt"]
59 })
60 }
61
62 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
63 if let Err(error) = self
68 .security
69 .enforce_tool_operation(ToolOperation::Act, "gemini_cli")
70 {
71 return Ok(ToolResult {
72 success: false,
73 output: String::new(),
74 error: Some(error),
75 });
76 }
77
78 let prompt = args.get("prompt").and_then(|v| v.as_str()).ok_or_else(|| {
80 ::zeroclaw_log::record!(
81 WARN,
82 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
83 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
84 .with_attrs(::serde_json::json!({"param": "prompt"})),
85 "gemini_cli_tool: missing prompt parameter"
86 );
87 anyhow::Error::msg("Missing 'prompt' parameter")
88 })?;
89
90 let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
95 let wd_path = std::path::PathBuf::from(wd);
96 let workspace = &self.security.workspace_dir;
97 let canonical_wd = match wd_path.canonicalize() {
98 Ok(p) => p,
99 Err(_) => {
100 return Ok(ToolResult {
101 success: false,
102 output: String::new(),
103 error: Some(format!(
104 "working_directory '{}' does not exist or is not accessible",
105 wd
106 )),
107 });
108 }
109 };
110 let canonical_ws = match workspace.canonicalize() {
111 Ok(p) => p,
112 Err(_) => {
113 return Ok(ToolResult {
114 success: false,
115 output: String::new(),
116 error: Some(format!(
117 "workspace directory '{}' does not exist or is not accessible",
118 workspace.display()
119 )),
120 });
121 }
122 };
123 if !canonical_wd.starts_with(&canonical_ws) {
124 return Ok(ToolResult {
125 success: false,
126 output: String::new(),
127 error: Some(format!(
128 "working_directory '{}' is outside the workspace '{}'",
129 wd,
130 workspace.display()
131 )),
132 });
133 }
134 canonical_wd
135 } else {
136 self.security.workspace_dir.clone()
137 };
138
139 let gemini_bin = if cfg!(target_os = "windows") {
141 "gemini.cmd"
142 } else {
143 "gemini"
144 };
145 let mut cmd = Command::new(gemini_bin);
146 cmd.arg("-p").arg(prompt);
147
148 cmd.env_clear();
150 for var in SAFE_ENV_VARS {
151 if let Ok(val) = std::env::var(var) {
152 cmd.env(var, val);
153 }
154 }
155 for var in &self.config.env_passthrough {
156 let trimmed = var.trim();
157 if !trimmed.is_empty()
158 && let Ok(val) = std::env::var(trimmed)
159 {
160 cmd.env(trimmed, val);
161 }
162 }
163
164 cmd.current_dir(&work_dir);
165 let timeout = Duration::from_secs(self.config.timeout_secs);
169 cmd.kill_on_drop(true);
170
171 let result = tokio::time::timeout(timeout, cmd.output()).await;
172
173 match result {
174 Ok(Ok(output)) => {
175 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
176 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
177
178 if stdout.len() > self.config.max_output_bytes {
180 let mut b = self.config.max_output_bytes.min(stdout.len());
181 while b > 0 && !stdout.is_char_boundary(b) {
182 b -= 1;
183 }
184 stdout.truncate(b);
185 stdout.push_str("\n... [output truncated]");
186 }
187
188 Ok(ToolResult {
189 success: output.status.success(),
190 output: stdout,
191 error: if stderr.is_empty() {
192 None
193 } else {
194 Some(stderr)
195 },
196 })
197 }
198 Ok(Err(e)) => {
199 let err_msg = e.to_string();
200 let msg = if err_msg.contains("No such file or directory")
201 || err_msg.contains("not found")
202 || err_msg.contains("cannot find")
203 {
204 "Gemini CLI ('gemini') not found in PATH. Install with: npm install -g @google/gemini-cli or see https://github.com/google-gemini/gemini-cli".into()
205 } else {
206 format!("Failed to execute gemini: {e}")
207 };
208 Ok(ToolResult {
209 success: false,
210 output: String::new(),
211 error: Some(msg),
212 })
213 }
214 Err(_) => {
215 Ok(ToolResult {
218 success: false,
219 output: String::new(),
220 error: Some(format!(
221 "Gemini CLI timed out after {}s and was killed",
222 self.config.timeout_secs
223 )),
224 })
225 }
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use zeroclaw_config::autonomy::AutonomyLevel;
234 use zeroclaw_config::policy::SecurityPolicy;
235 use zeroclaw_config::schema::GeminiCliConfig;
236
237 fn test_config() -> GeminiCliConfig {
238 GeminiCliConfig::default()
239 }
240
241 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
242 Arc::new(SecurityPolicy {
243 autonomy,
244 workspace_dir: std::env::temp_dir(),
245 ..SecurityPolicy::default()
246 })
247 }
248
249 #[test]
250 fn gemini_cli_tool_name() {
251 let tool = GeminiCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
252 assert_eq!(tool.name(), "gemini_cli");
253 }
254
255 #[test]
256 fn gemini_cli_tool_schema_has_prompt() {
257 let tool = GeminiCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
258 let schema = tool.parameters_schema();
259 assert!(schema["properties"]["prompt"].is_object());
260 assert!(
261 schema["required"]
262 .as_array()
263 .expect("schema required should be an array")
264 .contains(&json!("prompt"))
265 );
266 assert!(schema["properties"]["working_directory"].is_object());
267 }
268
269 #[tokio::test]
270 async fn gemini_cli_blocks_rate_limited() {
271 let security = Arc::new(SecurityPolicy {
272 autonomy: AutonomyLevel::Supervised,
273 max_actions_per_hour: 0,
274 workspace_dir: std::env::temp_dir(),
275 ..SecurityPolicy::default()
276 });
277 let tool = GeminiCliTool::new(security, test_config());
278 let result = tool
279 .execute(json!({"prompt": "hello"}))
280 .await
281 .expect("rate-limited should return a result");
282 assert!(!result.success);
283 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
284 }
285
286 #[tokio::test]
287 async fn gemini_cli_blocks_readonly() {
288 let tool = GeminiCliTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
289 let result = tool
290 .execute(json!({"prompt": "hello"}))
291 .await
292 .expect("readonly should return a result");
293 assert!(!result.success);
294 assert!(
295 result
296 .error
297 .as_deref()
298 .unwrap_or("")
299 .contains("read-only mode")
300 );
301 }
302
303 #[tokio::test]
304 async fn gemini_cli_missing_prompt_param() {
305 let tool = GeminiCliTool::new(test_security(AutonomyLevel::Supervised), test_config());
306 let result = tool.execute(json!({})).await;
307 assert!(result.is_err());
308 assert!(result.unwrap_err().to_string().contains("prompt"));
309 }
310
311 #[tokio::test]
312 async fn gemini_cli_rejects_path_outside_workspace() {
313 let tool = GeminiCliTool::new(test_security(AutonomyLevel::Full), test_config());
314 let result = tool
315 .execute(json!({
316 "prompt": "hello",
317 "working_directory": "/etc"
318 }))
319 .await
320 .expect("should return a result for path validation");
321 assert!(!result.success);
322 assert!(
323 result
324 .error
325 .as_deref()
326 .unwrap_or("")
327 .contains("outside the workspace")
328 );
329 }
330
331 #[test]
332 fn gemini_cli_env_passthrough_defaults() {
333 let config = GeminiCliConfig::default();
334 assert!(
335 config.env_passthrough.is_empty(),
336 "env_passthrough should default to empty"
337 );
338 }
339
340 #[test]
341 fn gemini_cli_default_config_values() {
342 let config = GeminiCliConfig::default();
343 assert!(!config.enabled);
344 assert_eq!(config.timeout_secs, 600);
345 assert_eq!(config.max_output_bytes, 2_097_152);
346 }
347}