Skip to main content

zeroclaw_tools/
claude_code_runner.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4use std::sync::Arc;
5use tokio::process::Command;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::policy::ToolOperation;
9use zeroclaw_config::schema::ClaudeCodeRunnerConfig;
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/// Event payload received from Claude Code HTTP hooks.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ClaudeCodeHookEvent {
19    /// The session identifier (matches the tmux session name suffix).
20    pub session_id: String,
21    /// Event type from Claude Code (e.g. "tool_use", "tool_result", "completion").
22    pub event_type: String,
23    /// Tool name when event_type is "tool_use" or "tool_result".
24    #[serde(default)]
25    pub tool_name: Option<String>,
26    /// Human-readable summary of what happened.
27    #[serde(default)]
28    pub summary: Option<String>,
29}
30
31/// Spawns Claude Code inside a tmux session with HTTP hooks that POST tool
32/// execution events back to ZeroClaw's gateway endpoint, enabling live Slack
33/// progress updates and SSH session handoff.
34///
35/// Unlike [`ClaudeCodeTool`](super::claude_code::ClaudeCodeTool) which runs
36/// `claude -p` inline and waits for completion, this runner:
37///
38/// 1. Creates a named tmux session (`<prefix><id>`)
39/// 2. Launches `claude` inside it with `--hook-url` pointing at the gateway
40/// 3. Returns immediately with the session ID and an SSH attach command
41/// 4. Receives streamed progress via the `/hooks/claude-code` endpoint
42pub struct ClaudeCodeRunnerTool {
43    security: Arc<SecurityPolicy>,
44    config: ClaudeCodeRunnerConfig,
45    /// Base URL of the ZeroClaw gateway (e.g. `"http://localhost:3000"`).
46    gateway_url: String,
47}
48
49impl ClaudeCodeRunnerTool {
50    pub fn new(
51        security: Arc<SecurityPolicy>,
52        config: ClaudeCodeRunnerConfig,
53        gateway_url: String,
54    ) -> Self {
55        Self {
56            security,
57            config,
58            gateway_url,
59        }
60    }
61
62    /// Build the tmux session name from the configured prefix and a unique id.
63    fn session_name(&self, id: &str) -> String {
64        format!("{}{}", self.config.tmux_prefix, id)
65    }
66
67    /// Build the SSH attach command for session handoff.
68    fn ssh_attach_command(&self, session_name: &str) -> Option<String> {
69        self.config
70            .ssh_host
71            .as_ref()
72            .map(|host| format!("ssh -t {host} tmux attach-session -t {session_name}"))
73    }
74}
75
76#[async_trait]
77impl Tool for ClaudeCodeRunnerTool {
78    fn name(&self) -> &str {
79        "claude_code_runner"
80    }
81
82    fn description(&self) -> &str {
83        "Spawn a Claude Code task in a tmux session with live Slack progress updates and SSH handoff. Returns immediately with session ID and attach command."
84    }
85
86    fn parameters_schema(&self) -> serde_json::Value {
87        json!({
88            "type": "object",
89            "properties": {
90                "prompt": {
91                    "type": "string",
92                    "description": "The coding task to delegate to Claude Code"
93                },
94                "working_directory": {
95                    "type": "string",
96                    "description": "Working directory within the workspace (must be inside workspace_dir)"
97                },
98                "slack_channel": {
99                    "type": "string",
100                    "description": "Slack channel ID to post progress updates to"
101                }
102            },
103            "required": ["prompt"]
104        })
105    }
106
107    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
108        // Rate limiting is applied by the RateLimitedTool wrapper at
109        // registration time (see zeroclaw-runtime::tools::mod).
110
111        // Enforce act policy
112        if let Err(error) = self
113            .security
114            .enforce_tool_operation(ToolOperation::Act, "claude_code_runner")
115        {
116            return Ok(ToolResult {
117                success: false,
118                output: String::new(),
119                error: Some(error),
120            });
121        }
122
123        // Extract prompt (required)
124        let prompt = args.get("prompt").and_then(|v| v.as_str()).ok_or_else(|| {
125            ::zeroclaw_log::record!(
126                WARN,
127                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
128                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
129                    .with_attrs(::serde_json::json!({"param": "prompt"})),
130                "claude_code_runner: missing prompt parameter"
131            );
132            anyhow::Error::msg("Missing 'prompt' parameter")
133        })?;
134
135        // Validate working directory
136        let work_dir = if let Some(wd) = args.get("working_directory").and_then(|v| v.as_str()) {
137            let wd_path = std::path::PathBuf::from(wd);
138            let workspace = &self.security.workspace_dir;
139            let canonical_wd = match wd_path.canonicalize() {
140                Ok(p) => p,
141                Err(_) => {
142                    return Ok(ToolResult {
143                        success: false,
144                        output: String::new(),
145                        error: Some(format!(
146                            "working_directory '{}' does not exist or is not accessible",
147                            wd
148                        )),
149                    });
150                }
151            };
152            let canonical_ws = match workspace.canonicalize() {
153                Ok(p) => p,
154                Err(_) => {
155                    return Ok(ToolResult {
156                        success: false,
157                        output: String::new(),
158                        error: Some(format!(
159                            "workspace directory '{}' does not exist or is not accessible",
160                            workspace.display()
161                        )),
162                    });
163                }
164            };
165            if !canonical_wd.starts_with(&canonical_ws) {
166                return Ok(ToolResult {
167                    success: false,
168                    output: String::new(),
169                    error: Some(format!(
170                        "working_directory '{}' is outside the workspace '{}'",
171                        wd,
172                        workspace.display()
173                    )),
174                });
175            }
176            canonical_wd
177        } else {
178            self.security.workspace_dir.clone()
179        };
180
181        let slack_channel = args
182            .get("slack_channel")
183            .and_then(|v| v.as_str())
184            .map(String::from);
185
186        // Generate a unique session ID
187        let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
188        let session_name = self.session_name(&session_id);
189
190        // Build the hook URL for Claude Code to POST events to
191        let hook_url = format!("{}/hooks/claude-code", self.gateway_url);
192
193        // Build the claude command that will run inside tmux
194        let mut claude_args = vec![
195            "claude".to_string(),
196            "-p".to_string(),
197            prompt.to_string(),
198            "--output-format".to_string(),
199            "json".to_string(),
200        ];
201
202        // Pass hook URL via environment variable (Claude Code uses
203        // CLAUDE_CODE_HOOK_URL when --hook-url is not available).
204        // We also append --hook-url for newer CLI versions.
205        claude_args.push("--hook-url".to_string());
206        claude_args.push(hook_url.clone());
207
208        // Build env string for tmux send-keys
209        let mut env_exports = String::new();
210        for var in SAFE_ENV_VARS {
211            if let Ok(val) = std::env::var(var) {
212                use std::fmt::Write;
213                let _ = write!(env_exports, "{}={} ", var, shell_escape(&val));
214            }
215        }
216        // Pass session metadata via env vars so the hook can correlate events
217        use std::fmt::Write;
218        let _ = write!(env_exports, "CLAUDE_CODE_SESSION_ID={} ", &session_id);
219        if let Some(ref ch) = slack_channel {
220            let _ = write!(env_exports, "CLAUDE_CODE_SLACK_CHANNEL={} ", ch);
221        }
222        let _ = write!(env_exports, "CLAUDE_CODE_HOOK_URL={} ", &hook_url);
223
224        // Create tmux session
225        let create_result = Command::new("tmux")
226            .args(["new-session", "-d", "-s", &session_name])
227            .arg("-c")
228            .arg(work_dir.to_str().unwrap_or("."))
229            .output()
230            .await;
231
232        match create_result {
233            Ok(output) if !output.status.success() => {
234                let stderr = String::from_utf8_lossy(&output.stderr);
235                return Ok(ToolResult {
236                    success: false,
237                    output: String::new(),
238                    error: Some(format!("Failed to create tmux session: {stderr}")),
239                });
240            }
241            Err(e) => {
242                return Ok(ToolResult {
243                    success: false,
244                    output: String::new(),
245                    error: Some(format!(
246                        "tmux not found or failed to execute: {e}. Install tmux to use claude_code_runner."
247                    )),
248                });
249            }
250            _ => {}
251        }
252
253        // Send the claude command into the tmux session
254        let full_command = format!(
255            "{env_exports}{cmd}",
256            env_exports = env_exports,
257            cmd = claude_args
258                .iter()
259                .map(|a| shell_escape(a))
260                .collect::<Vec<_>>()
261                .join(" ")
262        );
263
264        let send_result = Command::new("tmux")
265            .args(["send-keys", "-t", &session_name, &full_command, "Enter"])
266            .output()
267            .await;
268
269        if let Err(e) = send_result {
270            // Clean up the session we just created
271            let _ = Command::new("tmux")
272                .args(["kill-session", "-t", &session_name])
273                .output()
274                .await;
275            return Ok(ToolResult {
276                success: false,
277                output: String::new(),
278                error: Some(format!("Failed to send command to tmux session: {e}")),
279            });
280        }
281
282        // Schedule session TTL cleanup
283        let ttl = self.config.session_ttl;
284        let cleanup_session = session_name.clone();
285        tokio::spawn(async move {
286            tokio::time::sleep(std::time::Duration::from_secs(ttl)).await;
287            let _ = Command::new("tmux")
288                .args(["kill-session", "-t", &cleanup_session])
289                .output()
290                .await;
291            ::zeroclaw_log::record!(
292                INFO,
293                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
294                    .with_attrs(::serde_json::json!({"session": cleanup_session})),
295                "Claude Code runner session TTL expired, cleaned up"
296            );
297        });
298
299        // Build response
300        let mut output_parts = vec![
301            format!("Session started: {session_name}"),
302            format!("Session ID: {session_id}"),
303            format!("Hook URL: {hook_url}"),
304        ];
305
306        if let Some(ssh_cmd) = self.ssh_attach_command(&session_name) {
307            output_parts.push(format!("SSH attach: {ssh_cmd}"));
308        } else {
309            output_parts.push(format!(
310                "Local attach: tmux attach-session -t {session_name}"
311            ));
312        }
313
314        if let Some(ref ch) = slack_channel {
315            output_parts.push(format!("Slack channel: {ch} (progress updates enabled)"));
316        }
317
318        Ok(ToolResult {
319            success: true,
320            output: output_parts.join("\n"),
321            error: None,
322        })
323    }
324}
325
326/// Minimal shell escaping for values embedded in tmux send-keys.
327fn shell_escape(s: &str) -> String {
328    if s.chars()
329        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+'))
330    {
331        s.to_string()
332    } else {
333        format!("'{}'", s.replace('\'', "'\\''"))
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use zeroclaw_config::autonomy::AutonomyLevel;
341    use zeroclaw_config::policy::SecurityPolicy;
342    use zeroclaw_config::schema::ClaudeCodeRunnerConfig;
343
344    fn test_config() -> ClaudeCodeRunnerConfig {
345        ClaudeCodeRunnerConfig {
346            enabled: true,
347            ssh_host: Some("dev.example.com".into()),
348            tmux_prefix: "zc-test-".into(),
349            session_ttl: 3600,
350        }
351    }
352
353    fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
354        Arc::new(SecurityPolicy {
355            autonomy,
356            workspace_dir: std::env::temp_dir(),
357            ..SecurityPolicy::default()
358        })
359    }
360
361    #[test]
362    fn tool_name() {
363        let tool = ClaudeCodeRunnerTool::new(
364            test_security(AutonomyLevel::Supervised),
365            test_config(),
366            "http://localhost:3000".into(),
367        );
368        assert_eq!(tool.name(), "claude_code_runner");
369    }
370
371    #[test]
372    fn tool_schema_has_prompt() {
373        let tool = ClaudeCodeRunnerTool::new(
374            test_security(AutonomyLevel::Supervised),
375            test_config(),
376            "http://localhost:3000".into(),
377        );
378        let schema = tool.parameters_schema();
379        assert!(schema["properties"]["prompt"].is_object());
380        assert!(
381            schema["required"]
382                .as_array()
383                .expect("required should be an array")
384                .contains(&json!("prompt"))
385        );
386    }
387
388    #[test]
389    fn session_name_uses_prefix() {
390        let tool = ClaudeCodeRunnerTool::new(
391            test_security(AutonomyLevel::Supervised),
392            test_config(),
393            "http://localhost:3000".into(),
394        );
395        let name = tool.session_name("abc123");
396        assert_eq!(name, "zc-test-abc123");
397    }
398
399    #[test]
400    fn ssh_attach_command_with_host() {
401        let tool = ClaudeCodeRunnerTool::new(
402            test_security(AutonomyLevel::Supervised),
403            test_config(),
404            "http://localhost:3000".into(),
405        );
406        let cmd = tool.ssh_attach_command("zc-test-abc123");
407        assert_eq!(
408            cmd.as_deref(),
409            Some("ssh -t dev.example.com tmux attach-session -t zc-test-abc123")
410        );
411    }
412
413    #[test]
414    fn ssh_attach_command_without_host() {
415        let mut config = test_config();
416        config.ssh_host = None;
417        let tool = ClaudeCodeRunnerTool::new(
418            test_security(AutonomyLevel::Supervised),
419            config,
420            "http://localhost:3000".into(),
421        );
422        assert!(tool.ssh_attach_command("session").is_none());
423    }
424
425    #[tokio::test]
426    async fn blocks_rate_limited() {
427        let security = Arc::new(SecurityPolicy {
428            autonomy: AutonomyLevel::Supervised,
429            max_actions_per_hour: 0,
430            workspace_dir: std::env::temp_dir(),
431            ..SecurityPolicy::default()
432        });
433        let tool =
434            ClaudeCodeRunnerTool::new(security, test_config(), "http://localhost:3000".into());
435        let result = tool
436            .execute(json!({"prompt": "hello"}))
437            .await
438            .expect("rate-limited should return a result");
439        assert!(!result.success);
440        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
441    }
442
443    #[tokio::test]
444    async fn blocks_readonly() {
445        let tool = ClaudeCodeRunnerTool::new(
446            test_security(AutonomyLevel::ReadOnly),
447            test_config(),
448            "http://localhost:3000".into(),
449        );
450        let result = tool
451            .execute(json!({"prompt": "hello"}))
452            .await
453            .expect("readonly should return a result");
454        assert!(!result.success);
455        assert!(
456            result
457                .error
458                .as_deref()
459                .unwrap_or("")
460                .contains("read-only mode")
461        );
462    }
463
464    #[tokio::test]
465    async fn missing_prompt() {
466        let tool = ClaudeCodeRunnerTool::new(
467            test_security(AutonomyLevel::Supervised),
468            test_config(),
469            "http://localhost:3000".into(),
470        );
471        let result = tool.execute(json!({})).await;
472        assert!(result.is_err());
473        assert!(result.unwrap_err().to_string().contains("prompt"));
474    }
475
476    #[tokio::test]
477    async fn rejects_path_outside_workspace() {
478        let tool = ClaudeCodeRunnerTool::new(
479            test_security(AutonomyLevel::Full),
480            test_config(),
481            "http://localhost:3000".into(),
482        );
483        let result = tool
484            .execute(json!({
485                "prompt": "hello",
486                "working_directory": "/etc"
487            }))
488            .await
489            .expect("should return a result for path validation");
490        assert!(!result.success);
491        assert!(
492            result
493                .error
494                .as_deref()
495                .unwrap_or("")
496                .contains("outside the workspace")
497        );
498    }
499
500    #[test]
501    fn shell_escape_simple() {
502        assert_eq!(shell_escape("hello"), "hello");
503        assert_eq!(shell_escape("hello world"), "'hello world'");
504        assert_eq!(shell_escape("it's"), "'it'\\''s'");
505    }
506
507    #[test]
508    fn hook_event_deserialization() {
509        let json = r#"{
510            "session_id": "abc123",
511            "event_type": "tool_use",
512            "tool_name": "Edit",
513            "summary": "Editing file.rs"
514        }"#;
515        let event: ClaudeCodeHookEvent = serde_json::from_str(json).unwrap();
516        assert_eq!(event.session_id, "abc123");
517        assert_eq!(event.event_type, "tool_use");
518        assert_eq!(event.tool_name.as_deref(), Some("Edit"));
519    }
520}