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