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
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 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 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 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 let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
188 let session_name = self.session_name(&session_id);
189
190 let hook_url = format!("{}/hooks/claude-code", self.gateway_url);
192
193 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 claude_args.push("--hook-url".to_string());
206 claude_args.push(hook_url.clone());
207
208 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 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 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 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 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 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 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
326fn 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}