Skip to main content

bamboo_hooks/
configured.rs

1use std::ffi::OsString;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::sync::Arc;
6use std::time::Duration;
7
8use async_trait::async_trait;
9use bamboo_agent_core::{AgentHook, Session};
10use bamboo_config::{
11    lifecycle_script_extension, LifecycleHookGroup, LifecycleHookHandler, LifecycleHooksConfig,
12    LifecycleScriptRunner,
13};
14use bamboo_domain::{
15    AgentHookPoint, HookPayload, HookResult, SessionEndStatus, SessionStartSource,
16};
17use bamboo_infrastructure::{build_command_environment, preferred_bash_shell};
18use chrono::Utc;
19use regex::Regex;
20use serde::{Deserialize, Serialize};
21use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
22use tokio::process::{Child, Command};
23use tokio::sync::mpsc;
24use tracing::warn;
25
26use crate::HookDispatcher;
27
28const HOOK_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
29
30/// User-facing lifecycle events that currently map to engine hook seams.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LifecycleHookEvent {
33    SessionStart,
34    UserPromptSubmit,
35    PreToolUse,
36    PostToolUse,
37    Stop,
38    SessionEnd,
39    PreCompact,
40    Notification,
41}
42
43/// Raw handler result returned by the settings dry-run endpoint.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct LifecycleHookTestOutput {
46    pub exit_code: Option<i32>,
47    pub stdout: String,
48    pub stderr: String,
49    pub timed_out: bool,
50    pub stdout_truncated: bool,
51    pub stderr_truncated: bool,
52}
53
54/// Backward-compatible name for the command-only dry-run response.
55pub type ShellHookTestOutput = LifecycleHookTestOutput;
56/// Backward-compatible name retained for SDK consumers.
57pub type ShellHookEvent = LifecycleHookEvent;
58
59impl LifecycleHookEvent {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Self::SessionStart => "SessionStart",
63            Self::UserPromptSubmit => "UserPromptSubmit",
64            Self::PreToolUse => "PreToolUse",
65            Self::PostToolUse => "PostToolUse",
66            Self::Stop => "Stop",
67            Self::SessionEnd => "SessionEnd",
68            Self::PreCompact => "PreCompact",
69            Self::Notification => "Notification",
70        }
71    }
72
73    fn point(self) -> AgentHookPoint {
74        match self {
75            Self::SessionStart => AgentHookPoint::AfterSessionSetup,
76            Self::UserPromptSubmit => AgentHookPoint::BeforeSessionSetup,
77            Self::PreToolUse => AgentHookPoint::BeforeToolExecution,
78            Self::PostToolUse => AgentHookPoint::AfterToolExecution,
79            Self::Stop => AgentHookPoint::BeforeFinalize,
80            Self::SessionEnd => AgentHookPoint::AfterSessionEnd,
81            Self::PreCompact => AgentHookPoint::BeforeCompression,
82            Self::Notification => AgentHookPoint::AfterNotification,
83        }
84    }
85
86    fn supports_tool_matcher(self) -> bool {
87        matches!(self, Self::PreToolUse | Self::PostToolUse)
88    }
89}
90
91/// One config-driven lifecycle shell command.
92pub struct ShellCommandHook {
93    event: LifecycleHookEvent,
94    event_name_override: Option<&'static str>,
95    command: String,
96    timeout: Duration,
97    matcher: Option<Regex>,
98    fallback_cwd: Option<PathBuf>,
99    name: String,
100}
101
102impl ShellCommandHook {
103    pub fn new(
104        event: LifecycleHookEvent,
105        command: impl Into<String>,
106        timeout_ms: u64,
107        matcher: Option<&str>,
108        fallback_cwd: Option<PathBuf>,
109        sequence: usize,
110    ) -> Result<Self, regex::Error> {
111        let matcher = matcher.map(Regex::new).transpose()?;
112        Ok(Self {
113            event,
114            event_name_override: None,
115            command: command.into(),
116            timeout: Duration::from_millis(timeout_ms.max(1)),
117            matcher,
118            fallback_cwd,
119            name: format!("lifecycle_shell:{}:{sequence}", event.as_str()),
120        })
121    }
122
123    fn event_name(&self) -> &'static str {
124        self.event_name_override
125            .unwrap_or_else(|| self.event.as_str())
126    }
127
128    fn effective_cwd(&self, session: &Session) -> Option<PathBuf> {
129        session
130            .workspace
131            .as_deref()
132            .filter(|workspace| !workspace.trim().is_empty())
133            .map(PathBuf::from)
134            .or_else(|| self.fallback_cwd.clone())
135            .or_else(|| std::env::current_dir().ok())
136    }
137
138    fn envelope(
139        &self,
140        payload: &HookPayload,
141        session: &Session,
142        cwd: Option<&PathBuf>,
143    ) -> HookEnvelope {
144        build_hook_envelope(self.event_name(), payload, session, cwd)
145    }
146
147    async fn execute(
148        &self,
149        input: Vec<u8>,
150        cwd: Option<&PathBuf>,
151        session: &Session,
152    ) -> Result<CommandOutput, String> {
153        let shell = preferred_bash_shell();
154        let overrides = bamboo_llm::Config::current_env_vars();
155        let prepared_env = build_command_environment(&overrides).await;
156        let mut command = Command::new(&shell.program);
157        prepared_env.apply_to_tokio_command(&mut command);
158        if let Some(cwd) = cwd {
159            command.current_dir(cwd);
160        }
161        command
162            .arg(shell.arg)
163            .arg(&self.command)
164            .env("BAMBOO_SESSION_ID", &session.id)
165            .env("BAMBOO_HOOK_EVENT", self.event_name())
166            .stdin(Stdio::piped())
167            .stdout(Stdio::piped())
168            .stderr(Stdio::piped());
169        configure_hook_process(&mut command);
170
171        let child = command
172            .spawn()
173            .map_err(|error| format!("failed to spawn lifecycle hook: {error}"))?;
174        let child = HookChild::new(child)?;
175        capture_hook_child(child, input, self.timeout, &self.name).await
176    }
177
178    fn interpret(&self, output: CommandOutput) -> HookResult {
179        if output.stdout.truncated || output.stderr.truncated {
180            warn!(
181                hook = %self.name,
182                stdout_truncated = output.stdout.truncated,
183                stderr_truncated = output.stderr.truncated,
184                "lifecycle hook output exceeded capture limit"
185            );
186        }
187        if output.timed_out {
188            warn!(hook = %self.name, "lifecycle hook timed out and was killed");
189            return HookResult::Continue;
190        }
191
192        match output.exit_code {
193            Some(0) => {
194                if output.stdout.truncated {
195                    return HookResult::Continue;
196                }
197                self.interpret_success(&output.stdout.bytes)
198            }
199            Some(2) => {
200                let reason = String::from_utf8_lossy(&output.stderr.bytes)
201                    .trim()
202                    .to_string();
203                HookResult::Deny {
204                    reason: if reason.is_empty() {
205                        "lifecycle hook exited with blocking status 2".to_string()
206                    } else {
207                        reason
208                    },
209                }
210            }
211            exit_code => {
212                warn!(hook = %self.name, ?exit_code, "lifecycle hook failed non-blocking");
213                HookResult::Continue
214            }
215        }
216    }
217
218    fn interpret_success(&self, stdout: &[u8]) -> HookResult {
219        let stdout = String::from_utf8_lossy(stdout);
220        let stdout = stdout.trim();
221        if stdout.is_empty() {
222            return HookResult::Continue;
223        }
224
225        let response: HookResponse = match serde_json::from_str(stdout) {
226            Ok(response) => response,
227            Err(error) => {
228                warn!(hook = %self.name, error = %error, "ignoring malformed lifecycle hook response");
229                return HookResult::Continue;
230            }
231        };
232        interpret_response(response)
233    }
234
235    async fn test(
236        &self,
237        payload: &HookPayload,
238        session: &Session,
239    ) -> Result<LifecycleHookTestOutput, String> {
240        let cwd = self.effective_cwd(session);
241        let input = serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref()))
242            .map_err(|error| format!("failed serializing lifecycle hook test payload: {error}"))?;
243        let output = self.execute(input, cwd.as_ref(), session).await?;
244        Ok(LifecycleHookTestOutput {
245            exit_code: output.exit_code,
246            stdout: String::from_utf8_lossy(&output.stdout.bytes).into_owned(),
247            stderr: String::from_utf8_lossy(&output.stderr.bytes).into_owned(),
248            timed_out: output.timed_out,
249            stdout_truncated: output.stdout.truncated,
250            stderr_truncated: output.stderr.truncated,
251        })
252    }
253}
254
255/// Execute one configured handler against a deterministic synthetic payload.
256/// Production runtime limits and output caps are preserved, while result
257/// interpretation is skipped so callers receive raw diagnostics.
258pub async fn test_lifecycle_handler(
259    event_name: &str,
260    handler: &LifecycleHookHandler,
261    fallback_cwd: Option<PathBuf>,
262) -> Result<LifecycleHookTestOutput, String> {
263    let (event, payload) = synthetic_test_payload(event_name)?;
264    let session = Session::new("lifecycle-hook-test", "hook-test");
265
266    match handler {
267        LifecycleHookHandler::Command {
268            command,
269            timeout_ms,
270        } => {
271            let mut hook =
272                ShellCommandHook::new(event, command, *timeout_ms, None, fallback_cwd, 0)
273                    .map_err(|error| format!("invalid lifecycle hook matcher: {error}"))?;
274            hook.name = format!("lifecycle_shell_test:{event_name}");
275            hook.test(&payload, &session).await
276        }
277        LifecycleHookHandler::Script {
278            path,
279            runner,
280            timeout_ms,
281        } => {
282            let mut hook =
283                ScriptHook::new(event, path, *runner, *timeout_ms, None, fallback_cwd, 0)
284                    .map_err(|error| format!("invalid lifecycle hook matcher: {error}"))?;
285            hook.name = format!("lifecycle_script_test:{event_name}");
286            hook.test(&payload, &session).await
287        }
288    }
289}
290
291/// Backward-compatible command-only dry-run entry point.
292pub async fn test_lifecycle_shell_command(
293    event_name: &str,
294    command: &str,
295    timeout_ms: u64,
296    fallback_cwd: Option<PathBuf>,
297) -> Result<LifecycleHookTestOutput, String> {
298    test_lifecycle_handler(
299        event_name,
300        &LifecycleHookHandler::command(command, timeout_ms),
301        fallback_cwd,
302    )
303    .await
304}
305
306fn synthetic_test_payload(event_name: &str) -> Result<(LifecycleHookEvent, HookPayload), String> {
307    let value = match event_name {
308        "SessionStart" => (
309            LifecycleHookEvent::SessionStart,
310            HookPayload::SessionSetup {
311                initial_message: "Lifecycle hook test".to_string(),
312                source: SessionStartSource::Startup,
313            },
314        ),
315        "UserPromptSubmit" => (
316            LifecycleHookEvent::UserPromptSubmit,
317            HookPayload::Prompt {
318                prompt: "Lifecycle hook test".to_string(),
319            },
320        ),
321        "PreToolUse" => (
322            LifecycleHookEvent::PreToolUse,
323            HookPayload::ToolExecution {
324                tool_name: "Bash".to_string(),
325                tool_call_id: "hook-test-call".to_string(),
326                parsed_args: serde_json::json!({"command": "echo lifecycle-hook-test"}),
327            },
328        ),
329        "PostToolUse" => (
330            LifecycleHookEvent::PostToolUse,
331            HookPayload::ToolResult {
332                tool_name: "Bash".to_string(),
333                tool_call_id: "hook-test-call".to_string(),
334                outcome: bamboo_domain::HookToolOutcome {
335                    success: true,
336                    result: Some("lifecycle-hook-test".to_string()),
337                    error: None,
338                    needs_human: false,
339                    duration_ms: 1,
340                },
341            },
342        ),
343        "Stop" => (
344            LifecycleHookEvent::Stop,
345            HookPayload::Finalize {
346                stop_hook_active: false,
347            },
348        ),
349        "SessionEnd" => (
350            LifecycleHookEvent::SessionEnd,
351            HookPayload::SessionEnd {
352                status: SessionEndStatus::Completed,
353                completion_reason: Some("lifecycle hook test".to_string()),
354            },
355        ),
356        "PreCompact" => (
357            LifecycleHookEvent::PreCompact,
358            HookPayload::Compression {
359                estimated_tokens: 1_000,
360                usage_percent: 50.0,
361                max_context_tokens: 2_000,
362                trigger_context_tokens: 1_600,
363                trigger: "threshold".to_string(),
364                phase: "test".to_string(),
365            },
366        ),
367        "Notification" => (
368            LifecycleHookEvent::Notification,
369            HookPayload::Notification {
370                id: Some("notification-test".to_string()),
371                category: "custom".to_string(),
372                priority: "normal".to_string(),
373                title: "Lifecycle hook test".to_string(),
374                body: "Synthetic notification delivery".to_string(),
375                dedup_key: Some("lifecycle-hook-test".to_string()),
376                created_at: Some(Utc::now().to_rfc3339()),
377                click_url: None,
378            },
379        ),
380        other => return Err(format!("unknown lifecycle hook event '{other}'")),
381    };
382    Ok(value)
383}
384
385async fn capture_hook_child(
386    mut child: HookChild,
387    input: Vec<u8>,
388    timeout: Duration,
389    hook_name: &str,
390) -> Result<CommandOutput, String> {
391    let mut stdin = child
392        .child
393        .stdin
394        .take()
395        .ok_or_else(|| "failed to open lifecycle hook stdin".to_string())?;
396    let stdout = child
397        .child
398        .stdout
399        .take()
400        .ok_or_else(|| "failed to capture lifecycle hook stdout".to_string())?;
401    let stderr = child
402        .child
403        .stderr
404        .take()
405        .ok_or_else(|| "failed to capture lifecycle hook stderr".to_string())?;
406
407    let (io_tx, mut io_rx) = mpsc::channel(3);
408    let input_tx = io_tx.clone();
409    let input_task = tokio::spawn(async move {
410        let result = stdin.write_all(&input).await;
411        let _ = stdin.shutdown().await;
412        let _ = input_tx.send(HookIoEvent::Input(result)).await;
413    });
414    let stdout_tx = io_tx.clone();
415    let stdout_task = tokio::spawn(async move {
416        let _ = stdout_tx
417            .send(HookIoEvent::Stdout(read_capped(stdout).await))
418            .await;
419    });
420    let stderr_task = tokio::spawn(async move {
421        let _ = io_tx
422            .send(HookIoEvent::Stderr(read_capped(stderr).await))
423            .await;
424    });
425
426    let mut io_results = HookIoResults::default();
427    let completion = async {
428        let (status, io_result) = tokio::join!(
429            child.child.wait(),
430            receive_hook_io(&mut io_rx, &mut io_results)
431        );
432        io_result?;
433        status.map_err(|error| format!("failed waiting for lifecycle hook: {error}"))
434    };
435    let (status, timed_out) = match tokio::time::timeout(timeout, completion).await {
436        Ok(Ok(status)) => (Some(status), false),
437        Ok(Err(error)) => return Err(error),
438        Err(_) => {
439            kill_hook_process_tree(&mut child).await;
440            // Do not await pipe EOF after the deadline. A deliberately
441            // detached descendant can escape a Unix process group while still
442            // holding inherited descriptors, so the I/O tasks must be
443            // cancellable for the wall-clock deadline to remain bounded.
444            input_task.abort();
445            stdout_task.abort();
446            stderr_task.abort();
447            while let Ok(event) = io_rx.try_recv() {
448                io_results.record(event)?;
449            }
450            (None, true)
451        }
452    };
453
454    let (stdout, stderr) = if timed_out {
455        io_results.finish_after_timeout(hook_name)
456    } else {
457        io_results.finish(hook_name)?
458    };
459    child.finished = true;
460
461    Ok(CommandOutput {
462        exit_code: status.and_then(|status| status.code()),
463        stdout,
464        stderr,
465        timed_out,
466    })
467}
468
469fn configure_hook_process(command: &mut Command) {
470    command.kill_on_drop(true);
471    #[cfg(unix)]
472    {
473        // The saved process-group id remains usable after the direct child
474        // exits, so descendants that keep captured pipes open can still be
475        // terminated when the full wall-clock deadline expires.
476        command.process_group(0);
477    }
478    #[cfg(windows)]
479    {
480        use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
481
482        // Suspend before first instruction so the process can be assigned to a
483        // Job Object before it has any opportunity to spawn descendants.
484        command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
485    }
486}
487
488struct HookChild {
489    child: Child,
490    #[cfg(unix)]
491    process_group_id: u32,
492    finished: bool,
493    #[cfg(windows)]
494    job: WindowsJob,
495}
496
497impl HookChild {
498    fn new(child: Child) -> Result<Self, String> {
499        let process_id = child
500            .id()
501            .ok_or_else(|| "lifecycle hook exited before process isolation".to_string())?;
502        #[cfg(windows)]
503        let job = WindowsJob::assign_and_resume(&child, process_id)
504            .map_err(|error| format!("failed isolating lifecycle hook process tree: {error}"))?;
505        Ok(Self {
506            child,
507            #[cfg(unix)]
508            process_group_id: process_id,
509            finished: false,
510            #[cfg(windows)]
511            job,
512        })
513    }
514}
515
516impl Drop for HookChild {
517    fn drop(&mut self) {
518        if self.finished {
519            return;
520        }
521        terminate_hook_process_tree(self);
522        let _ = self.child.start_kill();
523    }
524}
525
526async fn kill_hook_process_tree(child: &mut HookChild) {
527    terminate_hook_process_tree(child);
528    let _ = child.child.start_kill();
529    let _ = child.child.wait().await;
530}
531
532fn terminate_hook_process_tree(child: &HookChild) {
533    #[cfg(unix)]
534    {
535        // SAFETY: every hook is spawned as the leader of its own process group,
536        // and the group id is captured before the direct child can exit.
537        unsafe {
538            libc::kill(-(child.process_group_id as libc::pid_t), libc::SIGKILL);
539        }
540    }
541    #[cfg(windows)]
542    {
543        let _ = child.job.terminate();
544    }
545    #[cfg(not(any(unix, windows)))]
546    {
547        let _ = child;
548    }
549}
550
551#[cfg(windows)]
552struct WindowsJob {
553    handle: std::os::windows::io::OwnedHandle,
554}
555
556#[cfg(windows)]
557impl WindowsJob {
558    fn assign_and_resume(child: &Child, process_id: u32) -> std::io::Result<Self> {
559        use std::os::windows::io::{FromRawHandle, RawHandle};
560        use std::ptr;
561        use windows_sys::Win32::System::JobObjects::{AssignProcessToJobObject, CreateJobObjectW};
562
563        let raw_job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
564        if raw_job.is_null() {
565            return Err(std::io::Error::last_os_error());
566        }
567        let job = Self {
568            handle: unsafe {
569                std::os::windows::io::OwnedHandle::from_raw_handle(raw_job as RawHandle)
570            },
571        };
572        let process_handle = child.raw_handle().ok_or_else(|| {
573            std::io::Error::new(
574                ErrorKind::NotFound,
575                "lifecycle hook exited before Job Object assignment",
576            )
577        })?;
578        if unsafe { AssignProcessToJobObject(job.raw_handle(), process_handle.cast()) } == 0 {
579            return Err(std::io::Error::last_os_error());
580        }
581        resume_suspended_process(process_id)?;
582        Ok(job)
583    }
584
585    fn raw_handle(&self) -> windows_sys::Win32::Foundation::HANDLE {
586        use std::os::windows::io::AsRawHandle;
587
588        self.handle.as_raw_handle().cast()
589    }
590
591    fn terminate(&self) -> std::io::Result<()> {
592        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
593
594        if unsafe { TerminateJobObject(self.raw_handle(), 1) } == 0 {
595            return Err(std::io::Error::last_os_error());
596        }
597        Ok(())
598    }
599}
600
601#[cfg(windows)]
602fn resume_suspended_process(process_id: u32) -> std::io::Result<()> {
603    use std::mem::size_of;
604    use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle};
605    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
606    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
607        CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
608    };
609    use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
610
611    let raw_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
612    if raw_snapshot == INVALID_HANDLE_VALUE {
613        return Err(std::io::Error::last_os_error());
614    }
615    let snapshot = unsafe { OwnedHandle::from_raw_handle(raw_snapshot as RawHandle) };
616    let mut entry = THREADENTRY32 {
617        dwSize: size_of::<THREADENTRY32>() as u32,
618        ..unsafe { std::mem::zeroed() }
619    };
620    let mut has_entry = unsafe { Thread32First(snapshot.as_raw_handle().cast(), &mut entry) } != 0;
621    while has_entry {
622        if entry.th32OwnerProcessID == process_id {
623            let raw_thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
624            if raw_thread.is_null() {
625                return Err(std::io::Error::last_os_error());
626            }
627            let thread = unsafe { OwnedHandle::from_raw_handle(raw_thread as RawHandle) };
628            if unsafe { ResumeThread(thread.as_raw_handle().cast()) } == u32::MAX {
629                return Err(std::io::Error::last_os_error());
630            }
631            return Ok(());
632        }
633        has_entry = unsafe { Thread32Next(snapshot.as_raw_handle().cast(), &mut entry) } != 0;
634    }
635    Err(std::io::Error::new(
636        ErrorKind::NotFound,
637        "suspended lifecycle hook primary thread was not found",
638    ))
639}
640
641enum HookIoEvent {
642    Input(std::io::Result<()>),
643    Stdout(std::io::Result<CapturedOutput>),
644    Stderr(std::io::Result<CapturedOutput>),
645}
646
647#[derive(Default)]
648struct HookIoResults {
649    input: Option<std::io::Result<()>>,
650    stdout: Option<std::io::Result<CapturedOutput>>,
651    stderr: Option<std::io::Result<CapturedOutput>>,
652}
653
654impl HookIoResults {
655    fn is_complete(&self) -> bool {
656        self.input.is_some() && self.stdout.is_some() && self.stderr.is_some()
657    }
658
659    fn record(&mut self, event: HookIoEvent) -> Result<(), String> {
660        match event {
661            HookIoEvent::Input(result) if self.input.is_none() => self.input = Some(result),
662            HookIoEvent::Stdout(result) if self.stdout.is_none() => self.stdout = Some(result),
663            HookIoEvent::Stderr(result) if self.stderr.is_none() => self.stderr = Some(result),
664            HookIoEvent::Input(_) => {
665                return Err("duplicate lifecycle hook stdin result".to_string())
666            }
667            HookIoEvent::Stdout(_) => {
668                return Err("duplicate lifecycle hook stdout result".to_string())
669            }
670            HookIoEvent::Stderr(_) => {
671                return Err("duplicate lifecycle hook stderr result".to_string())
672            }
673        }
674        Ok(())
675    }
676
677    fn finish(self, hook_name: &str) -> Result<(CapturedOutput, CapturedOutput), String> {
678        let Self {
679            input,
680            stdout,
681            stderr,
682        } = self;
683        match input.ok_or_else(|| "lifecycle hook stdin task ended unexpectedly".to_string())? {
684            Ok(()) => {}
685            Err(error) => {
686                warn!(hook = hook_name, error = %error, "failed writing lifecycle hook stdin");
687            }
688        }
689        let stdout = stdout
690            .ok_or_else(|| "lifecycle hook stdout task ended unexpectedly".to_string())?
691            .map_err(|error| format!("failed reading lifecycle hook stdout: {error}"))?;
692        let stderr = stderr
693            .ok_or_else(|| "lifecycle hook stderr task ended unexpectedly".to_string())?
694            .map_err(|error| format!("failed reading lifecycle hook stderr: {error}"))?;
695        Ok((stdout, stderr))
696    }
697
698    fn finish_after_timeout(self, hook_name: &str) -> (CapturedOutput, CapturedOutput) {
699        let Self {
700            input,
701            stdout,
702            stderr,
703        } = self;
704        if let Some(Err(error)) = input {
705            warn!(hook = hook_name, error = %error, "failed writing lifecycle hook stdin");
706        }
707        let stdout = match stdout {
708            Some(Ok(stdout)) => stdout,
709            Some(Err(error)) => {
710                warn!(hook = hook_name, error = %error, "failed reading lifecycle hook stdout");
711                CapturedOutput::default()
712            }
713            None => CapturedOutput::default(),
714        };
715        let stderr = match stderr {
716            Some(Ok(stderr)) => stderr,
717            Some(Err(error)) => {
718                warn!(hook = hook_name, error = %error, "failed reading lifecycle hook stderr");
719                CapturedOutput::default()
720            }
721            None => CapturedOutput::default(),
722        };
723        (stdout, stderr)
724    }
725}
726
727async fn receive_hook_io(
728    receiver: &mut mpsc::Receiver<HookIoEvent>,
729    results: &mut HookIoResults,
730) -> Result<(), String> {
731    while !results.is_complete() {
732        let event = receiver
733            .recv()
734            .await
735            .ok_or_else(|| "lifecycle hook I/O task ended unexpectedly".to_string())?;
736        results.record(event)?;
737    }
738    Ok(())
739}
740
741#[async_trait]
742impl AgentHook for ShellCommandHook {
743    fn point(&self) -> AgentHookPoint {
744        self.event.point()
745    }
746
747    async fn run(
748        &self,
749        _point: AgentHookPoint,
750        payload: &HookPayload,
751        session: &Session,
752    ) -> HookResult {
753        let cwd = self.effective_cwd(session);
754        let input = match serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref())) {
755            Ok(input) => input,
756            Err(error) => {
757                warn!(hook = %self.name, error = %error, "failed serializing lifecycle hook payload");
758                return HookResult::Continue;
759            }
760        };
761        match self.execute(input, cwd.as_ref(), session).await {
762            Ok(output) => self.interpret(output),
763            Err(error) => {
764                warn!(hook = %self.name, error = %error, "lifecycle hook execution failed non-blocking");
765                HookResult::Continue
766            }
767        }
768    }
769
770    fn matches(&self, payload: &HookPayload) -> bool {
771        let Some(matcher) = &self.matcher else {
772            return true;
773        };
774        match payload {
775            HookPayload::ToolExecution { tool_name, .. }
776            | HookPayload::ToolResult { tool_name, .. } => matcher.is_match(tool_name),
777            _ => false,
778        }
779    }
780
781    fn name(&self) -> &str {
782        &self.name
783    }
784}
785
786/// One config-driven lifecycle script executed by an installed system runtime.
787///
788/// Scripts receive Bamboo's versioned hook envelope on stdin and emit the same
789/// JSON response protocol as command hooks on stdout.
790pub struct ScriptHook {
791    event: LifecycleHookEvent,
792    path: String,
793    runner: LifecycleScriptRunner,
794    timeout: Duration,
795    matcher: Option<Regex>,
796    fallback_cwd: Option<PathBuf>,
797    name: String,
798}
799
800impl ScriptHook {
801    pub fn new(
802        event: LifecycleHookEvent,
803        path: impl Into<String>,
804        runner: LifecycleScriptRunner,
805        timeout_ms: u64,
806        matcher: Option<&str>,
807        fallback_cwd: Option<PathBuf>,
808        sequence: usize,
809    ) -> Result<Self, regex::Error> {
810        Ok(Self {
811            event,
812            path: path.into(),
813            runner,
814            timeout: Duration::from_millis(timeout_ms.max(1)),
815            matcher: matcher.map(Regex::new).transpose()?,
816            fallback_cwd,
817            name: format!("lifecycle_script:{}:{sequence}", event.as_str()),
818        })
819    }
820
821    fn effective_cwd(&self, session: &Session) -> Option<PathBuf> {
822        session
823            .workspace
824            .as_deref()
825            .filter(|workspace| !workspace.trim().is_empty())
826            .map(PathBuf::from)
827            .or_else(|| self.fallback_cwd.clone())
828            .or_else(|| std::env::current_dir().ok())
829    }
830
831    fn envelope(
832        &self,
833        payload: &HookPayload,
834        session: &Session,
835        cwd: Option<&PathBuf>,
836    ) -> HookEnvelope {
837        build_hook_envelope(self.event.as_str(), payload, session, cwd)
838    }
839
840    async fn resolve_path(&self, cwd: Option<&PathBuf>) -> Result<PathBuf, String> {
841        let configured = PathBuf::from(self.path.trim());
842        let resolved = if configured.is_absolute() {
843            configured
844        } else {
845            let cwd = cwd.ok_or_else(|| {
846                format!(
847                    "cannot resolve relative lifecycle script path '{}' without a working directory",
848                    self.path
849                )
850            })?;
851            cwd.join(configured)
852        };
853        let metadata = tokio::fs::metadata(&resolved).await.map_err(|error| {
854            format!(
855                "lifecycle script '{}' is not accessible: {error}",
856                resolved.display()
857            )
858        })?;
859        if !metadata.is_file() {
860            return Err(format!(
861                "lifecycle script '{}' is not a file",
862                resolved.display()
863            ));
864        }
865        Ok(resolved)
866    }
867
868    async fn execute(
869        &self,
870        input: Vec<u8>,
871        cwd: Option<&PathBuf>,
872        session: &Session,
873    ) -> Result<CommandOutput, String> {
874        let path = self.resolve_path(cwd).await?;
875        let configured_path = self.path.trim();
876        let invocations = script_invocations(configured_path, &path, self.runner)?;
877        let overrides = bamboo_llm::Config::current_env_vars();
878        let prepared_env = build_command_environment(&overrides).await;
879        let mut missing_runtimes = Vec::new();
880
881        for invocation in invocations {
882            let mut command = Command::new(&invocation.program);
883            prepared_env.apply_to_tokio_command(&mut command);
884            if let Some(cwd) = cwd {
885                command.current_dir(cwd);
886            }
887            command
888                .args(&invocation.args)
889                .env("BAMBOO_SESSION_ID", &session.id)
890                .env("BAMBOO_HOOK_EVENT", self.event.as_str())
891                .env("BAMBOO_HOOK_SCRIPT", &path)
892                .stdin(Stdio::piped())
893                .stdout(Stdio::piped())
894                .stderr(Stdio::piped());
895            configure_hook_process(&mut command);
896
897            match command.spawn() {
898                Ok(child) => {
899                    let child = HookChild::new(child)?;
900                    return capture_hook_child(child, input, self.timeout, &self.name).await;
901                }
902                Err(error) if error.kind() == ErrorKind::NotFound => {
903                    missing_runtimes.push(invocation.program.to_string_lossy().into_owned());
904                }
905                Err(error) => {
906                    return Err(format!(
907                        "failed to spawn lifecycle script runtime '{}': {error}",
908                        invocation.program.to_string_lossy()
909                    ));
910                }
911            }
912        }
913
914        Err(format!(
915            "no lifecycle script runtime was found for '{}'; tried {}",
916            self.path,
917            missing_runtimes.join(", ")
918        ))
919    }
920
921    fn interpret(&self, output: CommandOutput) -> HookResult {
922        if output.stdout.truncated || output.stderr.truncated {
923            warn!(
924                hook = %self.name,
925                stdout_truncated = output.stdout.truncated,
926                stderr_truncated = output.stderr.truncated,
927                "lifecycle script output exceeded capture limit"
928            );
929        }
930        if output.timed_out {
931            warn!(hook = %self.name, "lifecycle script timed out and was killed");
932            return HookResult::Continue;
933        }
934
935        match output.exit_code {
936            Some(0) => {
937                if output.stdout.truncated {
938                    return HookResult::Continue;
939                }
940                let stdout = String::from_utf8_lossy(&output.stdout.bytes);
941                let stdout = stdout.trim();
942                if stdout.is_empty() {
943                    return HookResult::Continue;
944                }
945                match serde_json::from_str::<HookResponse>(stdout) {
946                    Ok(response) => interpret_response(response),
947                    Err(error) => {
948                        warn!(
949                            hook = %self.name,
950                            error = %error,
951                            "ignoring malformed lifecycle script response"
952                        );
953                        HookResult::Continue
954                    }
955                }
956            }
957            Some(2) => {
958                let reason = String::from_utf8_lossy(&output.stderr.bytes)
959                    .trim()
960                    .to_string();
961                HookResult::Deny {
962                    reason: if reason.is_empty() {
963                        "lifecycle script exited with blocking status 2".to_string()
964                    } else {
965                        reason
966                    },
967                }
968            }
969            exit_code => {
970                warn!(hook = %self.name, ?exit_code, "lifecycle script failed non-blocking");
971                HookResult::Continue
972            }
973        }
974    }
975
976    async fn test(
977        &self,
978        payload: &HookPayload,
979        session: &Session,
980    ) -> Result<LifecycleHookTestOutput, String> {
981        let cwd = self.effective_cwd(session);
982        let input = serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref()))
983            .map_err(|error| format!("failed serializing lifecycle hook test payload: {error}"))?;
984        match self.execute(input, cwd.as_ref(), session).await {
985            Ok(output) => Ok(LifecycleHookTestOutput {
986                exit_code: output.exit_code,
987                stdout: String::from_utf8_lossy(&output.stdout.bytes).into_owned(),
988                stderr: String::from_utf8_lossy(&output.stderr.bytes).into_owned(),
989                timed_out: output.timed_out,
990                stdout_truncated: output.stdout.truncated,
991                stderr_truncated: output.stderr.truncated,
992            }),
993            Err(error) => {
994                let (stderr, stderr_truncated) = cap_text(error);
995                Ok(LifecycleHookTestOutput {
996                    exit_code: None,
997                    stdout: String::new(),
998                    stderr,
999                    timed_out: false,
1000                    stdout_truncated: false,
1001                    stderr_truncated,
1002                })
1003            }
1004        }
1005    }
1006}
1007
1008#[async_trait]
1009impl AgentHook for ScriptHook {
1010    fn point(&self) -> AgentHookPoint {
1011        self.event.point()
1012    }
1013
1014    async fn run(
1015        &self,
1016        _point: AgentHookPoint,
1017        payload: &HookPayload,
1018        session: &Session,
1019    ) -> HookResult {
1020        let cwd = self.effective_cwd(session);
1021        let input = match serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref())) {
1022            Ok(input) => input,
1023            Err(error) => {
1024                warn!(
1025                    hook = %self.name,
1026                    error = %error,
1027                    "failed serializing lifecycle script payload"
1028                );
1029                return HookResult::Continue;
1030            }
1031        };
1032        match self.execute(input, cwd.as_ref(), session).await {
1033            Ok(output) => self.interpret(output),
1034            Err(error) => {
1035                warn!(
1036                    hook = %self.name,
1037                    error = %error,
1038                    "lifecycle script execution failed non-blocking"
1039                );
1040                HookResult::Continue
1041            }
1042        }
1043    }
1044
1045    fn matches(&self, payload: &HookPayload) -> bool {
1046        let Some(matcher) = &self.matcher else {
1047            return true;
1048        };
1049        match payload {
1050            HookPayload::ToolExecution { tool_name, .. }
1051            | HookPayload::ToolResult { tool_name, .. } => matcher.is_match(tool_name),
1052            _ => false,
1053        }
1054    }
1055
1056    fn name(&self) -> &str {
1057        &self.name
1058    }
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Eq)]
1062struct ScriptInvocation {
1063    program: OsString,
1064    args: Vec<OsString>,
1065}
1066
1067impl ScriptInvocation {
1068    fn with_path(program: impl Into<OsString>, leading_args: &[&str], path: &Path) -> Self {
1069        let mut args = leading_args.iter().map(OsString::from).collect::<Vec<_>>();
1070        args.push(path.as_os_str().to_owned());
1071        Self {
1072            program: program.into(),
1073            args,
1074        }
1075    }
1076}
1077
1078fn script_invocations(
1079    configured_path: &str,
1080    resolved_path: &Path,
1081    runner: LifecycleScriptRunner,
1082) -> Result<Vec<ScriptInvocation>, String> {
1083    let extension = lifecycle_script_extension(configured_path).ok_or_else(|| {
1084        format!(
1085            "unsupported lifecycle script extension for '{configured_path}'; expected .js, .mjs, .cjs, .py, .sh, .ps1, .bat, or .cmd"
1086        )
1087    })?;
1088    if !runner.supports_path(configured_path) {
1089        return Err(format!(
1090            "lifecycle script runner '{}' cannot execute '.{extension}' files",
1091            runner.as_str()
1092        ));
1093    }
1094
1095    let selected = match runner {
1096        LifecycleScriptRunner::Auto => match extension.as_str() {
1097            "js" | "mjs" | "cjs" => vec![
1098                ScriptInvocation::with_path("node", &[], resolved_path),
1099                ScriptInvocation::with_path("bun", &["run"], resolved_path),
1100            ],
1101            "py" => python_invocations(resolved_path),
1102            "sh" => system_shell_invocations(resolved_path),
1103            "ps1" => powershell_invocations(resolved_path),
1104            "bat" | "cmd" => cmd_invocations(resolved_path)?,
1105            _ => unreachable!("supported extensions are matched exhaustively"),
1106        },
1107        LifecycleScriptRunner::Node => {
1108            vec![ScriptInvocation::with_path("node", &[], resolved_path)]
1109        }
1110        LifecycleScriptRunner::Bun => {
1111            vec![ScriptInvocation::with_path("bun", &["run"], resolved_path)]
1112        }
1113        LifecycleScriptRunner::Python => python_invocations(resolved_path),
1114        LifecycleScriptRunner::Bash => bash_invocations(resolved_path),
1115        LifecycleScriptRunner::PowerShell => powershell_invocations(resolved_path),
1116        LifecycleScriptRunner::Cmd => cmd_invocations(resolved_path)?,
1117    };
1118    Ok(selected)
1119}
1120
1121fn python_invocations(path: &Path) -> Vec<ScriptInvocation> {
1122    let invocations = vec![
1123        ScriptInvocation::with_path("python3", &[], path),
1124        ScriptInvocation::with_path("python", &[], path),
1125    ];
1126    #[cfg(windows)]
1127    let invocations = {
1128        let mut invocations = invocations;
1129        invocations.push(ScriptInvocation::with_path("py", &["-3"], path));
1130        invocations
1131    };
1132    invocations
1133}
1134
1135fn system_shell_invocations(path: &Path) -> Vec<ScriptInvocation> {
1136    let shell = preferred_bash_shell();
1137    #[cfg(windows)]
1138    let program = if shell.arg == "-lc" {
1139        shell.program
1140    } else {
1141        "bash".to_string()
1142    };
1143    #[cfg(not(windows))]
1144    let program = shell.program;
1145    vec![ScriptInvocation::with_path(program, &[], path)]
1146}
1147
1148fn bash_invocations(path: &Path) -> Vec<ScriptInvocation> {
1149    #[cfg(windows)]
1150    {
1151        return system_shell_invocations(path);
1152    }
1153    #[cfg(not(windows))]
1154    {
1155        vec![ScriptInvocation::with_path("bash", &[], path)]
1156    }
1157}
1158
1159fn powershell_invocations(path: &Path) -> Vec<ScriptInvocation> {
1160    vec![
1161        ScriptInvocation::with_path(
1162            "pwsh",
1163            &["-NoLogo", "-NoProfile", "-NonInteractive", "-File"],
1164            path,
1165        ),
1166        ScriptInvocation::with_path(
1167            "powershell",
1168            &["-NoLogo", "-NoProfile", "-NonInteractive", "-File"],
1169            path,
1170        ),
1171    ]
1172}
1173
1174#[cfg(windows)]
1175fn cmd_invocations(path: &Path) -> Result<Vec<ScriptInvocation>, String> {
1176    Ok(vec![ScriptInvocation::with_path(
1177        "cmd.exe",
1178        &["/D", "/C", "call"],
1179        path,
1180    )])
1181}
1182
1183#[cfg(not(windows))]
1184fn cmd_invocations(_path: &Path) -> Result<Vec<ScriptInvocation>, String> {
1185    Err("batch lifecycle scripts require Windows and cmd.exe".to_string())
1186}
1187
1188fn cap_text(mut text: String) -> (String, bool) {
1189    if text.len() <= HOOK_OUTPUT_LIMIT_BYTES {
1190        return (text, false);
1191    }
1192    let mut boundary = HOOK_OUTPUT_LIMIT_BYTES;
1193    while !text.is_char_boundary(boundary) {
1194        boundary -= 1;
1195    }
1196    text.truncate(boundary);
1197    (text, true)
1198}
1199
1200fn build_hook_envelope(
1201    event_name: &str,
1202    payload: &HookPayload,
1203    session: &Session,
1204    cwd: Option<&PathBuf>,
1205) -> HookEnvelope {
1206    let (
1207        tool_name,
1208        tool_input,
1209        tool_response,
1210        prompt,
1211        source,
1212        stop_hook_active,
1213        terminal_status,
1214        completion_reason,
1215    ) = match payload {
1216        HookPayload::SessionSetup {
1217            initial_message,
1218            source,
1219        } => (
1220            None,
1221            None,
1222            None,
1223            Some(initial_message.clone()),
1224            Some(*source),
1225            None,
1226            None,
1227            None,
1228        ),
1229        HookPayload::Prompt { prompt } => (
1230            None,
1231            None,
1232            None,
1233            Some(prompt.clone()),
1234            None,
1235            None,
1236            None,
1237            None,
1238        ),
1239        HookPayload::ToolExecution {
1240            tool_name,
1241            parsed_args,
1242            ..
1243        } => (
1244            Some(tool_name.clone()),
1245            Some(parsed_args.clone()),
1246            None,
1247            None,
1248            None,
1249            None,
1250            None,
1251            None,
1252        ),
1253        HookPayload::ToolResult {
1254            tool_name, outcome, ..
1255        } => (
1256            Some(tool_name.clone()),
1257            None,
1258            serde_json::to_value(outcome).ok(),
1259            None,
1260            None,
1261            None,
1262            None,
1263            None,
1264        ),
1265        HookPayload::Finalize { stop_hook_active } => (
1266            None,
1267            None,
1268            None,
1269            None,
1270            None,
1271            Some(*stop_hook_active),
1272            None,
1273            None,
1274        ),
1275        HookPayload::SessionEnd {
1276            status,
1277            completion_reason,
1278        } => (
1279            None,
1280            None,
1281            None,
1282            None,
1283            None,
1284            None,
1285            Some(*status),
1286            completion_reason.clone(),
1287        ),
1288        HookPayload::None
1289        | HookPayload::Round { .. }
1290        | HookPayload::Compression { .. }
1291        | HookPayload::Notification { .. } => (None, None, None, None, None, None, None, None),
1292    };
1293
1294    HookEnvelope {
1295        schema_version: 1,
1296        hook_event_name: event_name.to_string(),
1297        session_id: session.id.clone(),
1298        workspace_path: cwd
1299            .map(|path| path.to_string_lossy().into_owned())
1300            .unwrap_or_default(),
1301        model: session.model.clone(),
1302        payload: payload.clone(),
1303        tool_name,
1304        tool_input,
1305        tool_response,
1306        prompt,
1307        source,
1308        stop_hook_active,
1309        terminal_status,
1310        completion_reason,
1311        timestamp: Utc::now().to_rfc3339(),
1312    }
1313}
1314
1315#[derive(Debug, Serialize)]
1316struct HookEnvelope {
1317    schema_version: u8,
1318    hook_event_name: String,
1319    session_id: String,
1320    workspace_path: String,
1321    model: String,
1322    /// Complete, event-specific payload. Legacy convenience fields below stay
1323    /// populated for existing tool/prompt/session hook commands.
1324    payload: HookPayload,
1325    #[serde(skip_serializing_if = "Option::is_none")]
1326    tool_name: Option<String>,
1327    #[serde(skip_serializing_if = "Option::is_none")]
1328    tool_input: Option<serde_json::Value>,
1329    #[serde(skip_serializing_if = "Option::is_none")]
1330    tool_response: Option<serde_json::Value>,
1331    #[serde(skip_serializing_if = "Option::is_none")]
1332    prompt: Option<String>,
1333    #[serde(skip_serializing_if = "Option::is_none")]
1334    source: Option<SessionStartSource>,
1335    #[serde(skip_serializing_if = "Option::is_none")]
1336    stop_hook_active: Option<bool>,
1337    #[serde(skip_serializing_if = "Option::is_none")]
1338    terminal_status: Option<SessionEndStatus>,
1339    #[serde(skip_serializing_if = "Option::is_none")]
1340    completion_reason: Option<String>,
1341    timestamp: String,
1342}
1343
1344#[derive(Debug, Deserialize)]
1345struct HookResponse {
1346    #[serde(default)]
1347    decision: Option<HookDecision>,
1348    #[serde(default)]
1349    reason: Option<String>,
1350    #[serde(default)]
1351    additional_context: Option<String>,
1352    #[serde(default)]
1353    suppress_output: bool,
1354}
1355
1356#[derive(Debug, Deserialize)]
1357#[serde(rename_all = "lowercase")]
1358enum HookDecision {
1359    Block,
1360    Allow,
1361    Ask,
1362}
1363
1364fn interpret_response(response: HookResponse) -> HookResult {
1365    let HookResponse {
1366        decision,
1367        reason,
1368        additional_context,
1369        suppress_output: _suppress_output,
1370    } = response;
1371    let result = match decision {
1372        Some(HookDecision::Block) => HookResult::Deny {
1373            reason: reason
1374                .filter(|reason| !reason.trim().is_empty())
1375                .unwrap_or_else(|| "blocked by lifecycle hook".to_string()),
1376        },
1377        Some(HookDecision::Allow) => HookResult::Allow,
1378        Some(HookDecision::Ask) => HookResult::Ask,
1379        None => HookResult::Continue,
1380    };
1381    match additional_context.filter(|context| !context.trim().is_empty()) {
1382        Some(text) if matches!(result, HookResult::Continue) => HookResult::InjectContext { text },
1383        Some(text) => HookResult::WithContext {
1384            result: Box::new(result),
1385            text,
1386        },
1387        None => result,
1388    }
1389}
1390
1391#[derive(Debug)]
1392struct CommandOutput {
1393    exit_code: Option<i32>,
1394    stdout: CapturedOutput,
1395    stderr: CapturedOutput,
1396    timed_out: bool,
1397}
1398
1399#[derive(Debug, Default)]
1400struct CapturedOutput {
1401    bytes: Vec<u8>,
1402    truncated: bool,
1403}
1404
1405async fn read_capped(mut reader: impl AsyncRead + Unpin) -> Result<CapturedOutput, std::io::Error> {
1406    let mut captured = CapturedOutput::default();
1407    let mut chunk = [0_u8; 8192];
1408    loop {
1409        let read = reader.read(&mut chunk).await?;
1410        if read == 0 {
1411            break;
1412        }
1413        let remaining = HOOK_OUTPUT_LIMIT_BYTES.saturating_sub(captured.bytes.len());
1414        let keep = remaining.min(read);
1415        captured.bytes.extend_from_slice(&chunk[..keep]);
1416        captured.truncated |= keep < read;
1417    }
1418    Ok(captured)
1419}
1420
1421pub(super) fn register_configured_hooks(
1422    dispatcher: &mut HookDispatcher,
1423    config: &LifecycleHooksConfig,
1424    fallback_cwd: Option<PathBuf>,
1425) {
1426    if !config.enabled {
1427        return;
1428    }
1429
1430    let events: [(LifecycleHookEvent, &[LifecycleHookGroup]); 8] = [
1431        (LifecycleHookEvent::SessionStart, &config.session_start),
1432        (
1433            LifecycleHookEvent::UserPromptSubmit,
1434            &config.user_prompt_submit,
1435        ),
1436        (LifecycleHookEvent::PreToolUse, &config.pre_tool_use),
1437        (LifecycleHookEvent::PostToolUse, &config.post_tool_use),
1438        (LifecycleHookEvent::Stop, &config.stop),
1439        (LifecycleHookEvent::SessionEnd, &config.session_end),
1440        (LifecycleHookEvent::PreCompact, &config.pre_compact),
1441        (LifecycleHookEvent::Notification, &config.notification),
1442    ];
1443    let mut sequence = 0_usize;
1444    for (event, groups) in events {
1445        for group in groups {
1446            if !group.enabled {
1447                sequence += group.hooks.len();
1448                continue;
1449            }
1450            let matcher = if event.supports_tool_matcher() {
1451                group.matcher.as_deref()
1452            } else {
1453                if group.matcher.is_some() {
1454                    warn!(
1455                        event = event.as_str(),
1456                        "ignoring matcher on non-tool lifecycle hook"
1457                    );
1458                }
1459                None
1460            };
1461            for handler in &group.hooks {
1462                let hook: Result<Arc<dyn AgentHook>, regex::Error> = match handler {
1463                    LifecycleHookHandler::Command {
1464                        command,
1465                        timeout_ms,
1466                    } => ShellCommandHook::new(
1467                        event,
1468                        command,
1469                        *timeout_ms,
1470                        matcher,
1471                        fallback_cwd.clone(),
1472                        sequence,
1473                    )
1474                    .map(|hook| Arc::new(hook) as Arc<dyn AgentHook>),
1475                    LifecycleHookHandler::Script {
1476                        path,
1477                        runner,
1478                        timeout_ms,
1479                    } => ScriptHook::new(
1480                        event,
1481                        path,
1482                        *runner,
1483                        *timeout_ms,
1484                        matcher,
1485                        fallback_cwd.clone(),
1486                        sequence,
1487                    )
1488                    .map(|hook| Arc::new(hook) as Arc<dyn AgentHook>),
1489                };
1490                match hook {
1491                    Ok(hook) => dispatcher.register(hook),
1492                    Err(error) => {
1493                        warn!(
1494                            event = event.as_str(),
1495                            error = %error,
1496                            "skipping lifecycle hook with invalid matcher"
1497                        );
1498                    }
1499                }
1500                sequence += 1;
1501            }
1502        }
1503    }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509    use bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS;
1510    use serde_json::json;
1511    use std::time::Instant;
1512
1513    fn command(command: impl Into<String>, timeout_ms: u64) -> LifecycleHookHandler {
1514        LifecycleHookHandler::command(command, timeout_ms)
1515    }
1516
1517    fn script(
1518        path: impl Into<String>,
1519        runner: LifecycleScriptRunner,
1520        timeout_ms: u64,
1521    ) -> LifecycleHookHandler {
1522        LifecycleHookHandler::script(path, runner, timeout_ms)
1523    }
1524
1525    fn shell_hook(
1526        event: LifecycleHookEvent,
1527        command: impl Into<String>,
1528        timeout_ms: u64,
1529        matcher: Option<&str>,
1530        sequence: usize,
1531    ) -> ShellCommandHook {
1532        ShellCommandHook::new(event, command, timeout_ms, matcher, None, sequence).unwrap()
1533    }
1534
1535    fn script_hook(
1536        event: LifecycleHookEvent,
1537        path: impl Into<String>,
1538        runner: LifecycleScriptRunner,
1539        timeout_ms: u64,
1540        matcher: Option<&str>,
1541    ) -> ScriptHook {
1542        ScriptHook::new(event, path, runner, timeout_ms, matcher, None, 0).unwrap()
1543    }
1544
1545    fn write_script(dir: &tempfile::TempDir, name: &str, body: &str) -> String {
1546        std::fs::write(dir.path().join(name), body).unwrap();
1547        name.to_string()
1548    }
1549
1550    fn session(workspace: &std::path::Path) -> Session {
1551        let mut session = Session::new("session-1", "test-model");
1552        session.workspace = Some(workspace.to_string_lossy().into_owned());
1553        session
1554    }
1555
1556    #[cfg(unix)]
1557    fn process_is_alive(process_id: u32) -> bool {
1558        let result = unsafe { libc::kill(process_id as libc::pid_t, 0) };
1559        result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1560    }
1561
1562    #[cfg(windows)]
1563    fn process_is_alive(process_id: u32) -> bool {
1564        use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
1565        use windows_sys::Win32::System::Threading::{
1566            OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
1567        };
1568
1569        let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, process_id) };
1570        if handle.is_null() {
1571            return false;
1572        }
1573        let wait_result = unsafe { WaitForSingleObject(handle, 0) };
1574        unsafe {
1575            CloseHandle(handle);
1576        }
1577        wait_result == WAIT_TIMEOUT
1578    }
1579
1580    #[cfg(not(any(unix, windows)))]
1581    fn process_is_alive(_process_id: u32) -> bool {
1582        false
1583    }
1584
1585    async fn assert_process_exits(process_id: u32) {
1586        for _ in 0..100 {
1587            if !process_is_alive(process_id) {
1588                return;
1589            }
1590            tokio::time::sleep(Duration::from_millis(10)).await;
1591        }
1592        assert!(
1593            !process_is_alive(process_id),
1594            "hook descendant process {process_id} survived timeout cleanup"
1595        );
1596    }
1597
1598    #[tokio::test]
1599    async fn exit_zero_without_output_passes_through() {
1600        let dir = tempfile::tempdir().unwrap();
1601        let hook = shell_hook(
1602            ShellHookEvent::SessionStart,
1603            "printf ''",
1604            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
1605            None,
1606            0,
1607        );
1608        let result = hook
1609            .run(
1610                AgentHookPoint::AfterSessionSetup,
1611                &HookPayload::SessionSetup {
1612                    initial_message: "hello".to_string(),
1613                    source: SessionStartSource::Startup,
1614                },
1615                &session(dir.path()),
1616            )
1617            .await;
1618        assert_eq!(result, HookResult::Continue);
1619    }
1620
1621    #[tokio::test]
1622    async fn exit_two_blocks_with_stderr_reason() {
1623        let dir = tempfile::tempdir().unwrap();
1624        let hook = shell_hook(
1625            ShellHookEvent::PreToolUse,
1626            "printf 'blocked by policy' >&2; exit 2",
1627            1_000,
1628            None,
1629            0,
1630        );
1631        let result = hook
1632            .run(
1633                AgentHookPoint::BeforeToolExecution,
1634                &HookPayload::ToolExecution {
1635                    tool_name: "bash".to_string(),
1636                    tool_call_id: "call-1".to_string(),
1637                    parsed_args: json!({"command": "pwd"}),
1638                },
1639                &session(dir.path()),
1640            )
1641            .await;
1642        assert_eq!(
1643            result,
1644            HookResult::Deny {
1645                reason: "blocked by policy".to_string()
1646            }
1647        );
1648    }
1649
1650    #[tokio::test]
1651    async fn versioned_json_protocol_is_delivered_on_stdin() {
1652        let dir = tempfile::tempdir().unwrap();
1653        let shell = r#"payload=$(cat); case "$payload" in *'"hook_event_name":"PreToolUse"'*'"tool_name":"bash"'*) printf '%s' '{"decision":"allow"}' ;; *) printf 'bad payload' >&2; exit 2 ;; esac"#;
1654        let hook = shell_hook(ShellHookEvent::PreToolUse, shell, 1_000, None, 0);
1655        let result = hook
1656            .run(
1657                AgentHookPoint::BeforeToolExecution,
1658                &HookPayload::ToolExecution {
1659                    tool_name: "bash".to_string(),
1660                    tool_call_id: "call-1".to_string(),
1661                    parsed_args: json!({"command": "pwd"}),
1662                },
1663                &session(dir.path()),
1664            )
1665            .await;
1666        assert_eq!(result, HookResult::Allow);
1667    }
1668
1669    #[tokio::test]
1670    async fn stdout_decisions_and_context_are_parsed() {
1671        let dir = tempfile::tempdir().unwrap();
1672        for (body, expected) in [
1673            (r#"{"decision":"allow"}"#, HookResult::Allow),
1674            (r#"{"decision":"ask"}"#, HookResult::Ask),
1675            (
1676                r#"{"decision":"block","reason":"nope"}"#,
1677                HookResult::Deny {
1678                    reason: "nope".to_string(),
1679                },
1680            ),
1681            (
1682                r#"{"additional_context":"remember this"}"#,
1683                HookResult::InjectContext {
1684                    text: "remember this".to_string(),
1685                },
1686            ),
1687            (
1688                r#"{"decision":"allow","additional_context":"allowed context"}"#,
1689                HookResult::WithContext {
1690                    result: Box::new(HookResult::Allow),
1691                    text: "allowed context".to_string(),
1692                },
1693            ),
1694        ] {
1695            let shell = format!("printf '%s' '{body}'");
1696            let hook = shell_hook(ShellHookEvent::SessionStart, shell, 1_000, None, 0);
1697            assert_eq!(
1698                hook.run(
1699                    AgentHookPoint::AfterSessionSetup,
1700                    &HookPayload::None,
1701                    &session(dir.path()),
1702                )
1703                .await,
1704                expected
1705            );
1706        }
1707    }
1708
1709    #[tokio::test]
1710    async fn runner_preserves_decision_and_context_from_one_response() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let mut runner = HookDispatcher::new();
1713        runner.register(Arc::new(shell_hook(
1714            ShellHookEvent::SessionStart,
1715            r#"printf '%s' '{"decision":"allow","additional_context":"keep me"}'"#,
1716            1_000,
1717            None,
1718            0,
1719        )));
1720        let session = session(dir.path());
1721        let report = runner
1722            .run_hooks(
1723                AgentHookPoint::AfterSessionSetup,
1724                &HookPayload::None,
1725                &session,
1726            )
1727            .await;
1728        assert_eq!(report.outcome.decision, HookResult::Allow);
1729        assert_eq!(report.outcome.injected_contexts, vec!["keep me"]);
1730    }
1731
1732    #[tokio::test]
1733    async fn malformed_json_is_non_blocking() {
1734        let dir = tempfile::tempdir().unwrap();
1735        let hook = shell_hook(
1736            ShellHookEvent::SessionStart,
1737            "printf 'not-json'",
1738            1_000,
1739            None,
1740            0,
1741        );
1742        assert_eq!(
1743            hook.run(
1744                AgentHookPoint::AfterSessionSetup,
1745                &HookPayload::None,
1746                &session(dir.path()),
1747            )
1748            .await,
1749            HookResult::Continue
1750        );
1751    }
1752
1753    #[test]
1754    fn truncated_command_response_is_not_interpreted() {
1755        let hook = shell_hook(ShellHookEvent::SessionStart, "true", 1_000, None, 0);
1756        let output = CommandOutput {
1757            exit_code: Some(0),
1758            stdout: CapturedOutput {
1759                bytes: br#"{"decision":"block","reason":"must not apply"}"#.to_vec(),
1760                truncated: true,
1761            },
1762            stderr: CapturedOutput::default(),
1763            timed_out: false,
1764        };
1765
1766        assert_eq!(hook.interpret(output), HookResult::Continue);
1767    }
1768
1769    #[tokio::test]
1770    async fn timeout_kills_hook_and_continues() {
1771        let dir = tempfile::tempdir().unwrap();
1772        let hook = shell_hook(ShellHookEvent::SessionStart, "sleep 5", 25, None, 0);
1773        let started = Instant::now();
1774        let result = hook
1775            .run(
1776                AgentHookPoint::AfterSessionSetup,
1777                &HookPayload::None,
1778                &session(dir.path()),
1779            )
1780            .await;
1781        assert_eq!(result, HookResult::Continue);
1782        assert!(started.elapsed() < Duration::from_secs(2));
1783    }
1784
1785    #[test]
1786    fn script_invocations_cover_supported_system_runtimes() {
1787        let js = script_invocations(
1788            "guard.js",
1789            Path::new("guard.js"),
1790            LifecycleScriptRunner::Auto,
1791        )
1792        .unwrap();
1793        assert_eq!(js[0].program, OsString::from("node"));
1794        assert_eq!(js[1].program, OsString::from("bun"));
1795        assert_eq!(js[1].args[0], OsString::from("run"));
1796
1797        let bun = script_invocations(
1798            "guard.mjs",
1799            Path::new("guard.mjs"),
1800            LifecycleScriptRunner::Bun,
1801        )
1802        .unwrap();
1803        assert_eq!(bun[0].program, OsString::from("bun"));
1804
1805        let python = script_invocations(
1806            "guard.py",
1807            Path::new("guard.py"),
1808            LifecycleScriptRunner::Auto,
1809        )
1810        .unwrap();
1811        assert_eq!(python[0].program, OsString::from("python3"));
1812        assert_eq!(python[1].program, OsString::from("python"));
1813
1814        let shell = script_invocations(
1815            "guard.sh",
1816            Path::new("guard.sh"),
1817            LifecycleScriptRunner::Auto,
1818        )
1819        .unwrap();
1820        assert!(!shell[0].program.is_empty());
1821
1822        let powershell = script_invocations(
1823            "guard.ps1",
1824            Path::new("guard.ps1"),
1825            LifecycleScriptRunner::Auto,
1826        )
1827        .unwrap();
1828        assert_eq!(powershell[0].program, OsString::from("pwsh"));
1829        assert_eq!(powershell[1].program, OsString::from("powershell"));
1830
1831        assert!(script_invocations(
1832            "guard.py",
1833            Path::new("guard.py"),
1834            LifecycleScriptRunner::Node
1835        )
1836        .unwrap_err()
1837        .contains("cannot execute"));
1838    }
1839
1840    #[cfg(unix)]
1841    #[test]
1842    fn explicit_bash_runner_does_not_fall_back_to_sh() {
1843        let bash = script_invocations(
1844            "guard.sh",
1845            Path::new("guard.sh"),
1846            LifecycleScriptRunner::Bash,
1847        )
1848        .unwrap();
1849
1850        assert_eq!(bash[0].program, OsString::from("bash"));
1851    }
1852
1853    #[cfg(windows)]
1854    #[test]
1855    fn batch_scripts_use_cmd_on_windows() {
1856        let batch = script_invocations(
1857            "guard.bat",
1858            Path::new("guard.bat"),
1859            LifecycleScriptRunner::Auto,
1860        )
1861        .unwrap();
1862        assert_eq!(batch[0].program, OsString::from("cmd.exe"));
1863        assert_eq!(batch[0].args[0], OsString::from("/D"));
1864    }
1865
1866    #[cfg(windows)]
1867    #[tokio::test]
1868    async fn batch_script_executes_through_cmd() {
1869        let dir = tempfile::tempdir().unwrap();
1870        let script_dir = dir.path().join("hook scripts");
1871        std::fs::create_dir(&script_dir).unwrap();
1872        std::fs::write(
1873            script_dir.join("guard.bat"),
1874            "@echo off\r\necho {\"additional_context\":\"executed by bat\"}\r\n",
1875        )
1876        .unwrap();
1877        let hook = script_hook(
1878            LifecycleHookEvent::SessionStart,
1879            "hook scripts/guard.bat",
1880            LifecycleScriptRunner::Auto,
1881            2_000,
1882            None,
1883        );
1884
1885        assert_eq!(
1886            hook.run(
1887                AgentHookPoint::AfterSessionSetup,
1888                &HookPayload::None,
1889                &session(dir.path()),
1890            )
1891            .await,
1892            HookResult::InjectContext {
1893                text: "executed by bat".to_string(),
1894            }
1895        );
1896    }
1897
1898    #[cfg(not(windows))]
1899    #[test]
1900    fn batch_scripts_report_the_platform_requirement() {
1901        let error = script_invocations(
1902            "guard.bat",
1903            Path::new("guard.bat"),
1904            LifecycleScriptRunner::Auto,
1905        )
1906        .unwrap_err();
1907        assert!(error.contains("require Windows"), "{error}");
1908    }
1909
1910    #[tokio::test]
1911    async fn javascript_script_uses_system_runtime_and_reads_stdin_envelope() {
1912        let dir = tempfile::tempdir().unwrap();
1913        let path = write_script(
1914            &dir,
1915            "guard.js",
1916            r#"
1917let raw = "";
1918process.stdin.setEncoding("utf8");
1919process.stdin.on("data", chunk => raw += chunk);
1920process.stdin.on("end", async () => {
1921  await Promise.resolve();
1922  const input = JSON.parse(raw);
1923  process.stdout.write(JSON.stringify({
1924    decision: "allow",
1925    additional_context: `${input.hook_event_name}:${input.tool_name}:${input.tool_input.command}:${process.env.BAMBOO_HOOK_EVENT}`
1926  }));
1927});
1928"#,
1929        );
1930        let hook = script_hook(
1931            LifecycleHookEvent::PreToolUse,
1932            path,
1933            LifecycleScriptRunner::Auto,
1934            2_000,
1935            Some("^Bash$"),
1936        );
1937
1938        let result = hook
1939            .run(
1940                AgentHookPoint::BeforeToolExecution,
1941                &HookPayload::ToolExecution {
1942                    tool_name: "Bash".to_string(),
1943                    tool_call_id: "call-js-1".to_string(),
1944                    parsed_args: json!({"command": "pwd"}),
1945                },
1946                &session(dir.path()),
1947            )
1948            .await;
1949
1950        assert_eq!(
1951            result,
1952            HookResult::WithContext {
1953                result: Box::new(HookResult::Allow),
1954                text: "PreToolUse:Bash:pwd:PreToolUse".to_string(),
1955            }
1956        );
1957    }
1958
1959    #[tokio::test]
1960    async fn bun_can_be_selected_explicitly_when_installed() {
1961        if Command::new("bun").arg("--version").output().await.is_err() {
1962            return;
1963        }
1964        let dir = tempfile::tempdir().unwrap();
1965        let path = write_script(
1966            &dir,
1967            "bun-hook.js",
1968            r#"
1969await Bun.stdin.text();
1970process.stdout.write(JSON.stringify({additional_context: "executed by bun"}));
1971"#,
1972        );
1973        let hook = script_hook(
1974            LifecycleHookEvent::SessionStart,
1975            path,
1976            LifecycleScriptRunner::Bun,
1977            2_000,
1978            None,
1979        );
1980
1981        assert_eq!(
1982            hook.run(
1983                AgentHookPoint::AfterSessionSetup,
1984                &HookPayload::None,
1985                &session(dir.path()),
1986            )
1987            .await,
1988            HookResult::InjectContext {
1989                text: "executed by bun".to_string(),
1990            }
1991        );
1992    }
1993
1994    #[tokio::test]
1995    async fn python_script_uses_system_python() {
1996        let dir = tempfile::tempdir().unwrap();
1997        let path = write_script(
1998            &dir,
1999            "guard.py",
2000            r#"
2001import json
2002import sys
2003
2004payload = json.load(sys.stdin)
2005print(json.dumps({"additional_context": payload["hook_event_name"] + ":python"}))
2006"#,
2007        );
2008        let hook = script_hook(
2009            LifecycleHookEvent::SessionStart,
2010            path,
2011            LifecycleScriptRunner::Auto,
2012            2_000,
2013            None,
2014        );
2015
2016        assert_eq!(
2017            hook.run(
2018                AgentHookPoint::AfterSessionSetup,
2019                &HookPayload::None,
2020                &session(dir.path()),
2021            )
2022            .await,
2023            HookResult::InjectContext {
2024                text: "SessionStart:python".to_string(),
2025            }
2026        );
2027    }
2028
2029    #[cfg(unix)]
2030    #[tokio::test]
2031    async fn shell_script_uses_system_shell() {
2032        let dir = tempfile::tempdir().unwrap();
2033        let path = write_script(
2034            &dir,
2035            "guard.sh",
2036            r#"
2037payload=$(cat)
2038case "$payload" in
2039  *'"hook_event_name":"SessionStart"'*) printf '%s' '{"additional_context":"executed by sh"}' ;;
2040  *) printf '%s' 'unexpected payload' >&2; exit 1 ;;
2041esac
2042"#,
2043        );
2044        let hook = script_hook(
2045            LifecycleHookEvent::SessionStart,
2046            path,
2047            LifecycleScriptRunner::Auto,
2048            2_000,
2049            None,
2050        );
2051
2052        assert_eq!(
2053            hook.run(
2054                AgentHookPoint::AfterSessionSetup,
2055                &HookPayload::None,
2056                &session(dir.path()),
2057            )
2058            .await,
2059            HookResult::InjectContext {
2060                text: "executed by sh".to_string(),
2061            }
2062        );
2063    }
2064
2065    #[tokio::test]
2066    async fn powershell_script_executes_when_a_system_runtime_is_installed() {
2067        let pwsh_available = Command::new("pwsh").arg("--version").output().await.is_ok();
2068        let windows_powershell_available = Command::new("powershell")
2069            .arg("-NoLogo")
2070            .arg("-Command")
2071            .arg("$PSVersionTable.PSVersion")
2072            .output()
2073            .await
2074            .is_ok();
2075        if !pwsh_available && !windows_powershell_available {
2076            return;
2077        }
2078
2079        let dir = tempfile::tempdir().unwrap();
2080        let path = write_script(
2081            &dir,
2082            "guard.ps1",
2083            r#"
2084$null = [Console]::In.ReadToEnd()
2085[Console]::Out.Write('{"additional_context":"executed by powershell"}')
2086"#,
2087        );
2088        let hook = script_hook(
2089            LifecycleHookEvent::SessionStart,
2090            path,
2091            LifecycleScriptRunner::Auto,
2092            3_000,
2093            None,
2094        );
2095
2096        assert_eq!(
2097            hook.run(
2098                AgentHookPoint::AfterSessionSetup,
2099                &HookPayload::None,
2100                &session(dir.path()),
2101            )
2102            .await,
2103            HookResult::InjectContext {
2104                text: "executed by powershell".to_string(),
2105            }
2106        );
2107    }
2108
2109    #[tokio::test]
2110    async fn script_errors_are_diagnostic_and_non_blocking() {
2111        let dir = tempfile::tempdir().unwrap();
2112        let path = write_script(&dir, "error.js", "throw new Error('policy exploded');");
2113        let hook = script_hook(
2114            LifecycleHookEvent::SessionStart,
2115            path,
2116            LifecycleScriptRunner::Node,
2117            2_000,
2118            None,
2119        );
2120
2121        assert_eq!(
2122            hook.run(
2123                AgentHookPoint::AfterSessionSetup,
2124                &HookPayload::None,
2125                &session(dir.path()),
2126            )
2127            .await,
2128            HookResult::Continue
2129        );
2130        let output = hook
2131            .test(&HookPayload::None, &session(dir.path()))
2132            .await
2133            .unwrap();
2134        assert!(output.stderr.contains("policy exploded"), "{output:?}");
2135        assert!(!output.timed_out);
2136    }
2137
2138    #[tokio::test]
2139    async fn script_timeout_kills_the_runtime_process() {
2140        let dir = tempfile::tempdir().unwrap();
2141        let path = write_script(&dir, "loop.js", "while (true) {}");
2142        let hook = script_hook(
2143            LifecycleHookEvent::SessionStart,
2144            path,
2145            LifecycleScriptRunner::Node,
2146            25,
2147            None,
2148        );
2149        let started = Instant::now();
2150        let output = hook
2151            .test(&HookPayload::None, &session(dir.path()))
2152            .await
2153            .unwrap();
2154
2155        assert!(output.timed_out, "{output:?}");
2156        assert!(started.elapsed() < Duration::from_secs(2));
2157    }
2158
2159    #[tokio::test]
2160    async fn script_timeout_covers_descendant_pipe_drain_and_kills_tree() {
2161        if Command::new("node")
2162            .arg("--version")
2163            .output()
2164            .await
2165            .is_err()
2166        {
2167            return;
2168        }
2169
2170        let dir = tempfile::tempdir().unwrap();
2171        let process_id_path = dir.path().join("descendant.pid");
2172        let process_id_literal = serde_json::to_string(&process_id_path.to_string_lossy()).unwrap();
2173        let body = r#"
2174const fs = require("node:fs");
2175const childProcess = require("node:child_process");
2176const child = childProcess.spawn(
2177  process.execPath,
2178  ["-e", "setTimeout(() => {}, 10000)"],
2179  {stdio: ["ignore", "inherit", "inherit"]}
2180);
2181fs.writeFileSync(__PROCESS_ID_PATH__, String(child.pid));
2182child.unref();
2183"#
2184        .replace("__PROCESS_ID_PATH__", &process_id_literal);
2185        let path = write_script(&dir, "descendant.js", &body);
2186        let hook = script_hook(
2187            LifecycleHookEvent::SessionStart,
2188            path,
2189            LifecycleScriptRunner::Node,
2190            1_000,
2191            None,
2192        );
2193        let started = Instant::now();
2194        let output = hook
2195            .test(&HookPayload::None, &session(dir.path()))
2196            .await
2197            .unwrap();
2198
2199        assert!(output.timed_out, "{output:?}");
2200        assert!(started.elapsed() < Duration::from_secs(3));
2201        let process_id = std::fs::read_to_string(&process_id_path)
2202            .unwrap()
2203            .parse::<u32>()
2204            .unwrap();
2205        assert_process_exits(process_id).await;
2206    }
2207
2208    #[cfg(unix)]
2209    #[tokio::test]
2210    async fn script_timeout_does_not_wait_for_a_pipe_holder_outside_the_process_group() {
2211        if Command::new("node")
2212            .arg("--version")
2213            .output()
2214            .await
2215            .is_err()
2216        {
2217            return;
2218        }
2219
2220        let dir = tempfile::tempdir().unwrap();
2221        let process_id_path = dir.path().join("detached-descendant.pid");
2222        let process_id_literal = serde_json::to_string(&process_id_path.to_string_lossy()).unwrap();
2223        let body = r#"
2224const fs = require("node:fs");
2225const childProcess = require("node:child_process");
2226const child = childProcess.spawn(
2227  process.execPath,
2228  ["-e", "setTimeout(() => {}, 5000)"],
2229  {detached: true, stdio: ["ignore", "inherit", "inherit"]}
2230);
2231fs.writeFileSync(__PROCESS_ID_PATH__, String(child.pid));
2232child.unref();
2233"#
2234        .replace("__PROCESS_ID_PATH__", &process_id_literal);
2235        let path = write_script(&dir, "detached-descendant.js", &body);
2236        let hook = script_hook(
2237            LifecycleHookEvent::SessionStart,
2238            path,
2239            LifecycleScriptRunner::Node,
2240            1_000,
2241            None,
2242        );
2243        let started = Instant::now();
2244        let output = hook
2245            .test(&HookPayload::None, &session(dir.path()))
2246            .await
2247            .unwrap();
2248        let elapsed = started.elapsed();
2249        let process_id = std::fs::read_to_string(&process_id_path)
2250            .unwrap()
2251            .parse::<u32>()
2252            .unwrap();
2253
2254        unsafe {
2255            libc::kill(process_id as libc::pid_t, libc::SIGKILL);
2256        }
2257        assert_process_exits(process_id).await;
2258        assert!(output.timed_out, "{output:?}");
2259        assert!(elapsed < Duration::from_secs(3), "{elapsed:?}");
2260    }
2261
2262    #[tokio::test]
2263    async fn script_result_is_capped_before_interpretation() {
2264        let dir = tempfile::tempdir().unwrap();
2265        let path = write_script(
2266            &dir,
2267            "large.js",
2268            r#"process.stdout.write(JSON.stringify({additional_context: "x".repeat(128 * 1024)}));"#,
2269        );
2270        let hook = script_hook(
2271            LifecycleHookEvent::SessionStart,
2272            path,
2273            LifecycleScriptRunner::Node,
2274            2_000,
2275            None,
2276        );
2277        let output = hook
2278            .test(&HookPayload::None, &session(dir.path()))
2279            .await
2280            .unwrap();
2281
2282        assert_eq!(output.stdout.len(), HOOK_OUTPUT_LIMIT_BYTES);
2283        assert!(output.stdout_truncated, "{output:?}");
2284        assert!(output.stderr.is_empty(), "{output:?}");
2285        assert_eq!(
2286            hook.run(
2287                AgentHookPoint::AfterSessionSetup,
2288                &HookPayload::None,
2289                &session(dir.path()),
2290            )
2291            .await,
2292            HookResult::Continue,
2293            "a truncated response must fail open instead of applying partial JSON"
2294        );
2295    }
2296
2297    #[tokio::test]
2298    async fn missing_script_is_reported_by_dry_run_and_fails_open() {
2299        let dir = tempfile::tempdir().unwrap();
2300        let hook = script_hook(
2301            LifecycleHookEvent::SessionStart,
2302            "missing.js",
2303            LifecycleScriptRunner::Auto,
2304            1_000,
2305            None,
2306        );
2307
2308        assert_eq!(
2309            hook.run(
2310                AgentHookPoint::AfterSessionSetup,
2311                &HookPayload::None,
2312                &session(dir.path()),
2313            )
2314            .await,
2315            HookResult::Continue
2316        );
2317        let output = hook
2318            .test(&HookPayload::None, &session(dir.path()))
2319            .await
2320            .unwrap();
2321        assert!(output.stderr.contains("not accessible"), "{output:?}");
2322        assert_eq!(output.exit_code, None);
2323    }
2324
2325    #[tokio::test]
2326    async fn configured_script_handler_matches_tools_and_returns_decision() {
2327        let dir = tempfile::tempdir().unwrap();
2328        write_script(
2329            &dir,
2330            "block.js",
2331            r#"
2332process.stdin.resume();
2333process.stdin.on("end", () => {
2334  process.stderr.write("blocked in JS");
2335  process.exit(2);
2336});
2337"#,
2338        );
2339        let config = LifecycleHooksConfig {
2340            enabled: true,
2341            pre_tool_use: vec![LifecycleHookGroup {
2342                enabled: true,
2343                matcher: Some("^Bash$".to_string()),
2344                hooks: vec![script("block.js", LifecycleScriptRunner::Node, 2_000)],
2345            }],
2346            ..LifecycleHooksConfig::default()
2347        };
2348        let dispatcher = HookDispatcher::new().with_lifecycle_config(&config, None);
2349
2350        let report = dispatcher
2351            .run_hooks(
2352                AgentHookPoint::BeforeToolExecution,
2353                &HookPayload::ToolExecution {
2354                    tool_name: "Bash".to_string(),
2355                    tool_call_id: "call-js-2".to_string(),
2356                    parsed_args: json!({}),
2357                },
2358                &session(dir.path()),
2359            )
2360            .await;
2361        assert_eq!(
2362            report.outcome.decision,
2363            HookResult::Deny {
2364                reason: "blocked in JS".to_string(),
2365            }
2366        );
2367    }
2368
2369    #[test]
2370    fn matcher_filters_tool_names() {
2371        let hook = shell_hook(
2372            ShellHookEvent::PreToolUse,
2373            "exit 2",
2374            1_000,
2375            Some("^(bash|write_file)$"),
2376            0,
2377        );
2378        let payload = |tool_name: &str| HookPayload::ToolExecution {
2379            tool_name: tool_name.to_string(),
2380            tool_call_id: "call-1".to_string(),
2381            parsed_args: json!({}),
2382        };
2383        assert!(hook.matches(&payload("bash")));
2384        assert!(!hook.matches(&payload("read_file")));
2385    }
2386
2387    #[test]
2388    fn envelope_contains_versioned_protocol_fields() {
2389        let dir = tempfile::tempdir().unwrap();
2390        let hook = shell_hook(ShellHookEvent::PreToolUse, "true", 1_000, None, 0);
2391        let session = session(dir.path());
2392        let payload = HookPayload::ToolExecution {
2393            tool_name: "bash".to_string(),
2394            tool_call_id: "call-1".to_string(),
2395            parsed_args: json!({"command": "pwd"}),
2396        };
2397        let value = serde_json::to_value(hook.envelope(
2398            &payload,
2399            &session,
2400            hook.effective_cwd(&session).as_ref(),
2401        ))
2402        .unwrap();
2403        assert_eq!(value["schema_version"], 1);
2404        assert_eq!(value["hook_event_name"], "PreToolUse");
2405        assert_eq!(value["session_id"], "session-1");
2406        assert_eq!(value["model"], "test-model");
2407        assert_eq!(value["tool_name"], "bash");
2408        assert_eq!(value["tool_input"]["command"], "pwd");
2409        assert_eq!(value["payload"]["type"], "tool_execution");
2410        assert_eq!(value["payload"]["tool_call_id"], "call-1");
2411        assert!(value["timestamp"].as_str().is_some());
2412    }
2413
2414    #[test]
2415    fn session_envelopes_include_source_stop_and_terminal_fields() {
2416        let dir = tempfile::tempdir().unwrap();
2417        let session = session(dir.path());
2418        for (event, payload, expected) in [
2419            (
2420                ShellHookEvent::SessionStart,
2421                HookPayload::SessionSetup {
2422                    initial_message: "hello".to_string(),
2423                    source: SessionStartSource::Resume,
2424                },
2425                ("source", json!("resume")),
2426            ),
2427            (
2428                ShellHookEvent::Stop,
2429                HookPayload::Finalize {
2430                    stop_hook_active: true,
2431                },
2432                ("stop_hook_active", json!(true)),
2433            ),
2434            (
2435                ShellHookEvent::SessionEnd,
2436                HookPayload::SessionEnd {
2437                    status: SessionEndStatus::Cancelled,
2438                    completion_reason: Some("cancelled by user".to_string()),
2439                },
2440                ("terminal_status", json!("cancelled")),
2441            ),
2442        ] {
2443            let hook = shell_hook(event, "true", 1_000, None, 0);
2444            let value = serde_json::to_value(hook.envelope(
2445                &payload,
2446                &session,
2447                hook.effective_cwd(&session).as_ref(),
2448            ))
2449            .unwrap();
2450            assert_eq!(value["hook_event_name"], event.as_str());
2451            assert_eq!(value[expected.0], expected.1);
2452            if matches!(event, ShellHookEvent::SessionEnd) {
2453                assert_eq!(value["completion_reason"], "cancelled by user");
2454            }
2455        }
2456    }
2457
2458    #[test]
2459    fn configured_runner_registers_all_enabled_events() {
2460        let hook = command("true", 1_000);
2461        let config = LifecycleHooksConfig {
2462            enabled: true,
2463            pre_tool_use: vec![LifecycleHookGroup {
2464                enabled: true,
2465                matcher: Some("bash".to_string()),
2466                hooks: vec![hook.clone()],
2467            }],
2468            session_start: vec![LifecycleHookGroup {
2469                enabled: true,
2470                matcher: None,
2471                hooks: vec![hook.clone()],
2472            }],
2473            user_prompt_submit: vec![LifecycleHookGroup {
2474                enabled: true,
2475                matcher: None,
2476                hooks: vec![hook.clone()],
2477            }],
2478            session_end: vec![LifecycleHookGroup {
2479                enabled: true,
2480                matcher: None,
2481                hooks: vec![hook.clone()],
2482            }],
2483            notification: vec![LifecycleHookGroup {
2484                enabled: true,
2485                matcher: None,
2486                hooks: vec![hook],
2487            }],
2488            ..LifecycleHooksConfig::default()
2489        };
2490
2491        let base = HookDispatcher::new();
2492        let configured = base.with_lifecycle_config(&config, None);
2493        assert!(
2494            base.is_empty(),
2495            "per-run registration must not mutate the base"
2496        );
2497        assert_eq!(configured.len(), 5);
2498        assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionSetup));
2499        assert!(configured.has_hooks_for(AgentHookPoint::BeforeSessionSetup));
2500        assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionEnd));
2501        assert!(configured.has_hooks_for(AgentHookPoint::BeforeToolExecution));
2502        assert!(configured.has_hooks_for(AgentHookPoint::AfterNotification));
2503    }
2504
2505    #[test]
2506    fn disabled_group_is_preserved_but_not_registered() {
2507        let config = LifecycleHooksConfig {
2508            enabled: true,
2509            pre_tool_use: vec![LifecycleHookGroup {
2510                enabled: false,
2511                matcher: None,
2512                hooks: vec![command("exit 2", 1_000)],
2513            }],
2514            ..LifecycleHooksConfig::default()
2515        };
2516
2517        let configured = HookDispatcher::new().with_lifecycle_config(&config, None);
2518
2519        assert!(configured.is_empty());
2520    }
2521
2522    #[tokio::test]
2523    async fn configured_commands_run_in_order_and_first_block_wins() {
2524        let dir = tempfile::tempdir().unwrap();
2525        let config = LifecycleHooksConfig {
2526            enabled: true,
2527            pre_tool_use: vec![LifecycleHookGroup {
2528                enabled: true,
2529                matcher: None,
2530                hooks: vec![
2531                    command("printf 'first' >&2; exit 2", 1_000),
2532                    command("printf 'second' >&2; exit 2", 1_000),
2533                ],
2534            }],
2535            ..LifecycleHooksConfig::default()
2536        };
2537        let runner = HookDispatcher::new().with_lifecycle_config(&config, None);
2538        let report = runner
2539            .run_hooks(
2540                AgentHookPoint::BeforeToolExecution,
2541                &HookPayload::ToolExecution {
2542                    tool_name: "bash".to_string(),
2543                    tool_call_id: "call-1".to_string(),
2544                    parsed_args: json!({}),
2545                },
2546                &session(dir.path()),
2547            )
2548            .await;
2549        assert_eq!(
2550            report.outcome.decision,
2551            HookResult::Deny {
2552                reason: "first".to_string()
2553            }
2554        );
2555        assert_eq!(report.executions.len(), 1);
2556    }
2557
2558    #[tokio::test]
2559    async fn precompact_observer_ignores_block_and_collects_later_instructions() {
2560        let dir = tempfile::tempdir().unwrap();
2561        let config = LifecycleHooksConfig {
2562            enabled: true,
2563            pre_compact: vec![LifecycleHookGroup {
2564                enabled: true,
2565                matcher: None,
2566                hooks: vec![
2567                    command("printf 'cannot block compaction' >&2; exit 2", 1_000),
2568                    command(
2569                        r#"printf '%s' '{"additional_context":"preserve the failing assertion"}'"#,
2570                        1_000,
2571                    ),
2572                ],
2573            }],
2574            ..LifecycleHooksConfig::default()
2575        };
2576        let runner = HookDispatcher::new().with_lifecycle_config(&config, None);
2577        let report = runner
2578            .run_observer_hooks(
2579                AgentHookPoint::BeforeCompression,
2580                &HookPayload::Compression {
2581                    estimated_tokens: 1_700,
2582                    usage_percent: 85.0,
2583                    max_context_tokens: 2_000,
2584                    trigger_context_tokens: 1_600,
2585                    trigger: "threshold".to_string(),
2586                    phase: "pre-turn".to_string(),
2587                },
2588                &session(dir.path()),
2589            )
2590            .await;
2591
2592        assert_eq!(report.executions.len(), 2);
2593        assert_eq!(
2594            report.outcome.injected_contexts,
2595            vec!["preserve the failing assertion".to_string()]
2596        );
2597    }
2598
2599    #[test]
2600    fn invalid_matcher_is_skipped_without_failing_registration() {
2601        let config = LifecycleHooksConfig {
2602            enabled: true,
2603            pre_tool_use: vec![LifecycleHookGroup {
2604                enabled: true,
2605                matcher: Some("[".to_string()),
2606                hooks: vec![command("true", 1_000)],
2607            }],
2608            ..LifecycleHooksConfig::default()
2609        };
2610        let configured = HookDispatcher::new().with_lifecycle_config(&config, None);
2611        assert!(configured.is_empty());
2612    }
2613
2614    #[tokio::test]
2615    async fn output_capture_is_capped_but_fully_drained() {
2616        let (mut writer, reader) = tokio::io::duplex(1024);
2617        let input = vec![b'x'; HOOK_OUTPUT_LIMIT_BYTES + 17];
2618        let write_task = tokio::spawn(async move {
2619            writer.write_all(&input).await.unwrap();
2620        });
2621        let captured = read_capped(reader).await.unwrap();
2622        write_task.await.unwrap();
2623        assert_eq!(captured.bytes.len(), HOOK_OUTPUT_LIMIT_BYTES);
2624        assert!(captured.truncated);
2625    }
2626}