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