1use 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::ClaudeCodeConfig;
10
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16pub struct ClaudeCodeTool {
26 security: Arc<SecurityPolicy>,
27 config: ClaudeCodeConfig,
28}
29
30impl ClaudeCodeTool {
31 pub fn new(security: Arc<SecurityPolicy>, config: ClaudeCodeConfig) -> Self {
32 Self { security, config }
33 }
34}
35
36#[async_trait]
37impl Tool for ClaudeCodeTool {
38 fn name(&self) -> &str {
39 "claude_code"
40 }
41
42 fn description(&self) -> &str {
43 "Delegate a coding task to Claude Code (claude -p). Supports file editing, bash execution, structured output, and multi-turn sessions. Use for complex coding work that benefits from Claude Code's full agent loop."
44 }
45
46 fn parameters_schema(&self) -> serde_json::Value {
47 json!({
48 "type": "object",
49 "properties": {
50 "prompt": {
51 "type": "string",
52 "description": "The coding task to delegate to Claude Code"
53 },
54 "allowed_tools": {
55 "type": "array",
56 "items": { "type": "string" },
57 "description": "Override the default tool allowlist (e.g. [\"Read\", \"Edit\", \"Bash\", \"Write\"])"
58 },
59 "system_prompt": {
60 "type": "string",
61 "description": "Override or append a system prompt for this invocation"
62 },
63 "session_id": {
64 "type": "string",
65 "description": "Resume a previous Claude Code session by its ID"
66 },
67 "json_schema": {
68 "type": "object",
69 "description": "Request structured output conforming to this JSON Schema"
70 },
71 "working_directory": {
72 "type": "string",
73 "description": "Working directory within the workspace (must be inside workspace_dir)"
74 }
75 },
76 "required": ["prompt"]
77 })
78 }
79
80 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
81 if let Err(error) = self
86 .security
87 .enforce_tool_operation(ToolOperation::Act, "claude_code")
88 {
89 return Ok(ToolResult {
90 success: false,
91 output: String::new(),
92 error: Some(error),
93 });
94 }
95
96 let prompt = args.get("prompt").and_then(|v| v.as_str()).ok_or_else(|| {
98 ::zeroclaw_log::record!(
99 WARN,
100 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
101 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
102 .with_attrs(::serde_json::json!({"param": "prompt"})),
103 "claude_code: missing prompt parameter"
104 );
105 anyhow::Error::msg("Missing 'prompt' parameter")
106 })?;
107
108 let allowed_tools: Vec<String> = args
110 .get("allowed_tools")
111 .and_then(|v| v.as_array())
112 .map(|arr| {
113 arr.iter()
114 .filter_map(|v| v.as_str().map(String::from))
115 .collect()
116 })
117 .unwrap_or_else(|| self.config.allowed_tools.clone());
118
119 let system_prompt = args
120 .get("system_prompt")
121 .and_then(|v| v.as_str())
122 .map(String::from)
123 .or_else(|| self.config.system_prompt.clone());
124
125 let session_id = args.get("session_id").and_then(|v| v.as_str());
126
127 let json_schema = args.get("json_schema").filter(|v| v.is_object());
128
129 let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
134 let wd_path = std::path::PathBuf::from(wd);
135 let workspace = &self.security.workspace_dir;
136 let canonical_wd = match wd_path.canonicalize() {
137 Ok(p) => p,
138 Err(_) => {
139 return Ok(ToolResult {
140 success: false,
141 output: String::new(),
142 error: Some(format!(
143 "working_directory '{}' does not exist or is not accessible",
144 wd
145 )),
146 });
147 }
148 };
149 let canonical_ws = match workspace.canonicalize() {
150 Ok(p) => p,
151 Err(_) => {
152 return Ok(ToolResult {
153 success: false,
154 output: String::new(),
155 error: Some(format!(
156 "workspace directory '{}' does not exist or is not accessible",
157 workspace.display()
158 )),
159 });
160 }
161 };
162 if !canonical_wd.starts_with(&canonical_ws) {
163 return Ok(ToolResult {
164 success: false,
165 output: String::new(),
166 error: Some(format!(
167 "working_directory '{}' is outside the workspace '{}'",
168 wd,
169 workspace.display()
170 )),
171 });
172 }
173 canonical_wd
174 } else {
175 self.security.workspace_dir.clone()
176 };
177
178 let claude_bin = if cfg!(target_os = "windows") {
180 "claude.cmd"
181 } else {
182 "claude"
183 };
184 let mut cmd = Command::new(claude_bin);
185 cmd.arg("-p").arg(prompt);
186 cmd.arg("--output-format").arg("json");
187
188 if !allowed_tools.is_empty() {
189 for tool in &allowed_tools {
190 cmd.arg("--allowedTools").arg(tool);
191 }
192 }
193
194 if let Some(ref sp) = system_prompt {
195 cmd.arg("--append-system-prompt").arg(sp);
196 }
197
198 if let Some(sid) = session_id {
199 cmd.arg("--resume").arg(sid);
200 }
201
202 if let Some(schema) = json_schema {
203 let schema_str = serde_json::to_string(schema).unwrap_or_else(|_| "{}".to_string());
204 cmd.arg("--json-schema").arg(schema_str);
205 }
206
207 cmd.env_clear();
210 for var in SAFE_ENV_VARS {
211 if let Ok(val) = std::env::var(var) {
212 cmd.env(var, val);
213 }
214 }
215 for var in &self.config.env_passthrough {
216 let trimmed = var.trim();
217 if !trimmed.is_empty()
218 && let Ok(val) = std::env::var(trimmed)
219 {
220 cmd.env(trimmed, val);
221 }
222 }
223
224 cmd.current_dir(&work_dir);
225 let timeout = Duration::from_secs(self.config.timeout_secs);
229 cmd.kill_on_drop(true);
230
231 let result = tokio::time::timeout(timeout, cmd.output()).await;
232
233 match result {
234 Ok(Ok(output)) => {
235 let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
236 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
237
238 if stdout.len() > self.config.max_output_bytes {
240 let mut b = self.config.max_output_bytes.min(stdout.len());
241 while b > 0 && !stdout.is_char_boundary(b) {
242 b -= 1;
243 }
244 stdout.truncate(b);
245 stdout.push_str("\n... [output truncated]");
246 }
247
248 if let Ok(json_resp) = serde_json::from_str::<serde_json::Value>(&stdout) {
250 let result_text = json_resp
251 .get("result")
252 .and_then(|v| v.as_str())
253 .unwrap_or("");
254 let resp_session_id = json_resp
255 .get("session_id")
256 .and_then(|v| v.as_str())
257 .unwrap_or("");
258
259 let mut formatted = String::new();
260 if result_text.is_empty() {
261 formatted.push_str(&stdout);
263 } else {
264 formatted.push_str(result_text);
265 }
266 if !resp_session_id.is_empty() {
267 use std::fmt::Write;
268 let _ = write!(formatted, "\n\n[session_id: {}]", resp_session_id);
269 }
270
271 Ok(ToolResult {
272 success: output.status.success(),
273 output: formatted,
274 error: if stderr.is_empty() {
275 None
276 } else {
277 Some(stderr)
278 },
279 })
280 } else {
281 Ok(ToolResult {
283 success: output.status.success(),
284 output: stdout,
285 error: if stderr.is_empty() {
286 None
287 } else {
288 Some(stderr)
289 },
290 })
291 }
292 }
293 Ok(Err(e)) => {
294 let err_msg = e.to_string();
295 let msg = if err_msg.contains("No such file or directory")
296 || err_msg.contains("not found")
297 || err_msg.contains("cannot find")
298 {
299 "Claude Code CLI ('claude') not found in PATH. Install with: npm install -g @anthropic-ai/claude-code".into()
300 } else {
301 format!("Failed to execute claude: {e}")
302 };
303 Ok(ToolResult {
304 success: false,
305 output: String::new(),
306 error: Some(msg),
307 })
308 }
309 Err(_) => {
310 Ok(ToolResult {
313 success: false,
314 output: String::new(),
315 error: Some(format!(
316 "Claude Code timed out after {}s and was killed",
317 self.config.timeout_secs
318 )),
319 })
320 }
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use zeroclaw_config::autonomy::AutonomyLevel;
329 use zeroclaw_config::policy::SecurityPolicy;
330 use zeroclaw_config::schema::ClaudeCodeConfig;
331
332 fn test_config() -> ClaudeCodeConfig {
333 ClaudeCodeConfig::default()
334 }
335
336 fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
337 Arc::new(SecurityPolicy {
338 autonomy,
339 workspace_dir: std::env::temp_dir(),
340 ..SecurityPolicy::default()
341 })
342 }
343
344 #[test]
345 fn claude_code_tool_name() {
346 let tool = ClaudeCodeTool::new(test_security(AutonomyLevel::Supervised), test_config());
347 assert_eq!(tool.name(), "claude_code");
348 }
349
350 #[test]
351 fn claude_code_tool_schema_has_prompt() {
352 let tool = ClaudeCodeTool::new(test_security(AutonomyLevel::Supervised), test_config());
353 let schema = tool.parameters_schema();
354 assert!(schema["properties"]["prompt"].is_object());
355 assert!(
356 schema["required"]
357 .as_array()
358 .expect("schema required should be an array")
359 .contains(&json!("prompt"))
360 );
361 assert!(schema["properties"]["allowed_tools"].is_object());
363 assert!(schema["properties"]["system_prompt"].is_object());
364 assert!(schema["properties"]["session_id"].is_object());
365 assert!(schema["properties"]["json_schema"].is_object());
366 assert!(schema["properties"]["working_directory"].is_object());
367 }
368
369 #[tokio::test]
370 async fn claude_code_blocks_rate_limited() {
371 let security = Arc::new(SecurityPolicy {
372 autonomy: AutonomyLevel::Supervised,
373 max_actions_per_hour: 0,
374 workspace_dir: std::env::temp_dir(),
375 ..SecurityPolicy::default()
376 });
377 let tool = ClaudeCodeTool::new(security, test_config());
378 let result = tool
379 .execute(json!({"prompt": "hello"}))
380 .await
381 .expect("rate-limited should return a result");
382 assert!(!result.success);
383 assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
384 }
385
386 #[tokio::test]
387 async fn claude_code_blocks_readonly() {
388 let tool = ClaudeCodeTool::new(test_security(AutonomyLevel::ReadOnly), test_config());
389 let result = tool
390 .execute(json!({"prompt": "hello"}))
391 .await
392 .expect("readonly should return a result");
393 assert!(!result.success);
394 assert!(
395 result
396 .error
397 .as_deref()
398 .unwrap_or("")
399 .contains("read-only mode")
400 );
401 }
402
403 #[tokio::test]
404 async fn claude_code_missing_prompt_param() {
405 let tool = ClaudeCodeTool::new(test_security(AutonomyLevel::Supervised), test_config());
406 let result = tool.execute(json!({})).await;
407 assert!(result.is_err());
408 assert!(result.unwrap_err().to_string().contains("prompt"));
409 }
410
411 #[tokio::test]
412 async fn claude_code_rejects_path_outside_workspace() {
413 let tool = ClaudeCodeTool::new(test_security(AutonomyLevel::Full), test_config());
414 let result = tool
415 .execute(json!({
416 "prompt": "hello",
417 "working_directory": "/etc"
418 }))
419 .await
420 .expect("should return a result for path validation");
421 assert!(!result.success);
422 assert!(
423 result
424 .error
425 .as_deref()
426 .unwrap_or("")
427 .contains("outside the workspace")
428 );
429 }
430
431 #[test]
432 fn claude_code_env_passthrough_defaults() {
433 let config = ClaudeCodeConfig::default();
434 assert!(
435 config.env_passthrough.is_empty(),
436 "env_passthrough should default to empty (Max subscription needs no API key)"
437 );
438 }
439
440 #[test]
441 fn claude_code_default_config_values() {
442 let config = ClaudeCodeConfig::default();
443 assert!(!config.enabled);
444 assert_eq!(config.timeout_secs, 600);
445 assert_eq!(config.max_output_bytes, 2_097_152);
446 assert!(config.system_prompt.is_none());
447 assert_eq!(config.allowed_tools, vec!["Read", "Edit", "Bash", "Write"]);
448 }
449}