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