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