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/// Portable hooks share native capture, output bounds and process-tree cleanup,
1507/// but have a separate wire protocol and never use native response interpretation.
1508pub(crate) async fn execute_portable_command(
1509    command_text: &str,
1510    input: Vec<u8>,
1511    timeout_seconds: u64,
1512    cwd: &Path,
1513    plugin_root: &Path,
1514    plugin_data: &Path,
1515) -> Result<LifecycleHookTestOutput, String> {
1516    let shell = preferred_bash_shell();
1517    let overrides = bamboo_llm::Config::current_env_vars();
1518    let prepared_env = build_command_environment(&overrides).await;
1519    let mut command = Command::new(&shell.program);
1520    prepared_env.apply_to_tokio_command(&mut command);
1521    command
1522        .arg(shell.arg)
1523        .arg(command_text)
1524        .current_dir(cwd)
1525        .env("PLUGIN_ROOT", plugin_root)
1526        .env("CLAUDE_PLUGIN_ROOT", plugin_root)
1527        .env("PLUGIN_DATA", plugin_data)
1528        .env("CLAUDE_PLUGIN_DATA", plugin_data)
1529        .stdin(Stdio::piped())
1530        .stdout(Stdio::piped())
1531        .stderr(Stdio::piped());
1532    configure_hook_process(&mut command);
1533    let child = HookChild::new(command.spawn().map_err(|e| e.to_string())?)?;
1534    let output = capture_hook_child(
1535        child,
1536        input,
1537        Duration::from_secs(timeout_seconds),
1538        "plugin-command",
1539    )
1540    .await?;
1541    Ok(LifecycleHookTestOutput {
1542        exit_code: output.exit_code,
1543        stdout: String::from_utf8(output.stdout.bytes).map_err(|e| e.to_string())?,
1544        stderr: String::from_utf8(output.stderr.bytes).map_err(|e| e.to_string())?,
1545        timed_out: output.timed_out,
1546        stdout_truncated: output.stdout.truncated,
1547        stderr_truncated: output.stderr.truncated,
1548    })
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::*;
1554    use bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS;
1555    use serde_json::json;
1556    use std::time::Instant;
1557
1558    #[tokio::test]
1559    async fn portable_command_uses_prepared_environment_then_plugin_overrides() {
1560        let temp = tempfile::tempdir().unwrap();
1561        let prepared = build_command_environment(&std::collections::HashMap::new()).await;
1562        let mut env = prepared.env;
1563        env.insert(
1564            "BAMBOO_PORTABLE_ENV_FIXTURE".into(),
1565            "prepared value".into(),
1566        );
1567        env.insert("PLUGIN_ROOT".into(), "wrong imported root".into());
1568        let _guard = bamboo_infrastructure::test_support::override_command_environment(
1569            env,
1570            prepared.diagnostics,
1571        );
1572        let output = execute_portable_command(
1573            r#"printf '%s\n%s' "$BAMBOO_PORTABLE_ENV_FIXTURE" "$PLUGIN_ROOT""#,
1574            vec![],
1575            1,
1576            temp.path(),
1577            temp.path(),
1578            temp.path(),
1579        )
1580        .await
1581        .unwrap();
1582        assert_eq!(output.exit_code, Some(0));
1583        assert_eq!(
1584            output.stdout,
1585            format!("prepared value\n{}", temp.path().display())
1586        );
1587    }
1588
1589    fn command(command: impl Into<String>, timeout_ms: u64) -> LifecycleHookHandler {
1590        LifecycleHookHandler::command(command, timeout_ms)
1591    }
1592
1593    fn script(
1594        path: impl Into<String>,
1595        runner: LifecycleScriptRunner,
1596        timeout_ms: u64,
1597    ) -> LifecycleHookHandler {
1598        LifecycleHookHandler::script(path, runner, timeout_ms)
1599    }
1600
1601    fn shell_hook(
1602        event: LifecycleHookEvent,
1603        command: impl Into<String>,
1604        timeout_ms: u64,
1605        matcher: Option<&str>,
1606        sequence: usize,
1607    ) -> ShellCommandHook {
1608        ShellCommandHook::new(event, command, timeout_ms, matcher, None, sequence).unwrap()
1609    }
1610
1611    fn script_hook(
1612        event: LifecycleHookEvent,
1613        path: impl Into<String>,
1614        runner: LifecycleScriptRunner,
1615        timeout_ms: u64,
1616        matcher: Option<&str>,
1617    ) -> ScriptHook {
1618        ScriptHook::new(event, path, runner, timeout_ms, matcher, None, 0).unwrap()
1619    }
1620
1621    fn write_script(dir: &tempfile::TempDir, name: &str, body: &str) -> String {
1622        std::fs::write(dir.path().join(name), body).unwrap();
1623        name.to_string()
1624    }
1625
1626    fn session(workspace: &std::path::Path) -> Session {
1627        let mut session = Session::new("session-1", "test-model");
1628        session.workspace = Some(workspace.to_string_lossy().into_owned());
1629        session
1630    }
1631
1632    #[cfg(unix)]
1633    fn process_is_alive(process_id: u32) -> bool {
1634        let result = unsafe { libc::kill(process_id as libc::pid_t, 0) };
1635        result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1636    }
1637
1638    #[cfg(windows)]
1639    fn process_is_alive(process_id: u32) -> bool {
1640        use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
1641        use windows_sys::Win32::System::Threading::{
1642            OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
1643        };
1644
1645        let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, process_id) };
1646        if handle.is_null() {
1647            return false;
1648        }
1649        let wait_result = unsafe { WaitForSingleObject(handle, 0) };
1650        unsafe {
1651            CloseHandle(handle);
1652        }
1653        wait_result == WAIT_TIMEOUT
1654    }
1655
1656    #[cfg(not(any(unix, windows)))]
1657    fn process_is_alive(_process_id: u32) -> bool {
1658        false
1659    }
1660
1661    async fn assert_process_exits(process_id: u32) {
1662        for _ in 0..100 {
1663            if !process_is_alive(process_id) {
1664                return;
1665            }
1666            tokio::time::sleep(Duration::from_millis(10)).await;
1667        }
1668        assert!(
1669            !process_is_alive(process_id),
1670            "hook descendant process {process_id} survived timeout cleanup"
1671        );
1672    }
1673
1674    #[tokio::test]
1675    async fn exit_zero_without_output_passes_through() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let hook = shell_hook(
1678            ShellHookEvent::SessionStart,
1679            "printf ''",
1680            DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
1681            None,
1682            0,
1683        );
1684        let result = hook
1685            .run(
1686                AgentHookPoint::AfterSessionSetup,
1687                &HookPayload::SessionSetup {
1688                    initial_message: "hello".to_string(),
1689                    source: SessionStartSource::Startup,
1690                },
1691                &session(dir.path()),
1692            )
1693            .await;
1694        assert_eq!(result, HookResult::Continue);
1695    }
1696
1697    #[tokio::test]
1698    async fn exit_two_blocks_with_stderr_reason() {
1699        let dir = tempfile::tempdir().unwrap();
1700        let hook = shell_hook(
1701            ShellHookEvent::PreToolUse,
1702            "printf 'blocked by policy' >&2; exit 2",
1703            1_000,
1704            None,
1705            0,
1706        );
1707        let result = hook
1708            .run(
1709                AgentHookPoint::BeforeToolExecution,
1710                &HookPayload::ToolExecution {
1711                    tool_name: "bash".to_string(),
1712                    tool_call_id: "call-1".to_string(),
1713                    parsed_args: json!({"command": "pwd"}),
1714                },
1715                &session(dir.path()),
1716            )
1717            .await;
1718        assert_eq!(
1719            result,
1720            HookResult::Deny {
1721                reason: "blocked by policy".to_string()
1722            }
1723        );
1724    }
1725
1726    #[tokio::test]
1727    async fn versioned_json_protocol_is_delivered_on_stdin() {
1728        let dir = tempfile::tempdir().unwrap();
1729        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"#;
1730        let hook = shell_hook(ShellHookEvent::PreToolUse, shell, 1_000, None, 0);
1731        let result = hook
1732            .run(
1733                AgentHookPoint::BeforeToolExecution,
1734                &HookPayload::ToolExecution {
1735                    tool_name: "bash".to_string(),
1736                    tool_call_id: "call-1".to_string(),
1737                    parsed_args: json!({"command": "pwd"}),
1738                },
1739                &session(dir.path()),
1740            )
1741            .await;
1742        assert_eq!(result, HookResult::Allow);
1743    }
1744
1745    #[tokio::test]
1746    async fn stdout_decisions_and_context_are_parsed() {
1747        let dir = tempfile::tempdir().unwrap();
1748        for (body, expected) in [
1749            (r#"{"decision":"allow"}"#, HookResult::Allow),
1750            (r#"{"decision":"ask"}"#, HookResult::Ask),
1751            (
1752                r#"{"decision":"block","reason":"nope"}"#,
1753                HookResult::Deny {
1754                    reason: "nope".to_string(),
1755                },
1756            ),
1757            (
1758                r#"{"additional_context":"remember this"}"#,
1759                HookResult::InjectContext {
1760                    text: "remember this".to_string(),
1761                },
1762            ),
1763            (
1764                r#"{"decision":"allow","additional_context":"allowed context"}"#,
1765                HookResult::WithContext {
1766                    result: Box::new(HookResult::Allow),
1767                    text: "allowed context".to_string(),
1768                },
1769            ),
1770        ] {
1771            let shell = format!("printf '%s' '{body}'");
1772            let hook = shell_hook(ShellHookEvent::SessionStart, shell, 1_000, None, 0);
1773            assert_eq!(
1774                hook.run(
1775                    AgentHookPoint::AfterSessionSetup,
1776                    &HookPayload::None,
1777                    &session(dir.path()),
1778                )
1779                .await,
1780                expected
1781            );
1782        }
1783    }
1784
1785    #[tokio::test]
1786    async fn runner_preserves_decision_and_context_from_one_response() {
1787        let dir = tempfile::tempdir().unwrap();
1788        let mut runner = HookDispatcher::new();
1789        runner.register(Arc::new(shell_hook(
1790            ShellHookEvent::SessionStart,
1791            r#"printf '%s' '{"decision":"allow","additional_context":"keep me"}'"#,
1792            1_000,
1793            None,
1794            0,
1795        )));
1796        let session = session(dir.path());
1797        let report = runner
1798            .run_hooks(
1799                AgentHookPoint::AfterSessionSetup,
1800                &HookPayload::None,
1801                &session,
1802            )
1803            .await;
1804        assert_eq!(report.outcome.decision, HookResult::Allow);
1805        assert_eq!(report.outcome.injected_contexts, vec!["keep me"]);
1806    }
1807
1808    #[tokio::test]
1809    async fn malformed_json_is_non_blocking() {
1810        let dir = tempfile::tempdir().unwrap();
1811        let hook = shell_hook(
1812            ShellHookEvent::SessionStart,
1813            "printf 'not-json'",
1814            1_000,
1815            None,
1816            0,
1817        );
1818        assert_eq!(
1819            hook.run(
1820                AgentHookPoint::AfterSessionSetup,
1821                &HookPayload::None,
1822                &session(dir.path()),
1823            )
1824            .await,
1825            HookResult::Continue
1826        );
1827    }
1828
1829    #[test]
1830    fn truncated_command_response_is_not_interpreted() {
1831        let hook = shell_hook(ShellHookEvent::SessionStart, "true", 1_000, None, 0);
1832        let output = CommandOutput {
1833            exit_code: Some(0),
1834            stdout: CapturedOutput {
1835                bytes: br#"{"decision":"block","reason":"must not apply"}"#.to_vec(),
1836                truncated: true,
1837            },
1838            stderr: CapturedOutput::default(),
1839            timed_out: false,
1840        };
1841
1842        assert_eq!(hook.interpret(output), HookResult::Continue);
1843    }
1844
1845    #[tokio::test]
1846    async fn timeout_kills_hook_and_continues() {
1847        let dir = tempfile::tempdir().unwrap();
1848        let hook = shell_hook(ShellHookEvent::SessionStart, "sleep 5", 25, None, 0);
1849        let started = Instant::now();
1850        let result = hook
1851            .run(
1852                AgentHookPoint::AfterSessionSetup,
1853                &HookPayload::None,
1854                &session(dir.path()),
1855            )
1856            .await;
1857        assert_eq!(result, HookResult::Continue);
1858        assert!(started.elapsed() < Duration::from_secs(2));
1859    }
1860
1861    #[test]
1862    fn script_invocations_cover_supported_system_runtimes() {
1863        let js = script_invocations(
1864            "guard.js",
1865            Path::new("guard.js"),
1866            LifecycleScriptRunner::Auto,
1867        )
1868        .unwrap();
1869        assert_eq!(js[0].program, OsString::from("node"));
1870        assert_eq!(js[1].program, OsString::from("bun"));
1871        assert_eq!(js[1].args[0], OsString::from("run"));
1872
1873        let bun = script_invocations(
1874            "guard.mjs",
1875            Path::new("guard.mjs"),
1876            LifecycleScriptRunner::Bun,
1877        )
1878        .unwrap();
1879        assert_eq!(bun[0].program, OsString::from("bun"));
1880
1881        let python = script_invocations(
1882            "guard.py",
1883            Path::new("guard.py"),
1884            LifecycleScriptRunner::Auto,
1885        )
1886        .unwrap();
1887        assert_eq!(python[0].program, OsString::from("python3"));
1888        assert_eq!(python[1].program, OsString::from("python"));
1889
1890        let shell = script_invocations(
1891            "guard.sh",
1892            Path::new("guard.sh"),
1893            LifecycleScriptRunner::Auto,
1894        )
1895        .unwrap();
1896        assert!(!shell[0].program.is_empty());
1897
1898        let powershell = script_invocations(
1899            "guard.ps1",
1900            Path::new("guard.ps1"),
1901            LifecycleScriptRunner::Auto,
1902        )
1903        .unwrap();
1904        assert_eq!(powershell[0].program, OsString::from("pwsh"));
1905        assert_eq!(powershell[1].program, OsString::from("powershell"));
1906
1907        assert!(script_invocations(
1908            "guard.py",
1909            Path::new("guard.py"),
1910            LifecycleScriptRunner::Node
1911        )
1912        .unwrap_err()
1913        .contains("cannot execute"));
1914    }
1915
1916    #[cfg(unix)]
1917    #[test]
1918    fn explicit_bash_runner_does_not_fall_back_to_sh() {
1919        let bash = script_invocations(
1920            "guard.sh",
1921            Path::new("guard.sh"),
1922            LifecycleScriptRunner::Bash,
1923        )
1924        .unwrap();
1925
1926        assert_eq!(bash[0].program, OsString::from("bash"));
1927    }
1928
1929    #[cfg(windows)]
1930    #[test]
1931    fn batch_scripts_use_cmd_on_windows() {
1932        let batch = script_invocations(
1933            "guard.bat",
1934            Path::new("guard.bat"),
1935            LifecycleScriptRunner::Auto,
1936        )
1937        .unwrap();
1938        assert_eq!(batch[0].program, OsString::from("cmd.exe"));
1939        assert_eq!(batch[0].args[0], OsString::from("/D"));
1940    }
1941
1942    #[cfg(windows)]
1943    #[tokio::test]
1944    async fn batch_script_executes_through_cmd() {
1945        let dir = tempfile::tempdir().unwrap();
1946        let script_dir = dir.path().join("hook scripts");
1947        std::fs::create_dir(&script_dir).unwrap();
1948        std::fs::write(
1949            script_dir.join("guard.bat"),
1950            "@echo off\r\necho {\"additional_context\":\"executed by bat\"}\r\n",
1951        )
1952        .unwrap();
1953        let hook = script_hook(
1954            LifecycleHookEvent::SessionStart,
1955            "hook scripts/guard.bat",
1956            LifecycleScriptRunner::Auto,
1957            2_000,
1958            None,
1959        );
1960
1961        assert_eq!(
1962            hook.run(
1963                AgentHookPoint::AfterSessionSetup,
1964                &HookPayload::None,
1965                &session(dir.path()),
1966            )
1967            .await,
1968            HookResult::InjectContext {
1969                text: "executed by bat".to_string(),
1970            }
1971        );
1972    }
1973
1974    #[cfg(not(windows))]
1975    #[test]
1976    fn batch_scripts_report_the_platform_requirement() {
1977        let error = script_invocations(
1978            "guard.bat",
1979            Path::new("guard.bat"),
1980            LifecycleScriptRunner::Auto,
1981        )
1982        .unwrap_err();
1983        assert!(error.contains("require Windows"), "{error}");
1984    }
1985
1986    #[tokio::test]
1987    async fn javascript_script_uses_system_runtime_and_reads_stdin_envelope() {
1988        let dir = tempfile::tempdir().unwrap();
1989        let path = write_script(
1990            &dir,
1991            "guard.js",
1992            r#"
1993let raw = "";
1994process.stdin.setEncoding("utf8");
1995process.stdin.on("data", chunk => raw += chunk);
1996process.stdin.on("end", async () => {
1997  await Promise.resolve();
1998  const input = JSON.parse(raw);
1999  process.stdout.write(JSON.stringify({
2000    decision: "allow",
2001    additional_context: `${input.hook_event_name}:${input.tool_name}:${input.tool_input.command}:${process.env.BAMBOO_HOOK_EVENT}`
2002  }));
2003});
2004"#,
2005        );
2006        let hook = script_hook(
2007            LifecycleHookEvent::PreToolUse,
2008            path,
2009            LifecycleScriptRunner::Auto,
2010            2_000,
2011            Some("^Bash$"),
2012        );
2013
2014        let result = hook
2015            .run(
2016                AgentHookPoint::BeforeToolExecution,
2017                &HookPayload::ToolExecution {
2018                    tool_name: "Bash".to_string(),
2019                    tool_call_id: "call-js-1".to_string(),
2020                    parsed_args: json!({"command": "pwd"}),
2021                },
2022                &session(dir.path()),
2023            )
2024            .await;
2025
2026        assert_eq!(
2027            result,
2028            HookResult::WithContext {
2029                result: Box::new(HookResult::Allow),
2030                text: "PreToolUse:Bash:pwd:PreToolUse".to_string(),
2031            }
2032        );
2033    }
2034
2035    #[tokio::test]
2036    async fn bun_can_be_selected_explicitly_when_installed() {
2037        if Command::new("bun").arg("--version").output().await.is_err() {
2038            return;
2039        }
2040        let dir = tempfile::tempdir().unwrap();
2041        let path = write_script(
2042            &dir,
2043            "bun-hook.js",
2044            r#"
2045await Bun.stdin.text();
2046process.stdout.write(JSON.stringify({additional_context: "executed by bun"}));
2047"#,
2048        );
2049        let hook = script_hook(
2050            LifecycleHookEvent::SessionStart,
2051            path,
2052            LifecycleScriptRunner::Bun,
2053            2_000,
2054            None,
2055        );
2056
2057        assert_eq!(
2058            hook.run(
2059                AgentHookPoint::AfterSessionSetup,
2060                &HookPayload::None,
2061                &session(dir.path()),
2062            )
2063            .await,
2064            HookResult::InjectContext {
2065                text: "executed by bun".to_string(),
2066            }
2067        );
2068    }
2069
2070    #[tokio::test]
2071    async fn python_script_uses_system_python() {
2072        let dir = tempfile::tempdir().unwrap();
2073        let path = write_script(
2074            &dir,
2075            "guard.py",
2076            r#"
2077import json
2078import sys
2079
2080payload = json.load(sys.stdin)
2081print(json.dumps({"additional_context": payload["hook_event_name"] + ":python"}))
2082"#,
2083        );
2084        let hook = script_hook(
2085            LifecycleHookEvent::SessionStart,
2086            path,
2087            LifecycleScriptRunner::Auto,
2088            2_000,
2089            None,
2090        );
2091
2092        assert_eq!(
2093            hook.run(
2094                AgentHookPoint::AfterSessionSetup,
2095                &HookPayload::None,
2096                &session(dir.path()),
2097            )
2098            .await,
2099            HookResult::InjectContext {
2100                text: "SessionStart:python".to_string(),
2101            }
2102        );
2103    }
2104
2105    #[cfg(unix)]
2106    #[tokio::test]
2107    async fn shell_script_uses_system_shell() {
2108        let dir = tempfile::tempdir().unwrap();
2109        let path = write_script(
2110            &dir,
2111            "guard.sh",
2112            r#"
2113payload=$(cat)
2114case "$payload" in
2115  *'"hook_event_name":"SessionStart"'*) printf '%s' '{"additional_context":"executed by sh"}' ;;
2116  *) printf '%s' 'unexpected payload' >&2; exit 1 ;;
2117esac
2118"#,
2119        );
2120        let hook = script_hook(
2121            LifecycleHookEvent::SessionStart,
2122            path,
2123            LifecycleScriptRunner::Auto,
2124            2_000,
2125            None,
2126        );
2127
2128        assert_eq!(
2129            hook.run(
2130                AgentHookPoint::AfterSessionSetup,
2131                &HookPayload::None,
2132                &session(dir.path()),
2133            )
2134            .await,
2135            HookResult::InjectContext {
2136                text: "executed by sh".to_string(),
2137            }
2138        );
2139    }
2140
2141    async fn auto_powershell_runtime_is_available() -> bool {
2142        match Command::new("pwsh").arg("--version").output().await {
2143            Ok(output) => output.status.success(),
2144            Err(error) if error.kind() == ErrorKind::NotFound => Command::new("powershell")
2145                .args([
2146                    "-NoLogo",
2147                    "-NoProfile",
2148                    "-NonInteractive",
2149                    "-Command",
2150                    "$PSVersionTable.PSVersion",
2151                ])
2152                .output()
2153                .await
2154                .is_ok_and(|output| output.status.success()),
2155            Err(_) => false,
2156        }
2157    }
2158
2159    #[tokio::test]
2160    async fn powershell_script_executes_when_a_system_runtime_is_installed() {
2161        if !auto_powershell_runtime_is_available().await {
2162            return;
2163        }
2164
2165        let dir = tempfile::tempdir().unwrap();
2166        let path = write_script(
2167            &dir,
2168            "guard.ps1",
2169            r#"
2170$null = [Console]::In.ReadToEnd()
2171[Console]::Out.Write('{"additional_context":"executed by powershell"}')
2172"#,
2173        );
2174        let hook = script_hook(
2175            LifecycleHookEvent::SessionStart,
2176            path,
2177            LifecycleScriptRunner::Auto,
2178            // PowerShell cold starts can exceed a few seconds on a loaded CI
2179            // runner. Keep this functional contract below the 60-second
2180            // production default without coupling it to the dedicated
2181            // millisecond-scale timeout and process-cleanup regressions.
2182            30_000,
2183            None,
2184        );
2185
2186        assert_eq!(
2187            hook.run(
2188                AgentHookPoint::AfterSessionSetup,
2189                &HookPayload::None,
2190                &session(dir.path()),
2191            )
2192            .await,
2193            HookResult::InjectContext {
2194                text: "executed by powershell".to_string(),
2195            }
2196        );
2197    }
2198
2199    #[tokio::test]
2200    async fn script_errors_are_diagnostic_and_non_blocking() {
2201        let dir = tempfile::tempdir().unwrap();
2202        let path = write_script(&dir, "error.js", "throw new Error('policy exploded');");
2203        let hook = script_hook(
2204            LifecycleHookEvent::SessionStart,
2205            path,
2206            LifecycleScriptRunner::Node,
2207            2_000,
2208            None,
2209        );
2210
2211        assert_eq!(
2212            hook.run(
2213                AgentHookPoint::AfterSessionSetup,
2214                &HookPayload::None,
2215                &session(dir.path()),
2216            )
2217            .await,
2218            HookResult::Continue
2219        );
2220        let output = hook
2221            .test(&HookPayload::None, &session(dir.path()))
2222            .await
2223            .unwrap();
2224        assert!(output.stderr.contains("policy exploded"), "{output:?}");
2225        assert!(!output.timed_out);
2226    }
2227
2228    #[tokio::test]
2229    async fn script_timeout_kills_the_runtime_process() {
2230        let dir = tempfile::tempdir().unwrap();
2231        let path = write_script(&dir, "loop.js", "while (true) {}");
2232        let hook = script_hook(
2233            LifecycleHookEvent::SessionStart,
2234            path,
2235            LifecycleScriptRunner::Node,
2236            25,
2237            None,
2238        );
2239        let started = Instant::now();
2240        let output = hook
2241            .test(&HookPayload::None, &session(dir.path()))
2242            .await
2243            .unwrap();
2244
2245        assert!(output.timed_out, "{output:?}");
2246        assert!(started.elapsed() < Duration::from_secs(2));
2247    }
2248
2249    #[tokio::test]
2250    async fn script_timeout_covers_descendant_pipe_drain_and_kills_tree() {
2251        if Command::new("node")
2252            .arg("--version")
2253            .output()
2254            .await
2255            .is_err()
2256        {
2257            return;
2258        }
2259
2260        let dir = tempfile::tempdir().unwrap();
2261        let process_id_path = dir.path().join("descendant.pid");
2262        let process_id_literal = serde_json::to_string(&process_id_path.to_string_lossy()).unwrap();
2263        let body = r#"
2264const fs = require("node:fs");
2265const childProcess = require("node:child_process");
2266const child = childProcess.spawn(
2267  process.execPath,
2268  ["-e", "setTimeout(() => {}, 10000)"],
2269  {stdio: ["ignore", "inherit", "inherit"]}
2270);
2271fs.writeFileSync(__PROCESS_ID_PATH__, String(child.pid));
2272child.unref();
2273"#
2274        .replace("__PROCESS_ID_PATH__", &process_id_literal);
2275        let path = write_script(&dir, "descendant.js", &body);
2276        let hook = script_hook(
2277            LifecycleHookEvent::SessionStart,
2278            path,
2279            LifecycleScriptRunner::Node,
2280            1_000,
2281            None,
2282        );
2283        let started = Instant::now();
2284        let output = hook
2285            .test(&HookPayload::None, &session(dir.path()))
2286            .await
2287            .unwrap();
2288
2289        assert!(output.timed_out, "{output:?}");
2290        assert!(started.elapsed() < Duration::from_secs(3));
2291        let process_id = std::fs::read_to_string(&process_id_path)
2292            .unwrap()
2293            .parse::<u32>()
2294            .unwrap();
2295        assert_process_exits(process_id).await;
2296    }
2297
2298    #[cfg(unix)]
2299    #[tokio::test]
2300    async fn script_timeout_does_not_wait_for_a_pipe_holder_outside_the_process_group() {
2301        if Command::new("node")
2302            .arg("--version")
2303            .output()
2304            .await
2305            .is_err()
2306        {
2307            return;
2308        }
2309
2310        let dir = tempfile::tempdir().unwrap();
2311        let process_id_path = dir.path().join("detached-descendant.pid");
2312        let process_id_literal = serde_json::to_string(&process_id_path.to_string_lossy()).unwrap();
2313        let body = r#"
2314const fs = require("node:fs");
2315const childProcess = require("node:child_process");
2316const child = childProcess.spawn(
2317  process.execPath,
2318  ["-e", "setTimeout(() => {}, 5000)"],
2319  {detached: true, stdio: ["ignore", "inherit", "inherit"]}
2320);
2321fs.writeFileSync(__PROCESS_ID_PATH__, String(child.pid));
2322child.unref();
2323"#
2324        .replace("__PROCESS_ID_PATH__", &process_id_literal);
2325        let path = write_script(&dir, "detached-descendant.js", &body);
2326        let hook = script_hook(
2327            LifecycleHookEvent::SessionStart,
2328            path,
2329            LifecycleScriptRunner::Node,
2330            1_000,
2331            None,
2332        );
2333        let started = Instant::now();
2334        let output = hook
2335            .test(&HookPayload::None, &session(dir.path()))
2336            .await
2337            .unwrap();
2338        let elapsed = started.elapsed();
2339        let process_id = std::fs::read_to_string(&process_id_path)
2340            .unwrap()
2341            .parse::<u32>()
2342            .unwrap();
2343
2344        unsafe {
2345            libc::kill(process_id as libc::pid_t, libc::SIGKILL);
2346        }
2347        assert_process_exits(process_id).await;
2348        assert!(output.timed_out, "{output:?}");
2349        assert!(elapsed < Duration::from_secs(3), "{elapsed:?}");
2350    }
2351
2352    #[tokio::test]
2353    async fn script_result_is_capped_before_interpretation() {
2354        let dir = tempfile::tempdir().unwrap();
2355        let path = write_script(
2356            &dir,
2357            "large.js",
2358            r#"process.stdout.write(JSON.stringify({additional_context: "x".repeat(128 * 1024)}));"#,
2359        );
2360        let hook = script_hook(
2361            LifecycleHookEvent::SessionStart,
2362            path,
2363            LifecycleScriptRunner::Node,
2364            2_000,
2365            None,
2366        );
2367        let output = hook
2368            .test(&HookPayload::None, &session(dir.path()))
2369            .await
2370            .unwrap();
2371
2372        assert_eq!(output.stdout.len(), HOOK_OUTPUT_LIMIT_BYTES);
2373        assert!(output.stdout_truncated, "{output:?}");
2374        assert!(output.stderr.is_empty(), "{output:?}");
2375        assert_eq!(
2376            hook.run(
2377                AgentHookPoint::AfterSessionSetup,
2378                &HookPayload::None,
2379                &session(dir.path()),
2380            )
2381            .await,
2382            HookResult::Continue,
2383            "a truncated response must fail open instead of applying partial JSON"
2384        );
2385    }
2386
2387    #[tokio::test]
2388    async fn missing_script_is_reported_by_dry_run_and_fails_open() {
2389        let dir = tempfile::tempdir().unwrap();
2390        let hook = script_hook(
2391            LifecycleHookEvent::SessionStart,
2392            "missing.js",
2393            LifecycleScriptRunner::Auto,
2394            1_000,
2395            None,
2396        );
2397
2398        assert_eq!(
2399            hook.run(
2400                AgentHookPoint::AfterSessionSetup,
2401                &HookPayload::None,
2402                &session(dir.path()),
2403            )
2404            .await,
2405            HookResult::Continue
2406        );
2407        let output = hook
2408            .test(&HookPayload::None, &session(dir.path()))
2409            .await
2410            .unwrap();
2411        assert!(output.stderr.contains("not accessible"), "{output:?}");
2412        assert_eq!(output.exit_code, None);
2413    }
2414
2415    #[tokio::test]
2416    async fn configured_script_handler_matches_tools_and_returns_decision() {
2417        let dir = tempfile::tempdir().unwrap();
2418        write_script(
2419            &dir,
2420            "block.js",
2421            r#"
2422process.stdin.resume();
2423process.stdin.on("end", () => {
2424  process.stderr.write("blocked in JS");
2425  process.exit(2);
2426});
2427"#,
2428        );
2429        let config = LifecycleHooksConfig {
2430            enabled: true,
2431            pre_tool_use: vec![LifecycleHookGroup {
2432                enabled: true,
2433                matcher: Some("^Bash$".to_string()),
2434                hooks: vec![script("block.js", LifecycleScriptRunner::Node, 2_000)],
2435            }],
2436            ..LifecycleHooksConfig::default()
2437        };
2438        let dispatcher = HookDispatcher::new().with_lifecycle_config(&config, None);
2439
2440        let report = dispatcher
2441            .run_hooks(
2442                AgentHookPoint::BeforeToolExecution,
2443                &HookPayload::ToolExecution {
2444                    tool_name: "Bash".to_string(),
2445                    tool_call_id: "call-js-2".to_string(),
2446                    parsed_args: json!({}),
2447                },
2448                &session(dir.path()),
2449            )
2450            .await;
2451        assert_eq!(
2452            report.outcome.decision,
2453            HookResult::Deny {
2454                reason: "blocked in JS".to_string(),
2455            }
2456        );
2457    }
2458
2459    #[test]
2460    fn matcher_filters_tool_names() {
2461        let hook = shell_hook(
2462            ShellHookEvent::PreToolUse,
2463            "exit 2",
2464            1_000,
2465            Some("^(bash|write_file)$"),
2466            0,
2467        );
2468        let payload = |tool_name: &str| HookPayload::ToolExecution {
2469            tool_name: tool_name.to_string(),
2470            tool_call_id: "call-1".to_string(),
2471            parsed_args: json!({}),
2472        };
2473        assert!(hook.matches(&payload("bash")));
2474        assert!(!hook.matches(&payload("read_file")));
2475    }
2476
2477    #[test]
2478    fn envelope_contains_versioned_protocol_fields() {
2479        let dir = tempfile::tempdir().unwrap();
2480        let hook = shell_hook(ShellHookEvent::PreToolUse, "true", 1_000, None, 0);
2481        let session = session(dir.path());
2482        let payload = HookPayload::ToolExecution {
2483            tool_name: "bash".to_string(),
2484            tool_call_id: "call-1".to_string(),
2485            parsed_args: json!({"command": "pwd"}),
2486        };
2487        let value = serde_json::to_value(hook.envelope(
2488            &payload,
2489            &session,
2490            hook.effective_cwd(&session).as_ref(),
2491        ))
2492        .unwrap();
2493        assert_eq!(value["schema_version"], 1);
2494        assert_eq!(value["hook_event_name"], "PreToolUse");
2495        assert_eq!(value["session_id"], "session-1");
2496        assert_eq!(value["model"], "test-model");
2497        assert_eq!(value["tool_name"], "bash");
2498        assert_eq!(value["tool_input"]["command"], "pwd");
2499        assert_eq!(value["payload"]["type"], "tool_execution");
2500        assert_eq!(value["payload"]["tool_call_id"], "call-1");
2501        assert!(value["timestamp"].as_str().is_some());
2502    }
2503
2504    #[test]
2505    fn session_envelopes_include_source_stop_and_terminal_fields() {
2506        let dir = tempfile::tempdir().unwrap();
2507        let session = session(dir.path());
2508        for (event, payload, expected) in [
2509            (
2510                ShellHookEvent::SessionStart,
2511                HookPayload::SessionSetup {
2512                    initial_message: "hello".to_string(),
2513                    source: SessionStartSource::Resume,
2514                },
2515                ("source", json!("resume")),
2516            ),
2517            (
2518                ShellHookEvent::Stop,
2519                HookPayload::Finalize {
2520                    stop_hook_active: true,
2521                },
2522                ("stop_hook_active", json!(true)),
2523            ),
2524            (
2525                ShellHookEvent::SessionEnd,
2526                HookPayload::SessionEnd {
2527                    status: SessionEndStatus::Cancelled,
2528                    completion_reason: Some("cancelled by user".to_string()),
2529                },
2530                ("terminal_status", json!("cancelled")),
2531            ),
2532        ] {
2533            let hook = shell_hook(event, "true", 1_000, None, 0);
2534            let value = serde_json::to_value(hook.envelope(
2535                &payload,
2536                &session,
2537                hook.effective_cwd(&session).as_ref(),
2538            ))
2539            .unwrap();
2540            assert_eq!(value["hook_event_name"], event.as_str());
2541            assert_eq!(value[expected.0], expected.1);
2542            if matches!(event, ShellHookEvent::SessionEnd) {
2543                assert_eq!(value["completion_reason"], "cancelled by user");
2544            }
2545        }
2546    }
2547
2548    #[test]
2549    fn configured_runner_registers_all_enabled_events() {
2550        let hook = command("true", 1_000);
2551        let config = LifecycleHooksConfig {
2552            enabled: true,
2553            pre_tool_use: vec![LifecycleHookGroup {
2554                enabled: true,
2555                matcher: Some("bash".to_string()),
2556                hooks: vec![hook.clone()],
2557            }],
2558            session_start: vec![LifecycleHookGroup {
2559                enabled: true,
2560                matcher: None,
2561                hooks: vec![hook.clone()],
2562            }],
2563            user_prompt_submit: vec![LifecycleHookGroup {
2564                enabled: true,
2565                matcher: None,
2566                hooks: vec![hook.clone()],
2567            }],
2568            session_end: vec![LifecycleHookGroup {
2569                enabled: true,
2570                matcher: None,
2571                hooks: vec![hook.clone()],
2572            }],
2573            notification: vec![LifecycleHookGroup {
2574                enabled: true,
2575                matcher: None,
2576                hooks: vec![hook],
2577            }],
2578            ..LifecycleHooksConfig::default()
2579        };
2580
2581        let base = HookDispatcher::new();
2582        let configured = base.with_lifecycle_config(&config, None);
2583        assert!(
2584            base.is_empty(),
2585            "per-run registration must not mutate the base"
2586        );
2587        assert_eq!(configured.len(), 5);
2588        assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionSetup));
2589        assert!(configured.has_hooks_for(AgentHookPoint::BeforeSessionSetup));
2590        assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionEnd));
2591        assert!(configured.has_hooks_for(AgentHookPoint::BeforeToolExecution));
2592        assert!(configured.has_hooks_for(AgentHookPoint::AfterNotification));
2593    }
2594
2595    #[test]
2596    fn disabled_group_is_preserved_but_not_registered() {
2597        let config = LifecycleHooksConfig {
2598            enabled: true,
2599            pre_tool_use: vec![LifecycleHookGroup {
2600                enabled: false,
2601                matcher: None,
2602                hooks: vec![command("exit 2", 1_000)],
2603            }],
2604            ..LifecycleHooksConfig::default()
2605        };
2606
2607        let configured = HookDispatcher::new().with_lifecycle_config(&config, None);
2608
2609        assert!(configured.is_empty());
2610    }
2611
2612    #[tokio::test]
2613    async fn configured_commands_run_in_order_and_first_block_wins() {
2614        let dir = tempfile::tempdir().unwrap();
2615        let config = LifecycleHooksConfig {
2616            enabled: true,
2617            pre_tool_use: vec![LifecycleHookGroup {
2618                enabled: true,
2619                matcher: None,
2620                hooks: vec![
2621                    command("printf 'first' >&2; exit 2", 1_000),
2622                    command("printf 'second' >&2; exit 2", 1_000),
2623                ],
2624            }],
2625            ..LifecycleHooksConfig::default()
2626        };
2627        let runner = HookDispatcher::new().with_lifecycle_config(&config, None);
2628        let report = runner
2629            .run_hooks(
2630                AgentHookPoint::BeforeToolExecution,
2631                &HookPayload::ToolExecution {
2632                    tool_name: "bash".to_string(),
2633                    tool_call_id: "call-1".to_string(),
2634                    parsed_args: json!({}),
2635                },
2636                &session(dir.path()),
2637            )
2638            .await;
2639        assert_eq!(
2640            report.outcome.decision,
2641            HookResult::Deny {
2642                reason: "first".to_string()
2643            }
2644        );
2645        assert_eq!(report.executions.len(), 1);
2646    }
2647
2648    #[tokio::test]
2649    async fn precompact_observer_ignores_block_and_collects_later_instructions() {
2650        let dir = tempfile::tempdir().unwrap();
2651        let config = LifecycleHooksConfig {
2652            enabled: true,
2653            pre_compact: vec![LifecycleHookGroup {
2654                enabled: true,
2655                matcher: None,
2656                hooks: vec![
2657                    command("printf 'cannot block compaction' >&2; exit 2", 1_000),
2658                    command(
2659                        r#"printf '%s' '{"additional_context":"preserve the failing assertion"}'"#,
2660                        1_000,
2661                    ),
2662                ],
2663            }],
2664            ..LifecycleHooksConfig::default()
2665        };
2666        let runner = HookDispatcher::new().with_lifecycle_config(&config, None);
2667        let report = runner
2668            .run_observer_hooks(
2669                AgentHookPoint::BeforeCompression,
2670                &HookPayload::Compression {
2671                    estimated_tokens: 1_700,
2672                    usage_percent: 85.0,
2673                    max_context_tokens: 2_000,
2674                    trigger_context_tokens: 1_600,
2675                    trigger: "threshold".to_string(),
2676                    phase: "pre-turn".to_string(),
2677                },
2678                &session(dir.path()),
2679            )
2680            .await;
2681
2682        assert_eq!(report.executions.len(), 2);
2683        assert_eq!(
2684            report.outcome.injected_contexts,
2685            vec!["preserve the failing assertion".to_string()]
2686        );
2687    }
2688
2689    #[test]
2690    fn invalid_matcher_is_skipped_without_failing_registration() {
2691        let config = LifecycleHooksConfig {
2692            enabled: true,
2693            pre_tool_use: vec![LifecycleHookGroup {
2694                enabled: true,
2695                matcher: Some("[".to_string()),
2696                hooks: vec![command("true", 1_000)],
2697            }],
2698            ..LifecycleHooksConfig::default()
2699        };
2700        let configured = HookDispatcher::new().with_lifecycle_config(&config, None);
2701        assert!(configured.is_empty());
2702    }
2703
2704    #[tokio::test]
2705    async fn output_capture_is_capped_but_fully_drained() {
2706        let (mut writer, reader) = tokio::io::duplex(1024);
2707        let input = vec![b'x'; HOOK_OUTPUT_LIMIT_BYTES + 17];
2708        let write_task = tokio::spawn(async move {
2709            writer.write_all(&input).await.unwrap();
2710        });
2711        let captured = read_capped(reader).await.unwrap();
2712        write_task.await.unwrap();
2713        assert_eq!(captured.bytes.len(), HOOK_OUTPUT_LIMIT_BYTES);
2714        assert!(captured.truncated);
2715    }
2716}