Skip to main content

bamboo_engine/runtime/hooks/
shell_command.rs

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/// User-facing lifecycle events that currently map to engine hook seams.
26#[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
61/// One config-driven lifecycle shell command.
62pub 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            // Put every hook in its own process group so a timeout kills the
178            // complete command tree. Killing only the shell can leave a child
179            // holding stdout/stderr open and make the nominal timeout wait for
180            // that child to exit.
181            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        // SAFETY: the child was spawned as the leader of its own process group,
319        // so the negative pid targets only that hook and its descendants.
320        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}