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