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