atomcode-core 4.23.1

Open-source terminal AI coding agent
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
use std::time::Duration;

use tokio::io::AsyncWriteExt;
use tokio::process::Command;

use super::config::matching_hooks;
use super::{
    HookConfig, HookContext, HookEvent, PreHookResult, UserPromptHookResult,
    UserPromptSubmitOutput, UserPromptSubmitPayload,
};

/// Helper: append `extra` to `acc`, separated by a blank line. Keeps
/// hook-injected context blocks visually distinct when multiple hooks
/// each contribute a chunk.
fn push_context(acc: &mut String, extra: &str) {
    if !acc.is_empty() {
        acc.push_str("\n\n");
    }
    acc.push_str(extra);
}

/// Executes hook commands in response to agent lifecycle events.
pub struct HookExecutor {
    hooks: Vec<HookConfig>,
}

impl HookExecutor {
    /// Create an executor with the given hook configurations.
    pub fn new(hooks: Vec<HookConfig>) -> Self {
        Self { hooks }
    }

    /// Create an executor with no hooks (a no-op executor).
    pub fn empty() -> Self {
        Self { hooks: vec![] }
    }

    /// Whether any hooks are configured.
    pub fn has_hooks(&self) -> bool {
        !self.hooks.is_empty()
    }

    /// Run all matching `PreToolUse` hooks and return the aggregate result.
    ///
    /// If any hook returns `Block`, the overall result is `Block`.
    /// If any hook returns `Modify`, the last `Modify` wins.
    /// If a hook times out, crashes, or produces non-JSON output, it degrades
    /// to `Allow` (the tool call is not disrupted).
    pub async fn run_pre_tool_use(
        &self,
        tool_name: &str,
        ctx: &HookContext,
    ) -> PreHookResult {
        let matched = matching_hooks(&self.hooks, HookEvent::PreToolUse, Some(tool_name));
        if matched.is_empty() {
            return PreHookResult::Allow;
        }

        let mut result = PreHookResult::Allow;

        for hook in matched {
            match self.execute_hook(hook, ctx).await {
                Ok(stdout) => {
                    match serde_json::from_str::<PreHookResult>(&stdout) {
                        Ok(parsed) => match &parsed {
                            PreHookResult::Block { .. } => return parsed,
                            PreHookResult::Modify { .. } => result = parsed,
                            PreHookResult::Allow => {}
                        },
                        // Non-JSON output degrades to Allow.
                        Err(_) => {}
                    }
                }
                // Timeout or crash degrades to Allow.
                Err(_) => {}
            }
        }

        result
    }

    /// Run all matching `PostToolUse` hooks (fire-and-forget).
    ///
    /// Errors are silently swallowed — post-hooks are advisory.
    pub async fn run_post_tool_use(&self, tool_name: &str, ctx: &HookContext) {
        let matched = matching_hooks(&self.hooks, HookEvent::PostToolUse, Some(tool_name));
        for hook in matched {
            let _ = self.execute_hook(hook, ctx).await;
        }
    }

    /// Run every `UserPromptSubmit` hook in registration order. Aggregates
    /// results following CC's contract:
    ///
    /// - Any hook returning `decision: "block"` (or exit non-zero) → the
    ///   whole prompt is blocked; first reason wins.
    /// - Hooks emitting `hookSpecificOutput.additionalContext` (JSON) or
    ///   plain text on stdout → concatenated and surfaced to the agent as
    ///   extra context to append to the user message.
    /// - Empty stdout / unparseable JSON → treated as a silent continue,
    ///   so a hook author can still `print(...)` debug noise without
    ///   accidentally injecting it into every prompt.
    ///
    /// Each hook receives the payload as JSON on stdin (CC parity), so
    /// scripts using `json.load(sys.stdin)` work unchanged.
    pub async fn run_user_prompt_submit(
        &self,
        prompt: &str,
        session_id: &str,
        cwd: &str,
    ) -> UserPromptHookResult {
        let matched = matching_hooks(&self.hooks, HookEvent::UserPromptSubmit, None);
        if matched.is_empty() {
            return UserPromptHookResult::Continue;
        }

        let payload = UserPromptSubmitPayload {
            session_id: session_id.to_string(),
            hook_event_name: "UserPromptSubmit".to_string(),
            prompt: prompt.to_string(),
            cwd: cwd.to_string(),
        };
        let payload_json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());

