Skip to main content

construct/tools/
claude_code_runner.rs

1use super::traits::{Tool, ToolResult};
2use crate::config::ClaudeCodeRunnerConfig;
3use crate::security::SecurityPolicy;
4use crate::security::policy::ToolOperation;
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use serde_json::json;
8use std::sync::Arc;
9use tokio::process::Command;
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 Construct'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 Construct 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 limit check
109        if self.security.is_rate_limited() {
110            return Ok(ToolResult {
111                success: false,
112                output: String::new(),
113                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
114            });
115        }
116
117        // Enforce act policy
118        if let Err(error) = self
119            .security
120            .enforce_tool_operation(ToolOperation::Act, "claude_code_runner")
121        {
122            return Ok(ToolResult {
123                success: false,
124                output: String::new(),
125                error: Some(error),
126            });
127        }
128
129        // Extract prompt (required)
130        let prompt = args
131            .get("prompt")
132            .and_then(|v| v.as_str())
133            .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
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        // Record action budget
187        if !self.security.record_action() {
188            return Ok(ToolResult {
189                success: false,
190                output: String::new(),
191                error: Some("Rate limit exceeded: action budget exhausted".into()),
192            });
193        }
194
195        // Generate a unique session ID
196        let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
197        let session_name = self.session_name(&session_id);
198
199        // Build the hook URL for Claude Code to POST events to
200        let hook_url = format!("{}/hooks/claude-code", self.gateway_url);
201
202        // Build the claude command that will run inside tmux
203        let mut claude_args = vec![
204            "claude".to_string(),
205            "-p".to_string(),
206            prompt.to_string(),
207            "--output-format".to_string(),
208            "json".to_string(),
209        ];
210
211        // Pass hook URL via environment variable (Claude Code uses
212        // CLAUDE_CODE_HOOK_URL when --hook-url is not available).
213        // We also append --hook-url for newer CLI versions.
214        claude_args.push("--hook-url".to_string());
215        claude_args.push(hook_url.clone());
216
217        // Build env string for tmux send-keys
218        let mut env_exports = String::new();
219        for var in SAFE_ENV_VARS {
220            if let Ok(val) = std::env::var(var) {
221                use std::fmt::Write;
222                let _ = write!(env_exports, "{}={} ", var, shell_escape(&val));
223            }
224        }
225        // Pass session metadata via env vars so the hook can correlate events
226        use std::fmt::Write;
227        let _ = write!(env_exports, "CLAUDE_CODE_SESSION_ID={} ", &session_id);
228        if let Some(ref ch) = slack_channel {
229            let _ = write!(env_exports, "CLAUDE_CODE_SLACK_CHANNEL={} ", ch);
230        }
231        let _ = write!(env_exports, "CLAUDE_CODE_HOOK_URL={} ", &hook_url);
232
233        // Create tmux session
234        let create_result = Command::new("tmux")
235            .args(["new-session", "-d", "-s", &session_name])
236            .arg("-c")
237            .arg(work_dir.to_str().unwrap_or("."))
238            .output()
239            .await;
240
241        match create_result {
242            Ok(output) if !output.status.success() => {
243                let stderr = String::from_utf8_lossy(&output.stderr);
244                return Ok(ToolResult {
245                    success: false,
246                    output: String::new(),
247                    error: Some(format!("Failed to create tmux session: {stderr}")),
248                });
249            }
250            Err(e) => {
251                return Ok(ToolResult {
252                    success: false,
253                    output: String::new(),
254                    error: Some(format!(
255                        "tmux not found or failed to execute: {e}. Install tmux to use claude_code_runner."
256                    )),
257                });
258            }
259            _ => {}
260        }
261
262        // Send the claude command into the tmux session
263        let full_command = format!(
264            "{env_exports}{cmd}",
265            env_exports = env_exports,
266            cmd = claude_args
267                .iter()
268                .map(|a| shell_escape(a))
269                .collect::<Vec<_>>()
270                .join(" ")
271        );
272
273        let send_result = Command::new("tmux")
274            .args(["send-keys", "-t", &session_name, &full_command, "Enter"])
275            .output()
276            .await;
277
278        if let Err(e) = send_result {
279            // Clean up the session we just created
280            let _ = Command::new("tmux")
281                .args(["kill-session", "-t", &session_name])
282                .output()
283                .await;
284            return Ok(ToolResult {
285                success: false,
286                output: String::new(),
287                error: Some(format!("Failed to send command to tmux session: {e}")),
288            });
289        }
290
291        // Schedule session TTL cleanup
292        let ttl = self.config.session_ttl;
293        let cleanup_session = session_name.clone();
294        tokio::spawn(async move {
295            tokio::time::sleep(std::time::Duration::from_secs(ttl)).await;
296            let _ = Command::new("tmux")
297                .args(["kill-session", "-t", &cleanup_session])
298                .output()
299                .await;
300            tracing::info!(
301                session = cleanup_session,
302                "Claude Code runner session TTL expired, cleaned up"
303            );
304        });
305
306        // Build response
307        let mut output_parts = vec![
308            format!("Session started: {session_name}"),
309            format!("Session ID: {session_id}"),
310            format!("Hook URL: {hook_url}"),
311        ];
312
313        if let Some(ssh_cmd) = self.ssh_attach_command(&session_name) {
314            output_parts.push(format!("SSH attach: {ssh_cmd}"));
315        } else {
316            output_parts.push(format!(
317                "Local attach: tmux attach-session -t {session_name}"
318            ));
319        }
320
321        if let Some(ref ch) = slack_channel {
322            output_parts.push(format!("Slack channel: {ch} (progress updates enabled)"));
323        }
324
325        Ok(ToolResult {
326            success: true,
327            output: output_parts.join("\n"),
328            error: None,
329        })
330    }
331}
332
333/// Minimal shell escaping for values embedded in tmux send-keys.
334fn shell_escape(s: &str) -> String {
335    if s.chars()
336        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+'))
337    {
338        s.to_string()
339    } else {
340        format!("'{}'", s.replace('\'', "'\\''"))
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::config::ClaudeCodeRunnerConfig;
348    use crate::security::{AutonomyLevel, SecurityPolicy};
349
350    fn test_config() -> ClaudeCodeRunnerConfig {
351        ClaudeCodeRunnerConfig {
352            enabled: true,
353            ssh_host: Some("dev.example.com".into()),
354            tmux_prefix: "zc-test-".into(),
355            session_ttl: 3600,
356        }
357    }
358
359    fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
360        Arc::new(SecurityPolicy {
361            autonomy,
362            workspace_dir: std::env::temp_dir(),
363            ..SecurityPolicy::default()
364        })
365    }
366
367    #[test]
368    fn tool_name() {
369        let tool = ClaudeCodeRunnerTool::new(
370            test_security(AutonomyLevel::Supervised),
371            test_config(),
372            "http://localhost:3000".into(),
373        );
374        assert_eq!(tool.name(), "claude_code_runner");
375    }
376
377    #[test]
378    fn tool_schema_has_prompt() {
379        let tool = ClaudeCodeRunnerTool::new(
380            test_security(AutonomyLevel::Supervised),
381            test_config(),
382            "http://localhost:3000".into(),
383        );
384        let schema = tool.parameters_schema();
385        assert!(schema["properties"]["prompt"].is_object());
386        assert!(
387            schema["required"]
388                .as_array()
389                .expect("required should be an array")
390                .contains(&json!("prompt"))
391        );
392    }
393
394    #[test]
395    fn session_name_uses_prefix() {
396        let tool = ClaudeCodeRunnerTool::new(
397            test_security(AutonomyLevel::Supervised),
398            test_config(),
399            "http://localhost:3000".into(),
400        );
401        let name = tool.session_name("abc123");
402        assert_eq!(name, "zc-test-abc123");
403    }
404
405    #[test]
406    fn ssh_attach_command_with_host() {
407        let tool = ClaudeCodeRunnerTool::new(
408            test_security(AutonomyLevel::Supervised),
409            test_config(),
410            "http://localhost:3000".into(),
411        );
412        let cmd = tool.ssh_attach_command("zc-test-abc123");
413        assert_eq!(
414            cmd.as_deref(),
415            Some("ssh -t dev.example.com tmux attach-session -t zc-test-abc123")
416        );
417    }
418
419    #[test]
420    fn ssh_attach_command_without_host() {
421        let mut config = test_config();
422        config.ssh_host = None;
423        let tool = ClaudeCodeRunnerTool::new(
424            test_security(AutonomyLevel::Supervised),
425            config,
426            "http://localhost:3000".into(),
427        );
428        assert!(tool.ssh_attach_command("session").is_none());
429    }
430
431    #[tokio::test]
432    async fn blocks_rate_limited() {
433        let security = Arc::new(SecurityPolicy {
434            autonomy: AutonomyLevel::Supervised,
435            max_actions_per_hour: 0,
436            workspace_dir: std::env::temp_dir(),
437            ..SecurityPolicy::default()
438        });
439        let tool =
440            ClaudeCodeRunnerTool::new(security, test_config(), "http://localhost:3000".into());
441        let result = tool
442            .execute(json!({"prompt": "hello"}))
443            .await
444            .expect("rate-limited should return a result");
445        assert!(!result.success);
446        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
447    }
448
449    #[tokio::test]
450    async fn blocks_readonly() {
451        let tool = ClaudeCodeRunnerTool::new(
452            test_security(AutonomyLevel::ReadOnly),
453            test_config(),
454            "http://localhost:3000".into(),
455        );
456        let result = tool
457            .execute(json!({"prompt": "hello"}))
458            .await
459            .expect("readonly should return a result");
460        assert!(!result.success);
461        assert!(
462            result
463                .error
464                .as_deref()
465                .unwrap_or("")
466                .contains("read-only mode")
467        );
468    }
469
470    #[tokio::test]
471    async fn missing_prompt() {
472        let tool = ClaudeCodeRunnerTool::new(
473            test_security(AutonomyLevel::Supervised),
474            test_config(),
475            "http://localhost:3000".into(),
476        );
477        let result = tool.execute(json!({})).await;
478        assert!(result.is_err());
479        assert!(result.unwrap_err().to_string().contains("prompt"));
480    }
481
482    #[tokio::test]
483    async fn rejects_path_outside_workspace() {
484        let tool = ClaudeCodeRunnerTool::new(
485            test_security(AutonomyLevel::Full),
486            test_config(),
487            "http://localhost:3000".into(),
488        );
489        let result = tool
490            .execute(json!({
491                "prompt": "hello",
492                "working_directory": "/etc"
493            }))
494            .await
495            .expect("should return a result for path validation");
496        assert!(!result.success);
497        assert!(
498            result
499                .error
500                .as_deref()
501                .unwrap_or("")
502                .contains("outside the workspace")
503        );
504    }
505
506    #[test]
507    fn shell_escape_simple() {
508        assert_eq!(shell_escape("hello"), "hello");
509        assert_eq!(shell_escape("hello world"), "'hello world'");
510        assert_eq!(shell_escape("it's"), "'it'\\''s'");
511    }
512
513    #[test]
514    fn hook_event_deserialization() {
515        let json = r#"{
516            "session_id": "abc123",
517            "event_type": "tool_use",
518            "tool_name": "Edit",
519            "summary": "Editing file.rs"
520        }"#;
521        let event: ClaudeCodeHookEvent = serde_json::from_str(json).unwrap();
522        assert_eq!(event.session_id, "abc123");
523        assert_eq!(event.event_type, "tool_use");
524        assert_eq!(event.tool_name.as_deref(), Some("Edit"));
525    }
526}