Skip to main content

zeroclaw_tools/
claude_code.rs

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
11/// Environment variables safe to pass through to the `claude` subprocess.
12const SAFE_ENV_VARS: &[&str] = &[
13    "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16/// Delegates coding tasks to the Claude Code CLI (`claude -p`).
17///
18/// This creates a two-tier agent architecture: ZeroClaw orchestrates high-level
19/// tasks and delegates complex coding work to Claude Code, which has its own
20/// agent loop with Read/Edit/Bash tools.
21///
22/// Authentication uses the `claude` binary's own OAuth session (Max subscription)
23/// by default. No API key is needed unless `env_passthrough` includes
24/// `ANTHROPIC_API_KEY` for API-key billing.
25pub 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        // Rate limiting is applied by the RateLimitedTool wrapper at
82        // registration time (see zeroclaw-runtime::tools::mod).
83
84        // Enforce act policy
85        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        // Extract prompt (required)
97        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        // Extract optional params
109        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        // Validate working directory — require both paths to exist (reject
130        // non-existent paths instead of falling back to the raw value, which
131        // could bypass the workspace containment check via symlinks or
132        // specially-crafted path components).
133        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        // Build CLI command
179        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        // Environment: clear everything, pass only safe vars + configured passthrough.
208        // HOME is critical so `claude` finds its OAuth session in ~/.claude/
209        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        // Execute with timeout — use kill_on_drop(true) so the child process
226        // is automatically killed when the future is dropped on timeout,
227        // preventing zombie processes.
228        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                // Truncate to max_output_bytes with char-boundary safety
239                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                // Try to parse JSON response and extract result + session_id
249                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                        // Fall back to full JSON if no "result" key
262                        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                    // JSON parse failed — return raw stdout (defensive)
282                    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                // Timeout — kill_on_drop(true) ensures the child is killed
311                // when the future is dropped.
312                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        // Optional params exist in properties
362        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}