        let mut injected = String::new();
        for hook in matched {
            match self.execute_hook_with_stdin(hook, &payload_json).await {
                Ok((exit_ok, stdout, stderr)) => {
                    if !exit_ok {
                        // Non-zero exit → block. Prefer stderr for the user
                        // message (CC convention: scripts use stderr for
                        // human-readable rejection text).
                        let reason = if !stderr.trim().is_empty() {
                            stderr.trim().to_string()
                        } else if !stdout.trim().is_empty() {
                            stdout.trim().to_string()
                        } else {
                            "user prompt blocked by hook".into()
                        };
                        return UserPromptHookResult::Block(reason);
                    }
                    // CC parity: hooks routinely log debug noise on
                    // earlier lines and emit the structured decision as
                    // the final line. If we parse the whole blob as JSON
                    // and fail (because of the debug noise), we MUST NOT
                    // fall through and inject the JSON as plain text —
                    // that silently turned `decision: "block"` into an
                    // inject in the previous version.
                    //
                    // Strategy: try the last non-empty line as JSON
                    // first. If it parses, act on it. Only then fall back
                    // to "treat full stdout as plain-text context".
                    let last_line = stdout.lines().rev().find(|l| !l.trim().is_empty());
                    let json_action = last_line.and_then(|l| {
                        serde_json::from_str::<UserPromptSubmitOutput>(l.trim()).ok()
                    });
                    if let Some(parsed) = json_action {
                        if matches!(parsed.decision.as_deref(), Some("block")) {
                            let reason = parsed
                                .reason
                                .unwrap_or_else(|| "user prompt blocked by hook".into());
                            return UserPromptHookResult::Block(reason);
                        }
                        if let Some(ctx) = parsed
                            .hook_specific_output
                            .and_then(|o| o.additional_context)
                        {
                            push_context(&mut injected, &ctx);
                            continue;
                        }
                        // Valid JSON but no actionable fields — silent continue.
                        continue;
                    }
                    // No JSON decision found anywhere in stdout: take the
                    // entire trimmed blob as plain-text additional context.
                    let trimmed = stdout.trim();
                    if !trimmed.is_empty() {
                        push_context(&mut injected, trimmed);
                    }
                }
                Err(_) => {
                    // Timeout / spawn failure degrades to continue, mirroring
                    // PreToolUse's fail-open behavior. The alternative —
                    // blocking every prompt on a flaky hook — is worse UX.
                }
            }
        }

