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::{AgentHookPoint, HookPayload, HookResult};
11use bamboo_infrastructure::{
12 build_command_environment, hide_window_for_tokio_command, preferred_bash_shell,
13};
14use chrono::Utc;
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
18use tokio::process::{Child, Command};
19use tracing::warn;
20
21use super::HookRunner;
22
23const HOOK_OUTPUT_LIMIT_BYTES: usize = 64 * 1024;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ShellHookEvent {
28 SessionStart,
29 PreToolUse,
30 PostToolUse,
31 Stop,
32 PreCompact,
33}
34
35impl ShellHookEvent {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::SessionStart => "SessionStart",
39 Self::PreToolUse => "PreToolUse",
40 Self::PostToolUse => "PostToolUse",
41 Self::Stop => "Stop",
42 Self::PreCompact => "PreCompact",
43 }
44 }
45
46 fn point(self) -> AgentHookPoint {
47 match self {
48 Self::SessionStart => AgentHookPoint::AfterSessionSetup,
49 Self::PreToolUse => AgentHookPoint::BeforeToolExecution,
50 Self::PostToolUse => AgentHookPoint::AfterToolExecution,
51 Self::Stop => AgentHookPoint::BeforeFinalize,
52 Self::PreCompact => AgentHookPoint::BeforeCompression,
53 }
54 }
55
56 fn supports_tool_matcher(self) -> bool {
57 matches!(self, Self::PreToolUse | Self::PostToolUse)
58 }
59}
60
61pub struct ShellCommandHook {
63 event: ShellHookEvent,
64 command: String,
65 timeout: Duration,
66 matcher: Option<Regex>,
67 fallback_cwd: Option<PathBuf>,
68 name: String,
69}
70
71impl ShellCommandHook {
72 pub fn new(
73 event: ShellHookEvent,
74 config: &LifecycleHookCommand,
75 matcher: Option<&str>,
76 fallback_cwd: Option<PathBuf>,
77 sequence: usize,
78 ) -> Result<Self, regex::Error> {
79 let matcher = matcher.map(Regex::new).transpose()?;
80 Ok(Self {
81 event,
82 command: config.command.clone(),
83 timeout: Duration::from_millis(config.timeout_ms.max(1)),
84 matcher,
85 fallback_cwd,
86 name: format!("lifecycle_shell:{}:{sequence}", event.as_str()),
87 })
88 }
89
90 fn effective_cwd(&self, session: &Session) -> Option<PathBuf> {
91 session
92 .workspace
93 .as_deref()
94 .filter(|workspace| !workspace.trim().is_empty())
95 .map(PathBuf::from)
96 .or_else(|| self.fallback_cwd.clone())
97 .or_else(|| std::env::current_dir().ok())
98 }
99
100 fn envelope(
101 &self,
102 payload: &HookPayload,
103 session: &Session,
104 cwd: Option<&PathBuf>,
105 ) -> HookEnvelope {
106 let (tool_name, tool_input, tool_response, prompt) = match payload {
107 HookPayload::SessionSetup { initial_message } => {
108 (None, None, None, Some(initial_message.clone()))
109 }
110 HookPayload::Prompt { prompt } => (None, None, None, Some(prompt.clone())),
111 HookPayload::ToolExecution {
112 tool_name,
113 parsed_args,
114 ..
115 } => (
116 Some(tool_name.clone()),
117 Some(parsed_args.clone()),
118 None,
119 None,
120 ),
121 HookPayload::ToolResult {
122 tool_name, outcome, ..
123 } => (
124 Some(tool_name.clone()),
125 None,
126 serde_json::to_value(outcome).ok(),
127 None,
128 ),
129 HookPayload::None
130 | HookPayload::Round { .. }
131 | HookPayload::Compression { .. }
132 | HookPayload::Finalize => (None, None, None, None),
133 };
134
135 HookEnvelope {
136 schema_version: 1,
137 hook_event_name: self.event.as_str(),
138 session_id: session.id.clone(),
139 workspace_path: cwd
140 .map(|path| path.to_string_lossy().into_owned())
141 .unwrap_or_default(),
142 model: session.model.clone(),
143 tool_name,
144 tool_input,
145 tool_response,
146 prompt,
147 timestamp: Utc::now().to_rfc3339(),
148 }
149 }
150
151 async fn execute(
152 &self,
153 input: Vec<u8>,
154 cwd: Option<&PathBuf>,
155 session: &Session,
156 ) -> Result<CommandOutput, String> {
157 let shell = preferred_bash_shell();
158 let overrides = bamboo_llm::Config::current_env_vars();
159 let prepared_env = build_command_environment(&overrides).await;
160 let mut command = Command::new(&shell.program);
161 hide_window_for_tokio_command(&mut command);
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.as_str())
171 .stdin(Stdio::piped())
172 .stdout(Stdio::piped())
173 .stderr(Stdio::piped())
174 .kill_on_drop(true);
175 #[cfg(unix)]
176 {
177 command.process_group(0);
182 }
183
184 let mut child = command
185 .spawn()
186 .map_err(|error| format!("failed to spawn lifecycle hook: {error}"))?;
187 let mut stdin = child
188 .stdin
189 .take()
190 .ok_or_else(|| "failed to open lifecycle hook stdin".to_string())?;
191 let stdout = child
192 .stdout
193 .take()
194 .ok_or_else(|| "failed to capture lifecycle hook stdout".to_string())?;
195 let stderr = child
196 .stderr
197 .take()
198 .ok_or_else(|| "failed to capture lifecycle hook stderr".to_string())?;
199
200 let input_task = tokio::spawn(async move {
201 let result = stdin.write_all(&input).await;
202 let _ = stdin.shutdown().await;
203 result
204 });
205 let stdout_task = tokio::spawn(read_capped(stdout));
206 let stderr_task = tokio::spawn(read_capped(stderr));
207
208 let (status, timed_out) = match tokio::time::timeout(self.timeout, child.wait()).await {
209 Ok(Ok(status)) => (Some(status), false),
210 Ok(Err(error)) => return Err(format!("failed waiting for lifecycle hook: {error}")),
211 Err(_) => {
212 kill_hook_process_tree(&mut child).await;
213 (None, true)
214 }
215 };
216
217 if let Ok(Err(error)) = input_task.await {
218 warn!(hook = %self.name, error = %error, "failed writing lifecycle hook stdin");
219 }
220 let stdout = stdout_task
221 .await
222 .map_err(|error| format!("lifecycle hook stdout task failed: {error}"))?
223 .map_err(|error| format!("failed reading lifecycle hook stdout: {error}"))?;
224 let stderr = stderr_task
225 .await
226 .map_err(|error| format!("lifecycle hook stderr task failed: {error}"))?
227 .map_err(|error| format!("failed reading lifecycle hook stderr: {error}"))?;
228
229 Ok(CommandOutput {
230 exit_code: status.and_then(|status| status.code()),
231 stdout,
232 stderr,
233 timed_out,
234 })
235 }
236
237 fn interpret(&self, output: CommandOutput) -> HookResult {
238 if output.stdout.truncated || output.stderr.truncated {
239 warn!(
240 hook = %self.name,
241 stdout_truncated = output.stdout.truncated,
242 stderr_truncated = output.stderr.truncated,
243 "lifecycle hook output exceeded capture limit"
244 );
245 }
246 if output.timed_out {
247 warn!(hook = %self.name, "lifecycle hook timed out and was killed");
248 return HookResult::Continue;
249 }
250
251 match output.exit_code {
252 Some(0) => self.interpret_success(&output.stdout.bytes),
253 Some(2) => {
254 let reason = String::from_utf8_lossy(&output.stderr.bytes)
255 .trim()
256 .to_string();
257 HookResult::Deny {
258 reason: if reason.is_empty() {
259 "lifecycle hook exited with blocking status 2".to_string()
260 } else {
261 reason
262 },
263 }
264 }
265 exit_code => {
266 warn!(hook = %self.name, ?exit_code, "lifecycle hook failed non-blocking");
267 HookResult::Continue
268 }
269 }
270 }
271
272 fn interpret_success(&self, stdout: &[u8]) -> HookResult {
273 let stdout = String::from_utf8_lossy(stdout);
274 let stdout = stdout.trim();
275 if stdout.is_empty() {
276 return HookResult::Continue;
277 }
278
279 let response: HookResponse = match serde_json::from_str(stdout) {
280 Ok(response) => response,
281 Err(error) => {
282 warn!(hook = %self.name, error = %error, "ignoring malformed lifecycle hook response");
283 return HookResult::Continue;
284 }
285 };
286 let HookResponse {
287 decision,
288 reason,
289 additional_context,
290 suppress_output: _suppress_output,
291 } = response;
292 let result = match decision {
293 Some(HookDecision::Block) => HookResult::Deny {
294 reason: reason
295 .filter(|reason| !reason.trim().is_empty())
296 .unwrap_or_else(|| "blocked by lifecycle hook".to_string()),
297 },
298 Some(HookDecision::Allow) => HookResult::Allow,
299 Some(HookDecision::Ask) => HookResult::Ask,
300 None => HookResult::Continue,
301 };
302 match additional_context.filter(|context| !context.trim().is_empty()) {
303 Some(text) if matches!(result, HookResult::Continue) => {
304 HookResult::InjectContext { text }
305 }
306 Some(text) => HookResult::WithContext {
307 result: Box::new(result),
308 text,
309 },
310 None => result,
311 }
312 }
313}
314
315#[cfg(unix)]
316async fn kill_hook_process_tree(child: &mut Child) {
317 if let Some(pid) = child.id() {
318 unsafe {
321 libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
322 }
323 }
324 let _ = child.wait().await;
325}
326
327#[cfg(windows)]
328async fn kill_hook_process_tree(child: &mut Child) {
329 if let Some(pid) = child.id() {
330 let pid = pid.to_string();
331 let mut kill = Command::new("taskkill");
332 hide_window_for_tokio_command(&mut kill);
333 let _ = kill.args(["/F", "/T", "/PID", &pid]).status().await;
334 }
335 let _ = child.start_kill();
336 let _ = child.wait().await;
337}
338
339#[cfg(not(any(unix, windows)))]
340async fn kill_hook_process_tree(child: &mut Child) {
341 let _ = child.kill().await;
342 let _ = child.wait().await;
343}
344
345#[async_trait]
346impl AgentHook for ShellCommandHook {
347 fn point(&self) -> AgentHookPoint {
348 self.event.point()
349 }
350
351 async fn run(
352 &self,
353 _point: AgentHookPoint,
354 payload: &HookPayload,
355 session: &Session,
356 ) -> HookResult {
357 let cwd = self.effective_cwd(session);
358 let input = match serde_json::to_vec(&self.envelope(payload, session, cwd.as_ref())) {
359 Ok(input) => input,
360 Err(error) => {
361 warn!(hook = %self.name, error = %error, "failed serializing lifecycle hook payload");
362 return HookResult::Continue;
363 }
364 };
365 match self.execute(input, cwd.as_ref(), session).await {
366 Ok(output) => self.interpret(output),
367 Err(error) => {
368 warn!(hook = %self.name, error = %error, "lifecycle hook execution failed non-blocking");
369 HookResult::Continue
370 }
371 }
372 }
373
374 fn matches(&self, payload: &HookPayload) -> bool {
375 let Some(matcher) = &self.matcher else {
376 return true;
377 };
378 match payload {
379 HookPayload::ToolExecution { tool_name, .. }
380 | HookPayload::ToolResult { tool_name, .. } => matcher.is_match(tool_name),
381 _ => false,
382 }
383 }
384
385 fn name(&self) -> &str {
386 &self.name
387 }
388}
389
390#[derive(Debug, Serialize)]
391struct HookEnvelope {
392 schema_version: u8,
393 hook_event_name: &'static str,
394 session_id: String,
395 workspace_path: String,
396 model: String,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 tool_name: Option<String>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 tool_input: Option<serde_json::Value>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 tool_response: Option<serde_json::Value>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 prompt: Option<String>,
405 timestamp: String,
406}
407
408#[derive(Debug, Deserialize)]
409struct HookResponse {
410 #[serde(default)]
411 decision: Option<HookDecision>,
412 #[serde(default)]
413 reason: Option<String>,
414 #[serde(default)]
415 additional_context: Option<String>,
416 #[serde(default)]
417 suppress_output: bool,
418}
419
420#[derive(Debug, Deserialize)]
421#[serde(rename_all = "lowercase")]
422enum HookDecision {
423 Block,
424 Allow,
425 Ask,
426}
427
428#[derive(Debug)]
429struct CommandOutput {
430 exit_code: Option<i32>,
431 stdout: CapturedOutput,
432 stderr: CapturedOutput,
433 timed_out: bool,
434}
435
436#[derive(Debug, Default)]
437struct CapturedOutput {
438 bytes: Vec<u8>,
439 truncated: bool,
440}
441
442async fn read_capped(mut reader: impl AsyncRead + Unpin) -> Result<CapturedOutput, std::io::Error> {
443 let mut captured = CapturedOutput::default();
444 let mut chunk = [0_u8; 8192];
445 loop {
446 let read = reader.read(&mut chunk).await?;
447 if read == 0 {
448 break;
449 }
450 let remaining = HOOK_OUTPUT_LIMIT_BYTES.saturating_sub(captured.bytes.len());
451 let keep = remaining.min(read);
452 captured.bytes.extend_from_slice(&chunk[..keep]);
453 captured.truncated |= keep < read;
454 }
455 Ok(captured)
456}
457
458pub(super) fn register_configured_shell_hooks(
459 runner: &mut HookRunner,
460 config: &LifecycleHooksConfig,
461 fallback_cwd: Option<PathBuf>,
462) {
463 if !config.enabled {
464 return;
465 }
466
467 let events: [(ShellHookEvent, &[LifecycleHookGroup]); 5] = [
468 (ShellHookEvent::SessionStart, &config.session_start),
469 (ShellHookEvent::PreToolUse, &config.pre_tool_use),
470 (ShellHookEvent::PostToolUse, &config.post_tool_use),
471 (ShellHookEvent::Stop, &config.stop),
472 (ShellHookEvent::PreCompact, &config.pre_compact),
473 ];
474 let mut sequence = 0_usize;
475 for (event, groups) in events {
476 for group in groups {
477 let matcher = if event.supports_tool_matcher() {
478 group.matcher.as_deref()
479 } else {
480 if group.matcher.is_some() {
481 warn!(
482 event = event.as_str(),
483 "ignoring matcher on non-tool lifecycle hook"
484 );
485 }
486 None
487 };
488 for command in &group.hooks {
489 match command.hook_type {
490 LifecycleHookType::Command => {
491 match ShellCommandHook::new(
492 event,
493 command,
494 matcher,
495 fallback_cwd.clone(),
496 sequence,
497 ) {
498 Ok(hook) => runner.register(std::sync::Arc::new(hook)),
499 Err(error) => warn!(
500 event = event.as_str(),
501 error = %error,
502 "skipping lifecycle hook with invalid matcher"
503 ),
504 }
505 }
506 }
507 sequence += 1;
508 }
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS;
517 use serde_json::json;
518 use std::time::Instant;
519
520 fn command(command: impl Into<String>, timeout_ms: u64) -> LifecycleHookCommand {
521 LifecycleHookCommand {
522 hook_type: LifecycleHookType::Command,
523 command: command.into(),
524 timeout_ms,
525 }
526 }
527
528 fn session(workspace: &std::path::Path) -> Session {
529 let mut session = Session::new("session-1", "test-model");
530 session.workspace = Some(workspace.to_string_lossy().into_owned());
531 session
532 }
533
534 #[tokio::test]
535 async fn exit_zero_without_output_passes_through() {
536 let dir = tempfile::tempdir().unwrap();
537 let hook = ShellCommandHook::new(
538 ShellHookEvent::SessionStart,
539 &command("printf ''", DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS),
540 None,
541 None,
542 0,
543 )
544 .unwrap();
545 let result = hook
546 .run(
547 AgentHookPoint::AfterSessionSetup,
548 &HookPayload::SessionSetup {
549 initial_message: "hello".to_string(),
550 },
551 &session(dir.path()),
552 )
553 .await;
554 assert_eq!(result, HookResult::Continue);
555 }
556
557 #[tokio::test]
558 async fn exit_two_blocks_with_stderr_reason() {
559 let dir = tempfile::tempdir().unwrap();
560 let hook = ShellCommandHook::new(
561 ShellHookEvent::PreToolUse,
562 &command("printf 'blocked by policy' >&2; exit 2", 1_000),
563 None,
564 None,
565 0,
566 )
567 .unwrap();
568 let result = hook
569 .run(
570 AgentHookPoint::BeforeToolExecution,
571 &HookPayload::ToolExecution {
572 tool_name: "bash".to_string(),
573 tool_call_id: "call-1".to_string(),
574 parsed_args: json!({"command": "pwd"}),
575 },
576 &session(dir.path()),
577 )
578 .await;
579 assert_eq!(
580 result,
581 HookResult::Deny {
582 reason: "blocked by policy".to_string()
583 }
584 );
585 }
586
587 #[tokio::test]
588 async fn versioned_json_protocol_is_delivered_on_stdin() {
589 let dir = tempfile::tempdir().unwrap();
590 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"#;
591 let hook = ShellCommandHook::new(
592 ShellHookEvent::PreToolUse,
593 &command(shell, 1_000),
594 None,
595 None,
596 0,
597 )
598 .unwrap();
599 let result = hook
600 .run(
601 AgentHookPoint::BeforeToolExecution,
602 &HookPayload::ToolExecution {
603 tool_name: "bash".to_string(),
604 tool_call_id: "call-1".to_string(),
605 parsed_args: json!({"command": "pwd"}),
606 },
607 &session(dir.path()),
608 )
609 .await;
610 assert_eq!(result, HookResult::Allow);
611 }
612
613 #[tokio::test]
614 async fn stdout_decisions_and_context_are_parsed() {
615 let dir = tempfile::tempdir().unwrap();
616 for (body, expected) in [
617 (r#"{"decision":"allow"}"#, HookResult::Allow),
618 (r#"{"decision":"ask"}"#, HookResult::Ask),
619 (
620 r#"{"decision":"block","reason":"nope"}"#,
621 HookResult::Deny {
622 reason: "nope".to_string(),
623 },
624 ),
625 (
626 r#"{"additional_context":"remember this"}"#,
627 HookResult::InjectContext {
628 text: "remember this".to_string(),
629 },
630 ),
631 (
632 r#"{"decision":"allow","additional_context":"allowed context"}"#,
633 HookResult::WithContext {
634 result: Box::new(HookResult::Allow),
635 text: "allowed context".to_string(),
636 },
637 ),
638 ] {
639 let shell = format!("printf '%s' '{body}'");
640 let hook = ShellCommandHook::new(
641 ShellHookEvent::SessionStart,
642 &command(shell, 1_000),
643 None,
644 None,
645 0,
646 )
647 .unwrap();
648 assert_eq!(
649 hook.run(
650 AgentHookPoint::AfterSessionSetup,
651 &HookPayload::None,
652 &session(dir.path()),
653 )
654 .await,
655 expected
656 );
657 }
658 }
659
660 #[tokio::test]
661 async fn runner_preserves_decision_and_context_from_one_response() {
662 let dir = tempfile::tempdir().unwrap();
663 let mut runner = HookRunner::new();
664 runner.register(std::sync::Arc::new(
665 ShellCommandHook::new(
666 ShellHookEvent::SessionStart,
667 &command(
668 r#"printf '%s' '{"decision":"allow","additional_context":"keep me"}'"#,
669 1_000,
670 ),
671 None,
672 None,
673 0,
674 )
675 .unwrap(),
676 ));
677 let session = session(dir.path());
678 let mut state = bamboo_domain::AgentRuntimeState::new("run-1");
679 let outcome = runner
680 .run_hooks(
681 AgentHookPoint::AfterSessionSetup,
682 &HookPayload::None,
683 &session,
684 &mut state,
685 None,
686 )
687 .await;
688 assert_eq!(outcome.decision, HookResult::Allow);
689 assert_eq!(outcome.injected_contexts, vec!["keep me"]);
690 }
691
692 #[tokio::test]
693 async fn malformed_json_is_non_blocking() {
694 let dir = tempfile::tempdir().unwrap();
695 let hook = ShellCommandHook::new(
696 ShellHookEvent::SessionStart,
697 &command("printf 'not-json'", 1_000),
698 None,
699 None,
700 0,
701 )
702 .unwrap();
703 assert_eq!(
704 hook.run(
705 AgentHookPoint::AfterSessionSetup,
706 &HookPayload::None,
707 &session(dir.path()),
708 )
709 .await,
710 HookResult::Continue
711 );
712 }
713
714 #[tokio::test]
715 async fn timeout_kills_hook_and_continues() {
716 let dir = tempfile::tempdir().unwrap();
717 let hook = ShellCommandHook::new(
718 ShellHookEvent::SessionStart,
719 &command("sleep 5", 25),
720 None,
721 None,
722 0,
723 )
724 .unwrap();
725 let started = Instant::now();
726 let result = hook
727 .run(
728 AgentHookPoint::AfterSessionSetup,
729 &HookPayload::None,
730 &session(dir.path()),
731 )
732 .await;
733 assert_eq!(result, HookResult::Continue);
734 assert!(started.elapsed() < Duration::from_secs(2));
735 }
736
737 #[test]
738 fn matcher_filters_tool_names() {
739 let hook = ShellCommandHook::new(
740 ShellHookEvent::PreToolUse,
741 &command("exit 2", 1_000),
742 Some("^(bash|write_file)$"),
743 None,
744 0,
745 )
746 .unwrap();
747 let payload = |tool_name: &str| HookPayload::ToolExecution {
748 tool_name: tool_name.to_string(),
749 tool_call_id: "call-1".to_string(),
750 parsed_args: json!({}),
751 };
752 assert!(hook.matches(&payload("bash")));
753 assert!(!hook.matches(&payload("read_file")));
754 }
755
756 #[test]
757 fn envelope_contains_versioned_protocol_fields() {
758 let dir = tempfile::tempdir().unwrap();
759 let hook = ShellCommandHook::new(
760 ShellHookEvent::PreToolUse,
761 &command("true", 1_000),
762 None,
763 None,
764 0,
765 )
766 .unwrap();
767 let session = session(dir.path());
768 let payload = HookPayload::ToolExecution {
769 tool_name: "bash".to_string(),
770 tool_call_id: "call-1".to_string(),
771 parsed_args: json!({"command": "pwd"}),
772 };
773 let value = serde_json::to_value(hook.envelope(
774 &payload,
775 &session,
776 hook.effective_cwd(&session).as_ref(),
777 ))
778 .unwrap();
779 assert_eq!(value["schema_version"], 1);
780 assert_eq!(value["hook_event_name"], "PreToolUse");
781 assert_eq!(value["session_id"], "session-1");
782 assert_eq!(value["model"], "test-model");
783 assert_eq!(value["tool_name"], "bash");
784 assert_eq!(value["tool_input"]["command"], "pwd");
785 assert!(value["timestamp"].as_str().is_some());
786 }
787
788 #[test]
789 fn configured_runner_registers_only_enabled_engine_events() {
790 let hook = command("true", 1_000);
791 let config = LifecycleHooksConfig {
792 enabled: true,
793 pre_tool_use: vec![LifecycleHookGroup {
794 matcher: Some("bash".to_string()),
795 hooks: vec![hook.clone()],
796 }],
797 session_start: vec![LifecycleHookGroup {
798 matcher: None,
799 hooks: vec![hook.clone()],
800 }],
801 notification: vec![LifecycleHookGroup {
802 matcher: None,
803 hooks: vec![hook],
804 }],
805 ..LifecycleHooksConfig::default()
806 };
807
808 let base = HookRunner::new();
809 let configured = base.with_lifecycle_config(&config, None);
810 assert!(
811 base.is_empty(),
812 "per-run registration must not mutate the base"
813 );
814 assert_eq!(configured.len(), 2);
815 assert!(configured.has_hooks_for(AgentHookPoint::AfterSessionSetup));
816 assert!(configured.has_hooks_for(AgentHookPoint::BeforeToolExecution));
817 }
818
819 #[tokio::test]
820 async fn configured_commands_run_in_order_and_first_block_wins() {
821 let dir = tempfile::tempdir().unwrap();
822 let config = LifecycleHooksConfig {
823 enabled: true,
824 pre_tool_use: vec![LifecycleHookGroup {
825 matcher: None,
826 hooks: vec![
827 command("printf 'first' >&2; exit 2", 1_000),
828 command("printf 'second' >&2; exit 2", 1_000),
829 ],
830 }],
831 ..LifecycleHooksConfig::default()
832 };
833 let runner = HookRunner::new().with_lifecycle_config(&config, None);
834 let mut state = bamboo_domain::AgentRuntimeState::new("run-ordered");
835 let outcome = runner
836 .run_hooks(
837 AgentHookPoint::BeforeToolExecution,
838 &HookPayload::ToolExecution {
839 tool_name: "bash".to_string(),
840 tool_call_id: "call-1".to_string(),
841 parsed_args: json!({}),
842 },
843 &session(dir.path()),
844 &mut state,
845 None,
846 )
847 .await;
848 assert_eq!(
849 outcome.decision,
850 HookResult::Deny {
851 reason: "first".to_string()
852 }
853 );
854 assert_eq!(state.checkpoints.len(), 1);
855 }
856
857 #[test]
858 fn invalid_matcher_is_skipped_without_failing_registration() {
859 let config = LifecycleHooksConfig {
860 enabled: true,
861 pre_tool_use: vec![LifecycleHookGroup {
862 matcher: Some("[".to_string()),
863 hooks: vec![command("true", 1_000)],
864 }],
865 ..LifecycleHooksConfig::default()
866 };
867 let configured = HookRunner::new().with_lifecycle_config(&config, None);
868 assert!(configured.is_empty());
869 }
870
871 #[tokio::test]
872 async fn output_capture_is_capped_but_fully_drained() {
873 let (mut writer, reader) = tokio::io::duplex(1024);
874 let input = vec![b'x'; HOOK_OUTPUT_LIMIT_BYTES + 17];
875 let write_task = tokio::spawn(async move {
876 writer.write_all(&input).await.unwrap();
877 });
878 let captured = read_capped(reader).await.unwrap();
879 write_task.await.unwrap();
880 assert_eq!(captured.bytes.len(), HOOK_OUTPUT_LIMIT_BYTES);
881 assert!(captured.truncated);
882 }
883}