1use std::path::PathBuf;
2use std::process::Stdio;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use bamboo_agent_core::{AgentHook, Session};
7use bamboo_config::{
8 LifecycleHookCommand, LifecycleHookGroup, LifecycleHookType, LifecycleHooksConfig,
9};
10use bamboo_domain::{
11 AgentHookPoint, HookPayload, HookResult, SessionEndStatus, SessionStartSource,
12};
13use bamboo_infrastructure::{
14 build_command_environment, hide_window_for_tokio_command, preferred_bash_shell,
15};
16use chrono::Utc;
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
20use tokio::process::{Child, Command};
21use tracing::warn;
22
23use super::HookRunner;
24
25const HOOK_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ShellHookEvent {
30 SessionStart,
31 UserPromptSubmit,
32 PreToolUse,
33 PostToolUse,
34 Stop,
35 SessionEnd,
36 PreCompact,
37 Notification,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct ShellHookTestOutput {
45 pub exit_code: Option<i32>,
46 pub stdout: String,
47 pub stderr: String,
48 pub timed_out: bool,
49 pub stdout_truncated: bool,
50 pub stderr_truncated: bool,
51}
52
53impl ShellHookEvent {
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::SessionStart => "SessionStart",
57 Self::UserPromptSubmit => "UserPromptSubmit",
58 Self::PreToolUse => "PreToolUse",
59 Self::PostToolUse => "PostToolUse",
60 Self::Stop => "Stop",
61 Self::SessionEnd => "SessionEnd",
62 Self::PreCompact => "PreCompact",
63 Self::Notification => "Notification",
64 }
65 }
66
67 fn point(self) -> AgentHookPoint {
68 match self {
69 Self::SessionStart => AgentHookPoint::AfterSessionSetup,
70 Self::UserPromptSubmit => AgentHookPoint::BeforeSessionSetup,
71 Self::PreToolUse => AgentHookPoint::BeforeToolExecution,
72 Self::PostToolUse => AgentHookPoint::AfterToolExecution,
73 Self::Stop => AgentHookPoint::BeforeFinalize,
74 Self::SessionEnd => AgentHookPoint::AfterSessionEnd,
75 Self::PreCompact => AgentHookPoint::BeforeCompression,
76 Self::Notification => AgentHookPoint::AfterNotification,
77 }
78 }
79
80 fn supports_tool_matcher(self) -> bool {
81 matches!(self, Self::PreToolUse | Self::PostToolUse)
82 }
83}
84
85pub struct ShellCommandHook {
87 event: ShellHookEvent,
88 event_name_override: Option<&'static str>,
89 command: String,
90 timeout: Duration,
91 matcher: Option<Regex>,
92 fallback_cwd: Option<PathBuf>,
93 name: String,
94}
95
96impl ShellCommandHook {
97 pub fn new(
98 event: ShellHookEvent,
99 config: &LifecycleHookCommand,
100 matcher: Option<&str>,
101 fallback_cwd: Option<PathBuf>,
102 sequence: usize,
103 ) -> Result<Self, regex::Error> {
104 let matcher = matcher.map(Regex::new).transpose()?;
105 Ok(Self {
106 event,
107 event_name_override: None,
108 command: config.command.clone(),
109 timeout: Duration::from_millis(config.timeout_ms.max(1)),
110 matcher,
111 fallback_cwd,
112 name: format!("lifecycle_shell:{}:{sequence}", event.as_str()),
113 })
114 }
115
116 fn event_name(&self) -> &'static str {
117 self.event_name_override
118 .unwrap_or_else(|| self.event.as_str())
119 }
120
121 fn effective_cwd(&self, session: &Session) -> Option<PathBuf> {
122 session
123 .workspace
124 .as_deref()
125 .filter(|workspace| !workspace.trim().is_empty())
126 .map(PathBuf::from)
127 .or_else(|| self.fallback_cwd.clone())
128 .or_else(|| std::env::current_dir().ok())
129 }
130
131 fn envelope(
132 &self,
133 payload: &HookPayload,
134 session: &Session,
135 cwd: Option<&PathBuf>,
136 ) -> HookEnvelope {
137 let (
138 tool_name,
139 tool_input,
140 tool_response,
141 prompt,
142 source,
143 stop_hook_active,
144 terminal_status,
145 completion_reason,
146 ) = match payload {
147 HookPayload::SessionSetup {
148 initial_message,
149 source,
150 } => (
151 None,
152 None,
153 None,
154 Some(initial_message.clone()),
155 Some(*source),
156 None,
157 None,
158 None,
159 ),
160 HookPayload::Prompt { prompt } => (
161 None,
162 None,
163 None,
164 Some(prompt.clone()),
165 None,
166 None,
167 None,
168 None,
169 ),
170 HookPayload::ToolExecution {
171 tool_name,
172 parsed_args,
173 ..
174 } => (
175 Some(tool_name.clone()),
176 Some(parsed_args.clone()),
177 None,
178 None,
179 None,
180 None,
181 None,
182 None,
183 ),
184 HookPayload::ToolResult {
185 tool_name, outcome, ..
186 } => (
187 Some(tool_name.clone()),
188 None,
189 serde_json::to_value(outcome).ok(),
190 None,
191 None,
192 None,
193 None,
194 None,
195 ),
196 HookPayload::Finalize { stop_hook_active } => (
197 None,
198 None,
199 None,
200 None,
201 None,
202 Some(*stop_hook_active),
203 None,
204 None,
205 ),
206 HookPayload::SessionEnd {
207 status,
208 completion_reason,
209 } => (
210 None,
211 None,
212 None,
213 None,
214 None,
215 None,
216 Some(*status),
217 completion_reason.clone(),
218 ),
219 HookPayload::None
220 | HookPayload::Round { .. }
221 | HookPayload::Compression { .. }
222 | HookPayload::Notification { .. } => (None, None, None, None, None, None, None, None),
223 };
224
225 HookEnvelope {
226 schema_version: 1,
227 hook_event_name: self.event_name().to_string(),
228 session_id: session.id.clone(),
229 workspace_path: cwd
230 .map(|path| path.to_string_lossy().into_owned())
231 .unwrap_or_default(),
232 model: session.model.clone(),
233 payload: payload.clone(),
234 tool_name,
235 tool_input,
236 tool_response,
237 prompt,
238 source,
239 stop_hook_active,
240 terminal_status,
241 completion_reason,
242 timestamp: Utc::now().to_rfc3339(),
243 }
244 }
245
246 async fn execute(
247 &self,
248 input: Vec<u8>,
249 cwd: Option<&PathBuf>,
250 session: &Session,
251 ) -> Result<CommandOutput, String> {
252 let shell = preferred_bash_shell();
253 let overrides = bamboo_llm::Config::current_env_vars();
254 let prepared_env = build_command_environment(&overrides).await;
255 let mut command = Command::new(&shell.program);
256 hide_window_for_tokio_command(&mut command);
257 prepared_env.apply_to_tokio_command(&mut command);
258 if let Some(cwd) = cwd {
259 command.current_dir(cwd);
260 }
261 command
262 .arg(shell.arg)
263 .arg(&self.command)
264 .env("BAMBOO_SESSION_ID", &session.id)
265 .env("BAMBOO_HOOK_EVENT", self.event_name())
266 .stdin(Stdio::piped())
267 .stdout(Stdio::piped())
268 .stderr(Stdio::piped())
269 .kill_on_drop(true);
270 #[cfg(unix)]
271 {
272 command.process_group(0);
277 }
278
279 let mut child = command
280 .spawn()
281 .map_err(|error| format!("failed to spawn lifecycle hook: {error}"))?;
282 let mut stdin = child
283 .stdin
284 .take()
285 .ok_or_else(|| "failed to open lifecycle hook stdin".to_string())?;
286 let stdout = child
287 .stdout
288 .take()
289 .ok_or_else(|| "failed to capture lifecycle hook stdout".to_string())?;
290 let stderr = child
291 .stderr
292 .take()
293 .ok_or_else(|| "failed to capture lifecycle hook stderr".to_string())?;
294
295 let input_task = tokio::spawn(async move {
296 let result = stdin.write_all(&input).await;
297 let _ = stdin.shutdown().await;
298 result
299 });
300 let stdout_task = tokio::spawn(read_capped(stdout));
301 let stderr_task = tokio::spawn(read_capped(stderr));
302
303 let (status, timed_out) = match tokio::time::timeout(self.timeout, child.wait()).await {
304 Ok(Ok(status)) => (Some(status), false),
305 Ok(Err(error)) => return Err(format!("failed waiting for lifecycle hook: {error}")),
306 Err(_) => {
307 kill_hook_process_tree(&mut child).await;
308 (None, true)
309 }
310 };
311
312 if let Ok(Err(error)) = input_task.await {
313 warn!(hook = %self.name, error = %error, "failed writing lifecycle hook stdin");
314 }
315 let stdout = stdout_task
316 .await
317 .map_err(|error| format!("lifecycle hook stdout task failed: {error}"))?
318 .map_err(|error| format!("failed reading lifecycle hook stdout: {error}"))?;
319 let stderr = stderr_task
320 .await
321 .map_err(|error| format!("lifecycle hook stderr task failed: {error}"))?
322 .map_err(|error| format!("failed reading lifecycle hook stderr: {error}"))?;
323
324 Ok(CommandOutput {
325 exit_code: status.and_then(|status| status.code()),
326 stdout,
327 stderr,
328 timed_out,
329 })
330 }
331
332 fn interpret(&self, output: CommandOutput) -> HookResult {
333 if output.stdout.truncated || output.stderr.truncated {
334 warn!(
335 hook = %self.name,
336 stdout_truncated = output.stdout.truncated,
337 stderr_truncated = output.stderr.truncated,
338 "lifecycle hook output exceeded capture limit"
339 );
340 }
341 if output.timed_out {
342 warn!(hook = %self.name, "lifecycle hook timed out and was killed");
343 return HookResult::Continue;
344 }
345
346 match output.exit_code {
347 Some(0) => self.interpret_success(&output.stdout.bytes),
348 Some(2) => {
349 let reason = String::from_utf8_lossy(&output.stderr.bytes)
350 .trim()
351 .to_string();
352 HookResult::Deny {
353 reason: if reason.is_empty() {
354 "lifecycle hook exited with blocking status 2".to_string()
355 } else {
356 reason
357 },
358 }
359 }
360 exit_code => {
361 warn!(hook = %self.name, ?exit_code, "lifecycle hook failed non-blocking");
362 HookResult::Continue
363 }
364 }
365 }
366
367 fn interpret_success(&self, stdout: &[u8]) -> HookResult {
368 let stdout = String::from_utf8_lossy(stdout);
369 let stdout = stdout.trim();
370 if stdout.is_empty() {
371 return HookResult::Continue;
372 }
373
374 let response: HookResponse = match serde_json::from_str(stdout) {
375 Ok(response) => response,
376 Err(error) => {
377 warn!(hook = %self.name, error = %error, "ignoring malformed lifecycle hook response");
378 return HookResult::Continue;
379 }
380 };
381 let HookResponse {
382 decision,
383 reason,
384 additional_context,
385 suppress_output: _suppress_output,
386 } = response;
387 let result = match decision {
388 Some(HookDecision::Block) => HookResult::Deny {
389 reason: reason
390 .filter(|reason| !reason.trim().is_empty())
391 .unwrap_or_else(|| "blocked by lifecycle hook".to_string()),
392 },
393 Some(HookDecision::Allow) => HookResult::Allow,
394 Some(HookDecision::Ask) => HookResult::Ask,
395 None => HookResult::Continue,
396 };
397 match additional_context.filter(|context| !context.trim().is_empty()) {
398 Some(text) if matches!(result, HookResult::Continue) => {
399 HookResult::InjectContext { text }
400 }
401 Some(text) => HookResult::WithContext {
402 result: Box::new(result),
403 text,
404 },
405 None => result,
406 }
407 }
408
409 async fn test(
410 &self,
411 payload: &HookPayload,
412 session: &Session,
413 ) -> Result<ShellHookTestOutput, String> {
414 let cwd = self.effective_cwd(session);
415 let input = serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref()))
416 .map_err(|error| format!("failed serializing lifecycle hook test payload: {error}"))?;
417 let output = self.execute(input, cwd.as_ref(), session).await?;
418 Ok(ShellHookTestOutput {
419 exit_code: output.exit_code,
420 stdout: String::from_utf8_lossy(&output.stdout.bytes).into_owned(),
421 stderr: String::from_utf8_lossy(&output.stderr.bytes).into_owned(),
422 timed_out: output.timed_out,
423 stdout_truncated: output.stdout.truncated,
424 stderr_truncated: output.stderr.truncated,
425 })
426 }
427}
428
429pub async fn test_lifecycle_shell_command(
433 event_name: &str,
434 config: &LifecycleHookCommand,
435 fallback_cwd: Option<PathBuf>,
436) -> Result<ShellHookTestOutput, String> {
437 let (event, event_name_override, payload) = match event_name {
438 "SessionStart" => (
439 ShellHookEvent::SessionStart,
440 None,
441 HookPayload::SessionSetup {
442 initial_message: "Lifecycle hook test".to_string(),
443 source: SessionStartSource::Startup,
444 },
445 ),
446 "UserPromptSubmit" => (
447 ShellHookEvent::UserPromptSubmit,
448 None,
449 HookPayload::Prompt {
450 prompt: "Lifecycle hook test".to_string(),
451 },
452 ),
453 "PreToolUse" => (
454 ShellHookEvent::PreToolUse,
455 None,
456 HookPayload::ToolExecution {
457 tool_name: "Bash".to_string(),
458 tool_call_id: "hook-test-call".to_string(),
459 parsed_args: serde_json::json!({"command": "echo lifecycle-hook-test"}),
460 },
461 ),
462 "PostToolUse" => (
463 ShellHookEvent::PostToolUse,
464 None,
465 HookPayload::ToolResult {
466 tool_name: "Bash".to_string(),
467 tool_call_id: "hook-test-call".to_string(),
468 outcome: bamboo_domain::HookToolOutcome {
469 success: true,
470 result: Some("lifecycle-hook-test".to_string()),
471 error: None,
472 needs_human: false,
473 duration_ms: 1,
474 },
475 },
476 ),
477 "Stop" => (
478 ShellHookEvent::Stop,
479 None,
480 HookPayload::Finalize {
481 stop_hook_active: false,
482 },
483 ),
484 "SessionEnd" => (
485 ShellHookEvent::SessionEnd,
486 None,
487 HookPayload::SessionEnd {
488 status: SessionEndStatus::Completed,
489 completion_reason: Some("lifecycle hook test".to_string()),
490 },
491 ),
492 "PreCompact" => (
493 ShellHookEvent::PreCompact,
494 None,
495 HookPayload::Compression {
496 estimated_tokens: 1_000,
497 usage_percent: 50.0,
498 max_context_tokens: 2_000,
499 trigger_context_tokens: 1_600,
500 trigger: "threshold".to_string(),
501 phase: "test".to_string(),
502 },
503 ),
504 "Notification" => (
505 ShellHookEvent::Notification,
506 None,
507 HookPayload::Notification {
508 id: Some("notification-test".to_string()),
509 category: "custom".to_string(),
510 priority: "normal".to_string(),
511 title: "Lifecycle hook test".to_string(),
512 body: "Synthetic notification delivery".to_string(),
513 dedup_key: Some("lifecycle-hook-test".to_string()),
514 created_at: Some(Utc::now().to_rfc3339()),
515 click_url: None,
516 },
517 ),
518 other => return Err(format!("unknown lifecycle hook event '{other}'")),
519 };
520
521 let mut hook = ShellCommandHook::new(event, config, None, fallback_cwd, 0)
522 .map_err(|error| format!("invalid lifecycle hook matcher: {error}"))?;
523 hook.event_name_override = event_name_override;
524 hook.name = format!("lifecycle_shell_test:{event_name}");
525 let session = Session::new("lifecycle-hook-test", "hook-test");
526 hook.test(&payload, &session).await
527}
528
529#[cfg(unix)]
530async fn kill_hook_process_tree(child: &mut Child) {
531 if let Some(pid) = child.id() {
532 unsafe {
535 libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
536 }
537 }
538 let _ = child.wait().await;
539}
540
541#[cfg(windows)]
542async fn kill_hook_process_tree(child: &mut Child) {
543 if let Some(pid) = child.id() {
544 let pid = pid.to_string();
545 let mut kill = Command::new("taskkill");
546 hide_window_for_tokio_command(&mut kill);
547 let _ = kill.args(["/F", "/T", "/PID", &pid]).status().await;
548 }
549 let _ = child.start_kill();
550 let _ = child.wait().await;
551}
552
553#[cfg(not(any(unix, windows)))]
554async fn kill_hook_process_tree(child: &mut Child) {
555 let _ = child.kill().await;
556 let _ = child.wait().await;
557}
558
559#[async_trait]
560impl AgentHook for ShellCommandHook {
561 fn point(&self) -> AgentHookPoint {
562 self.event.point()
563 }
564
565 async fn run(
566 &self,
567 _point: AgentHookPoint,
568 payload: &HookPayload,
569 session: &Session,
570 ) -> HookResult {
571 let cwd = self.effective_cwd(session);
572 let input = match serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref())) {
573 Ok(input) => input,
574 Err(error) => {
575 warn!(hook = %self.name, error = %error, "failed serializing lifecycle hook payload");
576 return HookResult::Continue;
577 }
578 };
579 match self.execute(input, cwd.as_ref(), session).await {
580 Ok(output) => self.interpret(output),
581 Err(error) => {
582 warn!(hook = %self.name, error = %error, "lifecycle hook execution failed non-blocking");
583 HookResult::Continue
584 }
585 }
586 }
587
588 fn matches(&self, payload: &HookPayload) -> bool {
589 let Some(matcher) = &self.matcher else {
590 return true;
591 };
592 match payload {
593 HookPayload::ToolExecution { tool_name, .. }
594 | HookPayload::ToolResult { tool_name, .. } => matcher.is_match(tool_name),
595 _ => false,
596 }
597 }
598
599 fn name(&self) -> &str {
600 &self.name
601 }
602}
603
604#[derive(Debug, Serialize)]
605struct HookEnvelope {
606 schema_version: u8,
607 hook_event_name: String,
608 session_id: String,
609 workspace_path: String,
610 model: String,
611 payload: HookPayload,
614 #[serde(skip_serializing_if = "Option::is_none")]
615 tool_name: Option<String>,
616 #[serde(skip_serializing_if = "Option::is_none")]
617 tool_input: Option<serde_json::Value>,
618 #[serde(skip_serializing_if = "Option::is_none")]
619 tool_response: Option<serde_json::Value>,
620 #[serde(skip_serializing_if = "Option::is_none")]
621 prompt: Option<String>,
622 #[serde(skip_serializing_if = "Option::is_none")]
623 source: Option<SessionStartSource>,
624 #[serde(skip_serializing_if = "Option::is_none")]
625 stop_hook_active: Option<bool>,
626 #[serde(skip_serializing_if = "Option::is_none")]
627 terminal_status: Option<SessionEndStatus>,
628 #[serde(skip_serializing_if = "Option::is_none")]
629 completion_reason: Option<String>,
630 timestamp: String,
631}
632
633#[derive(Debug, Deserialize)]
634struct HookResponse {
635 #[serde(default)]
636 decision: Option<HookDecision>,
637 #[serde(default)]
638 reason: Option<String>,
639 #[serde(default)]
640 additional_context: Option<String>,
641 #[serde(default)]
642 suppress_output: bool,
643}
644
645#[derive(Debug, Deserialize)]
646#[serde(rename_all = "lowercase")]
647enum HookDecision {
648 Block,
649 Allow,
650 Ask,
651}
652
653#[derive(Debug)]
654struct CommandOutput {
655 exit_code: Option<i32>,
656 stdout: CapturedOutput,
657 stderr: CapturedOutput,
658 timed_out: bool,
659}
660
661#[derive(Debug, Default)]
662struct CapturedOutput {
663 bytes: Vec<u8>,
664 truncated: bool,
665}
666
667async fn read_capped(mut reader: impl AsyncRead + Unpin) -> Result<CapturedOutput, std::io::Error> {
668 let mut captured = CapturedOutput::default();
669 let mut chunk = [0_u8; 8192];
670 loop {
671 let read = reader.read(&mut chunk).await?;
672 if read == 0 {
673 break;
674 }
675 let remaining = HOOK_OUTPUT_LIMIT_BYTES.saturating_sub(captured.bytes.len());
676 let keep = remaining.min(read);
677 captured.bytes.extend_from_slice(&chunk[..keep]);
678 captured.truncated |= keep < read;
679 }
680 Ok(captured)
681}
682
683pub(super) fn register_configured_shell_hooks(
684 runner: &mut HookRunner,
685 config: &LifecycleHooksConfig,
686 fallback_cwd: Option<PathBuf>,
687) {
688 if !config.enabled {
689 return;
690 }
691
692 let events: [(ShellHookEvent, &[LifecycleHookGroup]); 8] = [
693 (ShellHookEvent::SessionStart, &config.session_start),
694 (ShellHookEvent::UserPromptSubmit, &config.user_prompt_submit),
695 (ShellHookEvent::PreToolUse, &config.pre_tool_use),
696 (ShellHookEvent::PostToolUse, &config.post_tool_use),
697 (ShellHookEvent::Stop, &config.stop),
698 (ShellHookEvent::SessionEnd, &config.session_end),
699 (ShellHookEvent::PreCompact, &config.pre_compact),
700 (ShellHookEvent::Notification, &config.notification),
701 ];
702 let mut sequence = 0_usize;
703 for (event, groups) in events {
704 for group in groups {
705 if !group.enabled {
706 sequence += group.hooks.len();
707 continue;
708 }
709 let matcher = if event.supports_tool_matcher() {
710 group.matcher.as_deref()
711 } else {
712 if group.matcher.is_some() {
713 warn!(
714 event = event.as_str(),
715 "ignoring matcher on non-tool lifecycle hook"
716 );
717 }
718 None
719 };
720 for command in &group.hooks {
721 match command.hook_type {
722 LifecycleHookType::Command => {
723 match ShellCommandHook::new(
724 event,
725 command,
726 matcher,
727 fallback_cwd.clone(),
728 sequence,
729 ) {
730 Ok(hook) => runner.register(std::sync::Arc::new(hook)),
731 Err(error) => warn!(
732 event = event.as_str(),
733 error = %error,
734 "skipping lifecycle hook with invalid matcher"
735 ),
736 }
737 }
738 }
739 sequence += 1;
740 }
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS;
749 use serde_json::json;
750 use std::time::Instant;
751
752 fn command(command: impl Into<String>, timeout_ms: u64) -> LifecycleHookCommand {
753 LifecycleHookCommand {
754 hook_type: LifecycleHookType::Command,
755 command: command.into(),
756 timeout_ms,
757 }
758 }
759
760 fn session(workspace: &std::path::Path) -> Session {
761 let mut session = Session::new("session-1", "test-model");
762 session.workspace = Some(workspace.to_string_lossy().into_owned());
763 session
764 }
765
766 #[tokio::test]
767 async fn exit_zero_without_output_passes_through() {
768 let dir = tempfile::tempdir().unwrap();
769 let hook = ShellCommandHook::new(
770 ShellHookEvent::SessionStart,
771 &command("printf ''", DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS),
772 None,
773 None,
774 0,
775 )
776 .unwrap();
777 let result = hook
778 .run(
779 AgentHookPoint::AfterSessionSetup,
780 &HookPayload::SessionSetup {
781 initial_message: "hello".to_string(),
782 source: SessionStartSource::Startup,
783 },
784 &session(dir.path()),
785 )
786 .await;
787 assert_eq!(result, HookResult::Continue);
788 }
789
790 #[tokio::test]
791 async fn exit_two_blocks_with_stderr_reason() {
792 let dir = tempfile::tempdir().unwrap();
793 let hook = ShellCommandHook::new(
794 ShellHookEvent::PreToolUse,
795 &command("printf 'blocked by policy' >&2; exit 2", 1_000),
796 None,
797 None,
798 0,
799 )
800 .unwrap();
801 let result = hook
802 .run(
803 AgentHookPoint::BeforeToolExecution,
804 &HookPayload::ToolExecution {
805 tool_name: "bash".to_string(),
806 tool_call_id: "call-1".to_string(),
807 parsed_args: json!({"command": "pwd"}),
808 },
809 &session(dir.path()),
810 )
811 .await;
812 assert_eq!(
813 result,
814 HookResult::Deny {
815 reason: "blocked by policy".to_string()
816 }
817 );
818 }
819
820 #[tokio::test]
821 async fn versioned_json_protocol_is_delivered_on_stdin() {
822 let dir = tempfile::tempdir().unwrap();
823 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"#;
824 let hook = ShellCommandHook::new(
825 ShellHookEvent::PreToolUse,
826 &command(shell, 1_000),
827 None,
828 None,
829 0,
830 )
831 .unwrap();
832 let result = hook
833 .run(
834 AgentHookPoint::BeforeToolExecution,
835 &HookPayload::ToolExecution {
836 tool_name: "bash".to_string(),
837 tool_call_id: "call-1".to_string(),
838 parsed_args: json!({"command": "pwd"}),
839 },
840 &session(dir.path()),
841 )
842 .await;
843 assert_eq!(result, HookResult::Allow);
844 }
845
846 #[tokio::test]
847 async fn stdout_decisions_and_context_are_parsed() {
848 let dir = tempfile::tempdir().unwrap();
849 for (body, expected) in [
850 (r#"{"decision":"allow"}"#, HookResult::Allow),
851 (r#"{"decision":"ask"}"#, HookResult::Ask),
852 (
853 r#"{"decision":"block","reason":"nope"}"#,
854 HookResult::Deny {
855 reason: "nope".to_string(),
856 },
857 ),
858 (
859 r#"{"additional_context":"remember this"}"#,
860 HookResult::InjectContext {
861 text: "remember this".to_string(),
862 },
863 ),
864 (
865 r#"{"decision":"allow","additional_context":"allowed context"}"#,
866 HookResult::WithContext {
867 result: Box::new(HookResult::Allow),
868 text: "allowed context".to_string(),
869 },
870 ),
871 ] {
872 let shell = format!("printf '%s' '{body}'");
873 let hook = ShellCommandHook::new(
874 ShellHookEvent::SessionStart,
875 &command(shell, 1_000),
876 None,
877 None,
878 0,
879 )
880 .unwrap();
881 assert_eq!(
882 hook.run(
883 AgentHookPoint::AfterSessionSetup,
884 &HookPayload::None,
885 &session(dir.path()),
886 )
887 .await,
888 expected
889 );
890 }
891 }
892
893 #[tokio::test]
894 async fn runner_preserves_decision_and_context_from_one_response() {
895 let dir = tempfile::tempdir().unwrap();
896 let mut runner = HookRunner::new();
897 runner.register(std::sync::Arc::new(
898 ShellCommandHook::new(
899 ShellHookEvent::SessionStart,
900 &command(
901 r#"printf '%s' '{"decision":"allow","additional_context":"keep me"}'"#,
902 1_000,
903 ),
904 None,
905 None,
906 0,
907 )
908 .unwrap(),
909 ));
910 let session = session(dir.path());
911 let mut state = bamboo_domain::AgentRuntimeState::new("run-1");
912 let outcome = runner
913 .run_hooks(
914 AgentHookPoint::AfterSessionSetup,
915 &HookPayload::None,
916 &session,
917 &mut state,
918 None,
919 )
920 .await;
921 assert_eq!(outcome.decision, HookResult::Allow);
922 assert_eq!(outcome.injected_contexts, vec!["keep me"]);
923 }
924
925 #[tokio::test]
926 async fn malformed_json_is_non_blocking() {
927 let dir = tempfile::tempdir().unwrap();
928 let hook = ShellCommandHook::new(
929 ShellHookEvent::SessionStart,
930 &command("printf 'not-json'", 1_000),
931 None,
932 None,
933 0,
934 )
935 .unwrap();
936 assert_eq!(
937 hook.run(
938 AgentHookPoint::AfterSessionSetup,
939 &HookPayload::None,
940 &session(dir.path()),
941 )
942 .await,
943 HookResult::Continue
944 );
945 }
946
947 #[tokio::test]
948 async fn timeout_kills_hook_and_continues() {
949 let dir = tempfile::tempdir().unwrap();
950 let hook = ShellCommandHook::new(
951 ShellHookEvent::SessionStart,
952 &command("sleep 5", 25),
953 None,
954 None,
955 0,
956 )
957 .unwrap();
958 let started = Instant::now();
959 let result = hook
960 .run(
961 AgentHookPoint::AfterSessionSetup,
962 &HookPayload::None,
963 &session(dir.path()),
964 )
965 .await;
966 assert_eq!(result, HookResult::Continue);
967 assert!(started.elapsed() < Duration::from_secs(2));
968 }
969
970 #[test]
971 fn matcher_filters_tool_names() {
972 let hook = ShellCommandHook::new(
973 ShellHookEvent::PreToolUse,
974 &command("exit 2", 1_000),
975 Some("^(bash|write_file)$"),
976 None,
977 0,
978 )
979 .unwrap();
980 let payload = |tool_name: &str| HookPayload::ToolExecution {
981 tool_name: tool_name.to_string(),
982 tool_call_id: "call-1".to_string(),
983 parsed_args: json!({}),
984 };
985 assert!(hook.matches(&payload("bash")));
986 assert!(!hook.matches(&payload("read_file")));
987 }
988
989 #[test]
990 fn envelope_contains_versioned_protocol_fields() {
991 let dir = tempfile::tempdir().unwrap();
992 let hook = ShellCommandHook::new(
993 ShellHookEvent::PreToolUse,
994 &command("true", 1_000),
995 None,
996 None,
997 0,
998 )
999 .unwrap();
1000 let session = session(dir.path());
1001 let payload = HookPayload::ToolExecution {
1002 tool_name: "bash".to_string(),
1003 tool_call_id: "call-1".to_string(),
1004 parsed_args: json!({"command": "pwd"}),
1005 };
1006 let value = serde_json::to_value(hook.envelope(
1007 &payload,
1008 &session,
1009 hook.effective_cwd(&session).as_ref(),
1010 ))
1011 .unwrap();
1012 assert_eq!(value["schema_version"], 1);
1013 assert_eq!(value["hook_event_name"], "PreToolUse");
1014 assert_eq!(value["session_id"], "session-1");
1015 assert_eq!(value["model"], "test-model");
1016 assert_eq!(value["tool_name"], "bash");
1017 assert_eq!(value["tool_input"]["command"], "pwd");
1018 assert_eq!(value["payload"]["type"], "tool_execution");
1019 assert_eq!(value["payload"]["tool_call_id"], "call-1");
1020 assert!(value["timestamp"].as_str().is_some());
1021 }
1022
1023 #[test]
1024 fn session_envelopes_include_source_stop_and_terminal_fields() {
1025 let dir = tempfile::tempdir().unwrap();
1026 let session = session(dir.path());
1027 for (event, payload, expected) in [
1028 (
1029 ShellHookEvent::SessionStart,
1030 HookPayload::SessionSetup {
1031 initial_message: "hello".to_string(),
1032 source: SessionStartSource::Resume,
1033 },
1034 ("source", json!("resume")),
1035 ),
1036 (
1037 ShellHookEvent::Stop,
1038 HookPayload::Finalize {
1039 stop_hook_active: true,
1040 },
1041 ("stop_hook_active", json!(true)),
1042 ),
1043 (
1044 ShellHookEvent::SessionEnd,
1045 HookPayload::SessionEnd {
1046 status: SessionEndStatus::Cancelled,
1047 completion_reason: Some("cancelled by user".to_string()),
1048 },
1049 ("terminal_status", json!("cancelled")),
1050 ),
1051 ] {
1052 let hook =
1053 ShellCommandHook::new(event, &command("true", 1_000), None, None, 0).expect("hook");
1054 let value = serde_json::to_value(hook.envelope(
1055 &payload,
1056 &session,
1057 hook.effective_cwd(&session).as_ref(),
1058 ))
1059 .unwrap();
1060 assert_eq!(value["hook_event_name"], event.as_str());
1061 assert_eq!(value[expected.0], expected.1);
1062 if matches!(event, ShellHookEvent::SessionEnd) {
1063 assert_eq!(value["completion_reason"], "cancelled by user");
1064 }
1065 }
1066 }
1067
1068 #[test]
1069 fn configured_runner_registers_all_enabled_events() {
1070 let hook = command("true", 1_000);
1071 let config = LifecycleHooksConfig {
1072 enabled: true,
1073 pre_tool_use: vec![LifecycleHookGroup {
1074 enabled: true,
1075 matcher: Some("bash".to_string()),
1076 hooks: vec![hook.clone()],
1077 }],
1078 session_start: vec![LifecycleHookGroup {
1079 enabled: true,
1080 matcher: None,
1081 hooks: vec![hook.clone()],
1082 }],
1083 user_prompt_submit: vec![LifecycleHookGroup {
1084 enabled: true,
1085 matcher: None,
1086 hooks: vec![hook.clone()],
1087 }],
1088 session_end: vec![LifecycleHookGroup {
1089 enabled: true,
1090 matcher: None,
1091 hooks: vec![hook.clone()],
1092 }],
1093 notification: vec![LifecycleHookGroup {
1094 enabled: true,
1095 matcher: None,
1096 hooks: vec![hook],
1097 }],
1098 ..LifecycleHooksConfig::default()
1099 };
1100
1101 let base = HookRunner::new();
1102 let configured = base.with_lifecycle_config(&config, None);
1103 assert!(
1104 base.is_empty(),
1105 "per-run registration must not mutate the base"
1106 );
1107 assert_eq!(configured.len(), 5);
1108 assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionSetup));
1109 assert!(configured.has_hooks_for(AgentHookPoint::BeforeSessionSetup));
1110 assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionEnd));
1111 assert!(configured.has_hooks_for(AgentHookPoint::BeforeToolExecution));
1112 assert!(configured.has_hooks_for(AgentHookPoint::AfterNotification));
1113 }
1114
1115 #[test]
1116 fn disabled_group_is_preserved_but_not_registered() {
1117 let config = LifecycleHooksConfig {
1118 enabled: true,
1119 pre_tool_use: vec![LifecycleHookGroup {
1120 enabled: false,
1121 matcher: None,
1122 hooks: vec![command("exit 2", 1_000)],
1123 }],
1124 ..LifecycleHooksConfig::default()
1125 };
1126
1127 let configured = HookRunner::new().with_lifecycle_config(&config, None);
1128
1129 assert!(configured.is_empty());
1130 }
1131
1132 #[tokio::test]
1133 async fn configured_commands_run_in_order_and_first_block_wins() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let config = LifecycleHooksConfig {
1136 enabled: true,
1137 pre_tool_use: vec![LifecycleHookGroup {
1138 enabled: true,
1139 matcher: None,
1140 hooks: vec![
1141 command("printf 'first' >&2; exit 2", 1_000),
1142 command("printf 'second' >&2; exit 2", 1_000),
1143 ],
1144 }],
1145 ..LifecycleHooksConfig::default()
1146 };
1147 let runner = HookRunner::new().with_lifecycle_config(&config, None);
1148 let mut state = bamboo_domain::AgentRuntimeState::new("run-ordered");
1149 let outcome = runner
1150 .run_hooks(
1151 AgentHookPoint::BeforeToolExecution,
1152 &HookPayload::ToolExecution {
1153 tool_name: "bash".to_string(),
1154 tool_call_id: "call-1".to_string(),
1155 parsed_args: json!({}),
1156 },
1157 &session(dir.path()),
1158 &mut state,
1159 None,
1160 )
1161 .await;
1162 assert_eq!(
1163 outcome.decision,
1164 HookResult::Deny {
1165 reason: "first".to_string()
1166 }
1167 );
1168 assert_eq!(state.checkpoints.len(), 1);
1169 }
1170
1171 #[tokio::test]
1172 async fn precompact_observer_ignores_block_and_collects_later_instructions() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let config = LifecycleHooksConfig {
1175 enabled: true,
1176 pre_compact: vec![LifecycleHookGroup {
1177 enabled: true,
1178 matcher: None,
1179 hooks: vec![
1180 command("printf 'cannot block compaction' >&2; exit 2", 1_000),
1181 command(
1182 r#"printf '%s' '{"additional_context":"preserve the failing assertion"}'"#,
1183 1_000,
1184 ),
1185 ],
1186 }],
1187 ..LifecycleHooksConfig::default()
1188 };
1189 let runner = HookRunner::new().with_lifecycle_config(&config, None);
1190 let mut state = bamboo_domain::AgentRuntimeState::new("run-precompact");
1191 let outcome = runner
1192 .run_observer_hooks(
1193 AgentHookPoint::BeforeCompression,
1194 &HookPayload::Compression {
1195 estimated_tokens: 1_700,
1196 usage_percent: 85.0,
1197 max_context_tokens: 2_000,
1198 trigger_context_tokens: 1_600,
1199 trigger: "threshold".to_string(),
1200 phase: "pre-turn".to_string(),
1201 },
1202 &session(dir.path()),
1203 &mut state,
1204 None,
1205 )
1206 .await;
1207
1208 assert_eq!(state.checkpoints.len(), 2);
1209 assert_eq!(
1210 outcome.injected_contexts,
1211 vec!["preserve the failing assertion".to_string()]
1212 );
1213 }
1214
1215 #[test]
1216 fn invalid_matcher_is_skipped_without_failing_registration() {
1217 let config = LifecycleHooksConfig {
1218 enabled: true,
1219 pre_tool_use: vec![LifecycleHookGroup {
1220 enabled: true,
1221 matcher: Some("[".to_string()),
1222 hooks: vec![command("true", 1_000)],
1223 }],
1224 ..LifecycleHooksConfig::default()
1225 };
1226 let configured = HookRunner::new().with_lifecycle_config(&config, None);
1227 assert!(configured.is_empty());
1228 }
1229
1230 #[tokio::test]
1231 async fn output_capture_is_capped_but_fully_drained() {
1232 let (mut writer, reader) = tokio::io::duplex(1024);
1233 let input = vec![b'x'; HOOK_OUTPUT_LIMIT_BYTES + 17];
1234 let write_task = tokio::spawn(async move {
1235 writer.write_all(&input).await.unwrap();
1236 });
1237 let captured = read_capped(reader).await.unwrap();
1238 write_task.await.unwrap();
1239 assert_eq!(captured.bytes.len(), HOOK_OUTPUT_LIMIT_BYTES);
1240 assert!(captured.truncated);
1241 }
1242}