        if injected.is_empty() {
            UserPromptHookResult::Continue
        } else {
            UserPromptHookResult::Inject(injected)
        }
    }

    /// Variant of `execute_hook` that pipes a payload to stdin and returns
    /// `(exit_ok, stdout, stderr)`. Used by event types that follow CC's
    /// stdin/stdout JSON protocol (currently only UserPromptSubmit).
    async fn execute_hook_with_stdin(
        &self,
        hook: &HookConfig,
        payload_json: &str,
    ) -> anyhow::Result<(bool, String, String)> {
        use std::process::Stdio;

        let mut cmd = Command::new("sh");
        cmd.arg("-c")
            .arg(&hook.command)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if let Some(ref root) = hook.plugin_root {
            let s = root.as_os_str();
            cmd.env("CLAUDE_PLUGIN_ROOT", s);
            cmd.env("ATOMCODE_PLUGIN_ROOT", s);
        }
        crate::process_utils::suppress_console_window(&mut cmd);

        let timeout = Duration::from_millis(hook.timeout_ms);

        let fut = async {
            let mut child = cmd.spawn()?;
            if let Some(mut stdin) = child.stdin.take() {
                stdin.write_all(payload_json.as_bytes()).await?;
                // Explicit shutdown so the hook script's `read` /
                // `json.load(sys.stdin)` returns rather than hanging
                // until our timeout fires.
                stdin.shutdown().await.ok();
                drop(stdin);
            }
            let output = child.wait_with_output().await?;
            anyhow::Ok((
                output.status.success(),
                String::from_utf8_lossy(&output.stdout).to_string(),
                String::from_utf8_lossy(&output.stderr).to_string(),
            ))
        };

        Ok(tokio::time::timeout(timeout, fut).await??)
    }

    /// Run all hooks matching a session-level event (fire-and-forget).
    pub async fn run_session_event(&self, event: HookEvent, ctx: &HookContext) {
        let matched = matching_hooks(&self.hooks, event, None);
        for hook in matched {
            let _ = self.execute_hook(hook, ctx).await;
        }
    }

    /// Execute a single hook command and return its stdout.
    ///
    /// The hook receives context via environment variables:
    /// - `ATOMCODE_HOOK_EVENT`   — the event name (e.g. `pre_tool_use`)
    /// - `ATOMCODE_TOOL_NAME`    — tool name, if applicable
    /// - `ATOMCODE_HOOK_CONTEXT` — full JSON-serialized `HookContext`
    ///
    /// The command is killed after `hook.timeout_ms` milliseconds.
    pub async fn execute_hook(
        &self,
        hook: &HookConfig,
        ctx: &HookContext,
    ) -> anyhow::Result<String> {
        let ctx_json =
            serde_json::to_string(ctx).unwrap_or_else(|_| "{}".to_string());

        let mut cmd = Command::new("sh");
        cmd.arg("-c")
            .arg(&hook.command)
            .env("ATOMCODE_HOOK_EVENT", &ctx.event)
            .env("ATOMCODE_HOOK_CONTEXT", &ctx_json);

        if let Some(ref name) = ctx.tool_name {
            cmd.env("ATOMCODE_TOOL_NAME", name);
        }
        if let Some(ref root) = hook.plugin_root {
            // CC parity: scripts reference `"${CLAUDE_PLUGIN_ROOT}/foo"`
            // via shell expansion. Mirroring under both names keeps
            // atomcode-native plugins idiomatic too.
            let s = root.as_os_str();
            cmd.env("CLAUDE_PLUGIN_ROOT", s);
            cmd.env("ATOMCODE_PLUGIN_ROOT", s);
        }

        crate::process_utils::suppress_console_window(&mut cmd);

        let timeout = Duration::from_millis(hook.timeout_ms);

        let output = tokio::time::timeout(timeout, cmd.output()).await??;

        if !output.status.success() {
            anyhow::bail!(
                "hook command exited with status {}",
                output.status.code().unwrap_or(-1)
            );
        }

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn test_ctx() -> HookContext {
        HookContext {
            event: "pre_tool_use".into(),
            tool_name: Some("bash".into()),
            tool_args: Some(json!({"command": "ls"})),
            tool_result: None,
            tool_success: None,
            session_id: "test-session".into(),
            working_dir: "/tmp".into(),
        }
    }

    fn make_hook(event: HookEvent, matcher: Option<&str>, cmd: &str) -> HookConfig {
        HookConfig {
            event,
            matcher: matcher.map(String::from),
            command: cmd.to_string(),
            timeout_ms: 10_000,
            plugin_root: None,
        }
    }

    // ── Basic executor ───────────────────────────────────────────

    #[tokio::test]
    async fn empty_executor_allows() {
        let exec = HookExecutor::empty();
        assert!(!exec.has_hooks());
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    // ── PreToolUse result parsing ────────────────────────────────

    #[tokio::test]
    async fn hook_returning_allow_json() {
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            r#"echo '{"action":"allow"}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    #[tokio::test]
    async fn hook_returning_block_json() {
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            r#"echo '{"action":"block","reason":"dangerous"}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(
            result,
            PreHookResult::Block {
                reason: "dangerous".into()
            }
        );
    }

    #[tokio::test]
    async fn hook_returning_non_json_allows() {
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            "echo 'not json at all'",
        );
        let exec = HookExecutor::new(vec![hook]);
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    // ── Error conditions ─────────────────────────────────────────

    #[tokio::test]
    async fn hook_timeout_degrades_to_allow() {
        let mut hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            "sleep 10",
        );
        hook.timeout_ms = 100; // 100 ms timeout
        let exec = HookExecutor::new(vec![hook]);
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    // ── UserPromptSubmit ─────────────────────────────────────────

    #[tokio::test]
    async fn user_prompt_no_hooks_returns_continue() {
        let exec = HookExecutor::empty();
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Continue);
    }

    #[tokio::test]
    async fn user_prompt_plain_stdout_injects_context() {
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            "echo extra-info",
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Inject("extra-info".into()));
    }

    #[tokio::test]
    async fn user_prompt_decision_block_blocks() {
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            r#"echo '{"decision":"block","reason":"nope"}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Block("nope".into()));
    }

    #[tokio::test]
    async fn user_prompt_hook_specific_output_injects() {
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            r#"echo '{"hookSpecificOutput":{"additionalContext":"ctx-bag"}}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Inject("ctx-bag".into()));
    }

    #[tokio::test]
    async fn user_prompt_nonzero_exit_blocks_with_stderr() {
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            "echo bad >&2; exit 1",
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Block("bad".into()));
    }

    /// Regression: stdout that mixes debug logging with a trailing JSON
    /// decision used to fail the whole-blob `serde_json::from_str` and
    /// fall through to plain-text injection — silently turning `block`
    /// into `inject`. Last-line parse must catch the JSON.
    #[tokio::test]
    async fn user_prompt_block_after_debug_noise_still_blocks() {
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            r#"echo 'debug line 1'; echo 'debug line 2'; echo '{"decision":"block","reason":"final"}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Block("final".into()));
    }

    /// Plugin-root path containing a space MUST NOT break the hook command.
    /// We pass it as `CLAUDE_PLUGIN_ROOT` env var; the hook expands it
    /// inside its own quoted reference.
    #[tokio::test]
    async fn user_prompt_plugin_root_with_spaces_via_env() {
        // Hook reads `$CLAUDE_PLUGIN_ROOT` (set by the executor) and
        // echoes it back. Path contains a space to prove we are not
        // doing string substitution.
        let mut hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            r#"printf '%s' "$CLAUDE_PLUGIN_ROOT""#,
        );
        hook.plugin_root = Some(std::path::PathBuf::from("/opt/has space/x"));
        let exec = HookExecutor::new(vec![hook]);
        let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
        assert_eq!(r, UserPromptHookResult::Inject("/opt/has space/x".into()));
    }

    #[tokio::test]
    async fn user_prompt_payload_reaches_stdin() {
        // Hook reads stdin and echoes the prompt field back; we verify the
        // payload made it through and was valid JSON with the expected key.
        let hook = make_hook(
            HookEvent::UserPromptSubmit,
            None,
            r#"python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["prompt"])'"#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let r = exec
            .run_user_prompt_submit("ping-payload", "sess", "/tmp")
            .await;
        assert_eq!(r, UserPromptHookResult::Inject("ping-payload".into()));
    }

    #[tokio::test]
    async fn hook_crash_degrades_to_allow() {
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            "exit 1",
        );
        let exec = HookExecutor::new(vec![hook]);
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    // ── PostToolUse fire-and-forget ──────────────────────────────

    #[tokio::test]
    async fn post_tool_use_fire_and_forget() {
        let hook = make_hook(
            HookEvent::PostToolUse,
            Some("bash"),
            "echo done",
        );
        let exec = HookExecutor::new(vec![hook]);
        // Should not panic or propagate errors.
        exec.run_post_tool_use("bash", &test_ctx()).await;
    }

    // ── Matcher integration ──────────────────────────────────────

    #[tokio::test]
    async fn matcher_filters_correctly() {
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            r#"echo '{"action":"block","reason":"bash only"}'"#,
        );
        let exec = HookExecutor::new(vec![hook]);

        // Should block for bash
        let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
        assert_eq!(
            result,
            PreHookResult::Block {
                reason: "bash only".into()
            }
        );

        // Should allow for grep (hook doesn't match)
        let result = exec.run_pre_tool_use("grep", &test_ctx()).await;
        assert_eq!(result, PreHookResult::Allow);
    }

    // ── Environment variables ────────────────────────────────────

    #[tokio::test]
    async fn hook_receives_env_vars() {
        // The hook echoes environment variables as JSON so we can verify.
        let hook = make_hook(
            HookEvent::PreToolUse,
            Some("bash"),
            r#"printf '{"event":"%s","tool":"%s","has_ctx":"%s"}' "$ATOMCODE_HOOK_EVENT" "$ATOMCODE_TOOL_NAME" "$(test -n "$ATOMCODE_HOOK_CONTEXT" && echo yes || echo no)""#,
        );
        let exec = HookExecutor::new(vec![hook]);
        let ctx = test_ctx();

        // We don't care about the PreHookResult (it won't be valid JSON for
        // our PreHookResult enum), so call execute_hook directly.
        let stdout = exec.execute_hook(&exec.hooks[0], &ctx).await.unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();

        assert_eq!(parsed["event"], "pre_tool_use");
        assert_eq!(parsed["tool"], "bash");
        assert_eq!(parsed["has_ctx"], "yes");
    }
}