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::{
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/// User-facing lifecycle events that currently map to engine hook seams.
28#[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/// Raw process result returned by the settings dry-run endpoint. Unlike the
41/// normal hook path, this deliberately exposes captured streams so users can
42/// diagnose a command before enabling it.
43#[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
85/// One config-driven lifecycle shell command.
86pub 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            // Put every hook in its own process group so a timeout kills the
273            // complete command tree. Killing only the shell can leave a child
274            // holding stdout/stderr open and make the nominal timeout wait for
275            // that child to exit.
276            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
429/// Execute one lifecycle command against a deterministic synthetic payload.
430/// This shares the production shell/environment/timeout/output-capping path;
431/// only hook-result interpretation is skipped so callers receive raw output.
432pub 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        // SAFETY: the child was spawned as the leader of its own process group,
533        // so the negative pid targets only that hook and its descendants.
534        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    /// Complete, event-specific payload. Legacy convenience fields below stay
612    /// populated for existing tool/prompt/session hook commands.
613    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}