Skip to main content

atomcode_core/hook/
executor.rs

1use std::time::Duration;
2
3use tokio::io::AsyncWriteExt;
4use tokio::process::Command;
5
6use super::config::matching_hooks;
7use super::{
8    HookConfig, HookContext, HookEvent, PreHookResult, UserPromptHookResult,
9    UserPromptSubmitOutput, UserPromptSubmitPayload,
10};
11
12/// Helper: append `extra` to `acc`, separated by a blank line. Keeps
13/// hook-injected context blocks visually distinct when multiple hooks
14/// each contribute a chunk.
15fn push_context(acc: &mut String, extra: &str) {
16    if !acc.is_empty() {
17        acc.push_str("\n\n");
18    }
19    acc.push_str(extra);
20}
21
22/// Executes hook commands in response to agent lifecycle events.
23pub struct HookExecutor {
24    hooks: Vec<HookConfig>,
25}
26
27impl HookExecutor {
28    /// Create an executor with the given hook configurations.
29    pub fn new(hooks: Vec<HookConfig>) -> Self {
30        Self { hooks }
31    }
32
33    /// Create an executor with no hooks (a no-op executor).
34    pub fn empty() -> Self {
35        Self { hooks: vec![] }
36    }
37
38    /// Whether any hooks are configured.
39    pub fn has_hooks(&self) -> bool {
40        !self.hooks.is_empty()
41    }
42
43    /// Run all matching `PreToolUse` hooks and return the aggregate result.
44    ///
45    /// If any hook returns `Block`, the overall result is `Block`.
46    /// If any hook returns `Modify`, the last `Modify` wins.
47    /// If a hook times out, crashes, or produces non-JSON output, it degrades
48    /// to `Allow` (the tool call is not disrupted).
49    pub async fn run_pre_tool_use(
50        &self,
51        tool_name: &str,
52        ctx: &HookContext,
53    ) -> PreHookResult {
54        let matched = matching_hooks(&self.hooks, HookEvent::PreToolUse, Some(tool_name));
55        if matched.is_empty() {
56            return PreHookResult::Allow;
57        }
58
59        let mut result = PreHookResult::Allow;
60
61        for hook in matched {
62            match self.execute_hook(hook, ctx).await {
63                Ok(stdout) => {
64                    match serde_json::from_str::<PreHookResult>(&stdout) {
65                        Ok(parsed) => match &parsed {
66                            PreHookResult::Block { .. } => return parsed,
67                            PreHookResult::Modify { .. } => result = parsed,
68                            PreHookResult::Allow => {}
69                        },
70                        // Non-JSON output degrades to Allow.
71                        Err(_) => {}
72                    }
73                }
74                // Timeout or crash degrades to Allow.
75                Err(_) => {}
76            }
77        }
78
79        result
80    }
81
82    /// Run all matching `PostToolUse` hooks (fire-and-forget).
83    ///
84    /// Errors are silently swallowed — post-hooks are advisory.
85    pub async fn run_post_tool_use(&self, tool_name: &str, ctx: &HookContext) {
86        let matched = matching_hooks(&self.hooks, HookEvent::PostToolUse, Some(tool_name));
87        for hook in matched {
88            let _ = self.execute_hook(hook, ctx).await;
89        }
90    }
91
92    /// Run every `UserPromptSubmit` hook in registration order. Aggregates
93    /// results following CC's contract:
94    ///
95    /// - Any hook returning `decision: "block"` (or exit non-zero) → the
96    ///   whole prompt is blocked; first reason wins.
97    /// - Hooks emitting `hookSpecificOutput.additionalContext` (JSON) or
98    ///   plain text on stdout → concatenated and surfaced to the agent as
99    ///   extra context to append to the user message.
100    /// - Empty stdout / unparseable JSON → treated as a silent continue,
101    ///   so a hook author can still `print(...)` debug noise without
102    ///   accidentally injecting it into every prompt.
103    ///
104    /// Each hook receives the payload as JSON on stdin (CC parity), so
105    /// scripts using `json.load(sys.stdin)` work unchanged.
106    pub async fn run_user_prompt_submit(
107        &self,
108        prompt: &str,
109        session_id: &str,
110        cwd: &str,
111    ) -> UserPromptHookResult {
112        let matched = matching_hooks(&self.hooks, HookEvent::UserPromptSubmit, None);
113        if matched.is_empty() {
114            return UserPromptHookResult::Continue;
115        }
116
117        let payload = UserPromptSubmitPayload {
118            session_id: session_id.to_string(),
119            hook_event_name: "UserPromptSubmit".to_string(),
120            prompt: prompt.to_string(),
121            cwd: cwd.to_string(),
122        };
123        let payload_json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
124
125        let mut injected = String::new();
126        for hook in matched {
127            match self.execute_hook_with_stdin(hook, &payload_json).await {
128                Ok((exit_ok, stdout, stderr)) => {
129                    if !exit_ok {
130                        // Non-zero exit → block. Prefer stderr for the user
131                        // message (CC convention: scripts use stderr for
132                        // human-readable rejection text).
133                        let reason = if !stderr.trim().is_empty() {
134                            stderr.trim().to_string()
135                        } else if !stdout.trim().is_empty() {
136                            stdout.trim().to_string()
137                        } else {
138                            "user prompt blocked by hook".into()
139                        };
140                        return UserPromptHookResult::Block(reason);
141                    }
142                    // CC parity: hooks routinely log debug noise on
143                    // earlier lines and emit the structured decision as
144                    // the final line. If we parse the whole blob as JSON
145                    // and fail (because of the debug noise), we MUST NOT
146                    // fall through and inject the JSON as plain text —
147                    // that silently turned `decision: "block"` into an
148                    // inject in the previous version.
149                    //
150                    // Strategy: try the last non-empty line as JSON
151                    // first. If it parses, act on it. Only then fall back
152                    // to "treat full stdout as plain-text context".
153                    let last_line = stdout.lines().rev().find(|l| !l.trim().is_empty());
154                    let json_action = last_line.and_then(|l| {
155                        serde_json::from_str::<UserPromptSubmitOutput>(l.trim()).ok()
156                    });
157                    if let Some(parsed) = json_action {
158                        if matches!(parsed.decision.as_deref(), Some("block")) {
159                            let reason = parsed
160                                .reason
161                                .unwrap_or_else(|| "user prompt blocked by hook".into());
162                            return UserPromptHookResult::Block(reason);
163                        }
164                        if let Some(ctx) = parsed
165                            .hook_specific_output
166                            .and_then(|o| o.additional_context)
167                        {
168                            push_context(&mut injected, &ctx);
169                            continue;
170                        }
171                        // Valid JSON but no actionable fields — silent continue.
172                        continue;
173                    }
174                    // No JSON decision found anywhere in stdout: take the
175                    // entire trimmed blob as plain-text additional context.
176                    let trimmed = stdout.trim();
177                    if !trimmed.is_empty() {
178                        push_context(&mut injected, trimmed);
179                    }
180                }
181                Err(_) => {
182                    // Timeout / spawn failure degrades to continue, mirroring
183                    // PreToolUse's fail-open behavior. The alternative —
184                    // blocking every prompt on a flaky hook — is worse UX.
185                }
186            }
187        }
188
189        if injected.is_empty() {
190            UserPromptHookResult::Continue
191        } else {
192            UserPromptHookResult::Inject(injected)
193        }
194    }
195
196    /// Variant of `execute_hook` that pipes a payload to stdin and returns
197    /// `(exit_ok, stdout, stderr)`. Used by event types that follow CC's
198    /// stdin/stdout JSON protocol (currently only UserPromptSubmit).
199    async fn execute_hook_with_stdin(
200        &self,
201        hook: &HookConfig,
202        payload_json: &str,
203    ) -> anyhow::Result<(bool, String, String)> {
204        use std::process::Stdio;
205
206        let mut cmd = Command::new("sh");
207        cmd.arg("-c")
208            .arg(&hook.command)
209            .stdin(Stdio::piped())
210            .stdout(Stdio::piped())
211            .stderr(Stdio::piped());
212        if let Some(ref root) = hook.plugin_root {
213            let s = root.as_os_str();
214            cmd.env("CLAUDE_PLUGIN_ROOT", s);
215            cmd.env("ATOMCODE_PLUGIN_ROOT", s);
216        }
217        crate::process_utils::suppress_console_window(&mut cmd);
218
219        let timeout = Duration::from_millis(hook.timeout_ms);
220
221        let fut = async {
222            let mut child = cmd.spawn()?;
223            if let Some(mut stdin) = child.stdin.take() {
224                stdin.write_all(payload_json.as_bytes()).await?;
225                // Explicit shutdown so the hook script's `read` /
226                // `json.load(sys.stdin)` returns rather than hanging
227                // until our timeout fires.
228                stdin.shutdown().await.ok();
229                drop(stdin);
230            }
231            let output = child.wait_with_output().await?;
232            anyhow::Ok((
233                output.status.success(),
234                String::from_utf8_lossy(&output.stdout).to_string(),
235                String::from_utf8_lossy(&output.stderr).to_string(),
236            ))
237        };
238
239        Ok(tokio::time::timeout(timeout, fut).await??)
240    }
241
242    /// Run all hooks matching a session-level event (fire-and-forget).
243    pub async fn run_session_event(&self, event: HookEvent, ctx: &HookContext) {
244        let matched = matching_hooks(&self.hooks, event, None);
245        for hook in matched {
246            let _ = self.execute_hook(hook, ctx).await;
247        }
248    }
249
250    /// Execute a single hook command and return its stdout.
251    ///
252    /// The hook receives context via environment variables:
253    /// - `ATOMCODE_HOOK_EVENT`   — the event name (e.g. `pre_tool_use`)
254    /// - `ATOMCODE_TOOL_NAME`    — tool name, if applicable
255    /// - `ATOMCODE_HOOK_CONTEXT` — full JSON-serialized `HookContext`
256    ///
257    /// The command is killed after `hook.timeout_ms` milliseconds.
258    pub async fn execute_hook(
259        &self,
260        hook: &HookConfig,
261        ctx: &HookContext,
262    ) -> anyhow::Result<String> {
263        let ctx_json =
264            serde_json::to_string(ctx).unwrap_or_else(|_| "{}".to_string());
265
266        let mut cmd = Command::new("sh");
267        cmd.arg("-c")
268            .arg(&hook.command)
269            .env("ATOMCODE_HOOK_EVENT", &ctx.event)
270            .env("ATOMCODE_HOOK_CONTEXT", &ctx_json);
271
272        if let Some(ref name) = ctx.tool_name {
273            cmd.env("ATOMCODE_TOOL_NAME", name);
274        }
275        if let Some(ref root) = hook.plugin_root {
276            // CC parity: scripts reference `"${CLAUDE_PLUGIN_ROOT}/foo"`
277            // via shell expansion. Mirroring under both names keeps
278            // atomcode-native plugins idiomatic too.
279            let s = root.as_os_str();
280            cmd.env("CLAUDE_PLUGIN_ROOT", s);
281            cmd.env("ATOMCODE_PLUGIN_ROOT", s);
282        }
283
284        crate::process_utils::suppress_console_window(&mut cmd);
285
286        let timeout = Duration::from_millis(hook.timeout_ms);
287
288        let output = tokio::time::timeout(timeout, cmd.output()).await??;
289
290        if !output.status.success() {
291            anyhow::bail!(
292                "hook command exited with status {}",
293                output.status.code().unwrap_or(-1)
294            );
295        }
296
297        Ok(String::from_utf8_lossy(&output.stdout).to_string())
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use serde_json::json;
305
306    fn test_ctx() -> HookContext {
307        HookContext {
308            event: "pre_tool_use".into(),
309            tool_name: Some("bash".into()),
310            tool_args: Some(json!({"command": "ls"})),
311            tool_result: None,
312            tool_success: None,
313            session_id: "test-session".into(),
314            working_dir: "/tmp".into(),
315        }
316    }
317
318    fn make_hook(event: HookEvent, matcher: Option<&str>, cmd: &str) -> HookConfig {
319        HookConfig {
320            event,
321            matcher: matcher.map(String::from),
322            command: cmd.to_string(),
323            timeout_ms: 10_000,
324            plugin_root: None,
325        }
326    }
327
328    // ── Basic executor ───────────────────────────────────────────
329
330    #[tokio::test]
331    async fn empty_executor_allows() {
332        let exec = HookExecutor::empty();
333        assert!(!exec.has_hooks());
334        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
335        assert_eq!(result, PreHookResult::Allow);
336    }
337
338    // ── PreToolUse result parsing ────────────────────────────────
339
340    #[tokio::test]
341    async fn hook_returning_allow_json() {
342        let hook = make_hook(
343            HookEvent::PreToolUse,
344            Some("bash"),
345            r#"echo '{"action":"allow"}'"#,
346        );
347        let exec = HookExecutor::new(vec![hook]);
348        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
349        assert_eq!(result, PreHookResult::Allow);
350    }
351
352    #[tokio::test]
353    async fn hook_returning_block_json() {
354        let hook = make_hook(
355            HookEvent::PreToolUse,
356            Some("bash"),
357            r#"echo '{"action":"block","reason":"dangerous"}'"#,
358        );
359        let exec = HookExecutor::new(vec![hook]);
360        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
361        assert_eq!(
362            result,
363            PreHookResult::Block {
364                reason: "dangerous".into()
365            }
366        );
367    }
368
369    #[tokio::test]
370    async fn hook_returning_non_json_allows() {
371        let hook = make_hook(
372            HookEvent::PreToolUse,
373            Some("bash"),
374            "echo 'not json at all'",
375        );
376        let exec = HookExecutor::new(vec![hook]);
377        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
378        assert_eq!(result, PreHookResult::Allow);
379    }
380
381    // ── Error conditions ─────────────────────────────────────────
382
383    #[tokio::test]
384    async fn hook_timeout_degrades_to_allow() {
385        let mut hook = make_hook(
386            HookEvent::PreToolUse,
387            Some("bash"),
388            "sleep 10",
389        );
390        hook.timeout_ms = 100; // 100 ms timeout
391        let exec = HookExecutor::new(vec![hook]);
392        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
393        assert_eq!(result, PreHookResult::Allow);
394    }
395
396    // ── UserPromptSubmit ─────────────────────────────────────────
397
398    #[tokio::test]
399    async fn user_prompt_no_hooks_returns_continue() {
400        let exec = HookExecutor::empty();
401        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
402        assert_eq!(r, UserPromptHookResult::Continue);
403    }
404
405    #[tokio::test]
406    async fn user_prompt_plain_stdout_injects_context() {
407        let hook = make_hook(
408            HookEvent::UserPromptSubmit,
409            None,
410            "echo extra-info",
411        );
412        let exec = HookExecutor::new(vec![hook]);
413        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
414        assert_eq!(r, UserPromptHookResult::Inject("extra-info".into()));
415    }
416
417    #[tokio::test]
418    async fn user_prompt_decision_block_blocks() {
419        let hook = make_hook(
420            HookEvent::UserPromptSubmit,
421            None,
422            r#"echo '{"decision":"block","reason":"nope"}'"#,
423        );
424        let exec = HookExecutor::new(vec![hook]);
425        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
426        assert_eq!(r, UserPromptHookResult::Block("nope".into()));
427    }
428
429    #[tokio::test]
430    async fn user_prompt_hook_specific_output_injects() {
431        let hook = make_hook(
432            HookEvent::UserPromptSubmit,
433            None,
434            r#"echo '{"hookSpecificOutput":{"additionalContext":"ctx-bag"}}'"#,
435        );
436        let exec = HookExecutor::new(vec![hook]);
437        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
438        assert_eq!(r, UserPromptHookResult::Inject("ctx-bag".into()));
439    }
440
441    #[tokio::test]
442    async fn user_prompt_nonzero_exit_blocks_with_stderr() {
443        let hook = make_hook(
444            HookEvent::UserPromptSubmit,
445            None,
446            "echo bad >&2; exit 1",
447        );
448        let exec = HookExecutor::new(vec![hook]);
449        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
450        assert_eq!(r, UserPromptHookResult::Block("bad".into()));
451    }
452
453    /// Regression: stdout that mixes debug logging with a trailing JSON
454    /// decision used to fail the whole-blob `serde_json::from_str` and
455    /// fall through to plain-text injection — silently turning `block`
456    /// into `inject`. Last-line parse must catch the JSON.
457    #[tokio::test]
458    async fn user_prompt_block_after_debug_noise_still_blocks() {
459        let hook = make_hook(
460            HookEvent::UserPromptSubmit,
461            None,
462            r#"echo 'debug line 1'; echo 'debug line 2'; echo '{"decision":"block","reason":"final"}'"#,
463        );
464        let exec = HookExecutor::new(vec![hook]);
465        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
466        assert_eq!(r, UserPromptHookResult::Block("final".into()));
467    }
468
469    /// Plugin-root path containing a space MUST NOT break the hook command.
470    /// We pass it as `CLAUDE_PLUGIN_ROOT` env var; the hook expands it
471    /// inside its own quoted reference.
472    #[tokio::test]
473    async fn user_prompt_plugin_root_with_spaces_via_env() {
474        // Hook reads `$CLAUDE_PLUGIN_ROOT` (set by the executor) and
475        // echoes it back. Path contains a space to prove we are not
476        // doing string substitution.
477        let mut hook = make_hook(
478            HookEvent::UserPromptSubmit,
479            None,
480            r#"printf '%s' "$CLAUDE_PLUGIN_ROOT""#,
481        );
482        hook.plugin_root = Some(std::path::PathBuf::from("/opt/has space/x"));
483        let exec = HookExecutor::new(vec![hook]);
484        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
485        assert_eq!(r, UserPromptHookResult::Inject("/opt/has space/x".into()));
486    }
487
488    #[tokio::test]
489    async fn user_prompt_payload_reaches_stdin() {
490        // Hook reads stdin and echoes the prompt field back; we verify the
491        // payload made it through and was valid JSON with the expected key.
492        let hook = make_hook(
493            HookEvent::UserPromptSubmit,
494            None,
495            r#"python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["prompt"])'"#,
496        );
497        let exec = HookExecutor::new(vec![hook]);
498        let r = exec
499            .run_user_prompt_submit("ping-payload", "sess", "/tmp")
500            .await;
501        assert_eq!(r, UserPromptHookResult::Inject("ping-payload".into()));
502    }
503
504    #[tokio::test]
505    async fn hook_crash_degrades_to_allow() {
506        let hook = make_hook(
507            HookEvent::PreToolUse,
508            Some("bash"),
509            "exit 1",
510        );
511        let exec = HookExecutor::new(vec![hook]);
512        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
513        assert_eq!(result, PreHookResult::Allow);
514    }
515
516    // ── PostToolUse fire-and-forget ──────────────────────────────
517
518    #[tokio::test]
519    async fn post_tool_use_fire_and_forget() {
520        let hook = make_hook(
521            HookEvent::PostToolUse,
522            Some("bash"),
523            "echo done",
524        );
525        let exec = HookExecutor::new(vec![hook]);
526        // Should not panic or propagate errors.
527        exec.run_post_tool_use("bash", &test_ctx()).await;
528    }
529
530    // ── Matcher integration ──────────────────────────────────────
531
532    #[tokio::test]
533    async fn matcher_filters_correctly() {
534        let hook = make_hook(
535            HookEvent::PreToolUse,
536            Some("bash"),
537            r#"echo '{"action":"block","reason":"bash only"}'"#,
538        );
539        let exec = HookExecutor::new(vec![hook]);
540
541        // Should block for bash
542        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
543        assert_eq!(
544            result,
545            PreHookResult::Block {
546                reason: "bash only".into()
547            }
548        );
549
550        // Should allow for grep (hook doesn't match)
551        let result = exec.run_pre_tool_use("grep", &test_ctx()).await;
552        assert_eq!(result, PreHookResult::Allow);
553    }
554
555    // ── Environment variables ────────────────────────────────────
556
557    #[tokio::test]
558    async fn hook_receives_env_vars() {
559        // The hook echoes environment variables as JSON so we can verify.
560        let hook = make_hook(
561            HookEvent::PreToolUse,
562            Some("bash"),
563            r#"printf '{"event":"%s","tool":"%s","has_ctx":"%s"}' "$ATOMCODE_HOOK_EVENT" "$ATOMCODE_TOOL_NAME" "$(test -n "$ATOMCODE_HOOK_CONTEXT" && echo yes || echo no)""#,
564        );
565        let exec = HookExecutor::new(vec![hook]);
566        let ctx = test_ctx();
567
568        // We don't care about the PreHookResult (it won't be valid JSON for
569        // our PreHookResult enum), so call execute_hook directly.
570        let stdout = exec.execute_hook(&exec.hooks[0], &ctx).await.unwrap();
571        let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
572
573        assert_eq!(parsed["event"], "pre_tool_use");
574        assert_eq!(parsed["tool"], "bash");
575        assert_eq!(parsed["has_ctx"], "yes");
576    }
577}