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
11const SAFE_ENV_VARS: &[&str] = &[
13 "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR",
14];
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ClaudeCodeHookEvent {
19 pub session_id: String,
21 pub event_type: String,
23 #[serde(default)]
25 pub tool_name: Option<String>,
26 #[serde(default)]
28 pub summary: Option<String>,
29}
30
31pub struct ClaudeCodeRunnerTool {
43 security: Arc<SecurityPolicy>,
44 config: ClaudeCodeRunnerConfig,
45 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 fn session_name(&self, id: &str) -> String {
64 format!("{}{}", self.config.tmux_prefix, id)
65 }
66
67 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 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 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 let prompt = args
131 .get("prompt")
132 .and_then(|v| v.as_str())
133 .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
134
135 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 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 let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
197 let session_name = self.session_name(&session_id);
198
199 let hook_url = format!("{}/hooks/claude-code", self.gateway_url);
201
202 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 claude_args.push("--hook-url".to_string());
215 claude_args.push(hook_url.clone());
216
217 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 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 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 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 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 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 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
333fn 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}