Skip to main content

bamboo_engine/runtime/hooks/
mod.rs

1//! Hook runner — dispatches registered hooks at lifecycle points.
2
3mod local_resources;
4
5use std::sync::Arc;
6
7use bamboo_agent_core::{AgentError, AgentEvent, AgentHook, Message, Session};
8use bamboo_domain::{
9    AgentHookPoint, AgentRuntimeState, AgentStatusState, HookCheckpoint, HookPayload, HookResult,
10    SessionEndStatus, SuspensionState,
11};
12use chrono::Utc;
13use tokio::sync::mpsc;
14
15pub use bamboo_hooks::{
16    test_lifecycle_handler, test_lifecycle_shell_command, HookRunOutcome, LifecycleHookEvent,
17    LifecycleHookTestOutput, LifecycleScriptRunner, ScriptHook, ShellCommandHook, ShellHookEvent,
18    ShellHookTestOutput,
19};
20
21/// Engine adapter around the standalone hook dispatcher.
22///
23/// `bamboo-hooks` owns matching and handler execution. This adapter translates
24/// completed executions into engine checkpoints and lifecycle events.
25#[derive(Clone)]
26pub struct HookRunner {
27    dispatcher: bamboo_hooks::HookDispatcher,
28    plugin_root: Option<std::path::PathBuf>,
29}
30
31impl HookRunner {
32    pub fn new() -> Self {
33        Self {
34            dispatcher: bamboo_hooks::HookDispatcher::new(),
35            plugin_root: None,
36        }
37    }
38
39    pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
40        self.dispatcher.register(hook);
41    }
42
43    /// Clone this registry and append configured handlers from one frozen config
44    /// snapshot. The original registry remains reusable by future runs.
45    pub fn with_lifecycle_config(
46        &self,
47        config: &bamboo_config::LifecycleHooksConfig,
48        fallback_cwd: Option<std::path::PathBuf>,
49    ) -> Self {
50        Self {
51            plugin_root: fallback_cwd.as_ref().map(|root| root.join("plugins")),
52            dispatcher: self.dispatcher.with_lifecycle_config(config, fallback_cwd),
53        }
54    }
55
56    /// Record the exact accepted prompt from the server submission seam. The
57    /// shared loop consumes this once, so server and embedded runs cannot fire
58    /// portable UserPromptSubmit twice for the same admitted prompt.
59    pub fn mark_user_prompt_prechecked(session: &mut Session, prompt: &str) {
60        session.metadata.insert(
61            "runtime.plugin_prompt_prechecked".into(),
62            prompt_fingerprint(prompt),
63        );
64    }
65
66    /// Shared CLI/SDK fallback, before session preparation or provider calls.
67    /// Native submission hooks keep their existing server-only invocation.
68    pub(crate) async fn apply_portable_user_prompt(
69        &self,
70        session: &mut Session,
71        prompt: &str,
72    ) -> Result<String, AgentError> {
73        let prechecked = session.metadata.remove("runtime.plugin_prompt_prechecked");
74        if prechecked.as_deref() == Some(prompt_fingerprint(prompt).as_str()) {
75            return Ok(prompt.to_owned());
76        }
77        let Some(root) = &self.plugin_root else {
78            return Ok(prompt.to_owned());
79        };
80        let report = local_resources::run(bamboo_hooks::portable::run(
81            root,
82            AgentHookPoint::BeforeSessionSetup,
83            &HookPayload::Prompt {
84                prompt: prompt.to_owned(),
85            },
86            session,
87        ))
88        .await;
89        for error in &report.errors {
90            tracing::warn!(%error, "plugin prompt hook compatibility failure");
91        }
92        match report.decision {
93            HookResult::Deny { reason } | HookResult::Abort { reason } => {
94                return Err(AgentError::Tool(format!(
95                    "UserPromptSubmit hook rejected prompt: {reason}"
96                )));
97            }
98            _ => {}
99        }
100        let contexts = report
101            .contexts
102            .into_iter()
103            .map(|context| context.rendered_text())
104            .collect::<Vec<_>>();
105        if contexts.is_empty() {
106            Ok(prompt.to_owned())
107        } else {
108            Ok(format!(
109                "{prompt}\n\n<user_prompt_submit_context>\n{}\n</user_prompt_submit_context>",
110                contexts.join("\n\n---\n\n")
111            ))
112        }
113    }
114
115    /// Run all hooks matching the given point.
116    ///
117    /// Records checkpoints in `runtime_state`. Returns the first
118    /// `Suspend` or `Abort` result, or the aggregate result otherwise.
119    pub async fn run_hooks(
120        &self,
121        point: AgentHookPoint,
122        payload: &HookPayload,
123        session: &Session,
124        runtime_state: &mut AgentRuntimeState,
125        event_tx: Option<&mpsc::Sender<AgentEvent>>,
126    ) -> HookRunOutcome {
127        self.run_hooks_with_tool_input(point, payload, session, runtime_state, event_tx, None)
128            .await
129    }
130
131    pub async fn run_hooks_with_tool_input(
132        &self,
133        point: AgentHookPoint,
134        payload: &HookPayload,
135        session: &Session,
136        runtime_state: &mut AgentRuntimeState,
137        event_tx: Option<&mpsc::Sender<AgentEvent>>,
138        original_tool_input: Option<&serde_json::Value>,
139    ) -> HookRunOutcome {
140        self.run_hooks_with_inputs(
141            point,
142            payload,
143            session,
144            runtime_state,
145            event_tx,
146            bamboo_hooks::portable::PortableInputs {
147                original_tool_input,
148                ..Default::default()
149            },
150        )
151        .await
152    }
153
154    pub async fn run_hooks_with_inputs(
155        &self,
156        point: AgentHookPoint,
157        payload: &HookPayload,
158        session: &Session,
159        runtime_state: &mut AgentRuntimeState,
160        event_tx: Option<&mpsc::Sender<AgentEvent>>,
161        inputs: bamboo_hooks::portable::PortableInputs<'_>,
162    ) -> HookRunOutcome {
163        let report = local_resources::run(self.dispatcher.run_hooks_with_resolved_shell(
164            point,
165            payload,
166            session,
167            inputs.resolved_tool_name,
168            inputs.original_tool_input,
169        ))
170        .await;
171        let mut outcome = record_dispatch_report(report, runtime_state, event_tx).await;
172        if let Some(root) = &self.plugin_root {
173            let mut portable_payload = payload.clone();
174            if let Some(name) = inputs.resolved_tool_name {
175                match &mut portable_payload {
176                    HookPayload::ToolExecution { tool_name, .. }
177                    | HookPayload::ToolResult { tool_name, .. } => *tool_name = name.to_owned(),
178                    _ => {}
179                }
180            }
181            let pending_composite_ask = matches!(outcome.decision, HookResult::Ask)
182                && bamboo_hooks::requires_complete_shell_policy_checks(
183                    point,
184                    &portable_payload,
185                    session,
186                    inputs.original_tool_input,
187                );
188            if matches!(
189                outcome.decision,
190                HookResult::Continue | HookResult::Allow | HookResult::Mutated
191            ) || pending_composite_ask
192            {
193                let plugin = local_resources::run(bamboo_hooks::portable::run_with_inputs(
194                    root,
195                    point,
196                    &portable_payload,
197                    session,
198                    inputs.original_tool_input,
199                    inputs.final_assistant_content,
200                ))
201                .await;
202                for error in plugin.errors {
203                    tracing::warn!(%error, "plugin hook compatibility failure");
204                    runtime_state.checkpoints.push(HookCheckpoint {
205                        hook_point: format!("plugin:{point:?}"),
206                        timestamp: Utc::now(),
207                        result: error.chars().take(1024).collect(),
208                        duration_ms: 0,
209                    });
210                }
211                if !matches!(plugin.decision, HookResult::Continue)
212                    && (!pending_composite_ask
213                        || matches!(
214                            plugin.decision,
215                            HookResult::Deny { .. }
216                                | HookResult::Abort { .. }
217                                | HookResult::Suspend { .. }
218                        ))
219                {
220                    outcome.decision = plugin.decision;
221                }
222                outcome.plugin_contexts = plugin.contexts;
223            }
224        }
225        outcome
226    }
227
228    /// Run every matching hook while recording checkpoints/events, but never
229    /// short-circuit on a control decision. Observer/advisory seams such as
230    /// `SessionEnd`, `PreCompact`, and `Notification` use this so a command's
231    /// control-shaped output cannot suppress later hooks or reverse an
232    /// operation that must proceed for correctness.
233    pub async fn run_observer_hooks(
234        &self,
235        point: AgentHookPoint,
236        payload: &HookPayload,
237        session: &Session,
238        runtime_state: &mut AgentRuntimeState,
239        event_tx: Option<&mpsc::Sender<AgentEvent>>,
240    ) -> HookRunOutcome {
241        let report =
242            local_resources::run(self.dispatcher.run_observer_hooks(point, payload, session)).await;
243        record_dispatch_report(report, runtime_state, event_tx).await
244    }
245
246    pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
247        self.dispatcher.has_hooks_for(point)
248            || (self
249                .plugin_root
250                .as_ref()
251                .is_some_and(|root| bamboo_hooks::portable::has_active_hooks_for(root, point)))
252    }
253
254    /// Future control seams in this runner, after the model can issue Start.
255    /// Setup is already complete and observer seams never honor suspension.
256    pub(crate) fn has_suspending_loop_control_hooks(&self) -> bool {
257        [
258            AgentHookPoint::BeforeRound,
259            AgentHookPoint::AfterRound,
260            AgentHookPoint::BeforeToolExecution,
261            AgentHookPoint::AfterToolExecution,
262            AgentHookPoint::BeforeFinalize,
263        ]
264        .into_iter()
265        .any(|point| self.dispatcher.has_suspending_hooks_for(point))
266    }
267
268    pub fn len(&self) -> usize {
269        self.dispatcher.len()
270    }
271
272    pub fn is_empty(&self) -> bool {
273        self.dispatcher.is_empty()
274            && !self.plugin_root.as_ref().is_some_and(|root| {
275                [
276                    AgentHookPoint::BeforeSessionSetup,
277                    AgentHookPoint::BeforeToolExecution,
278                    AgentHookPoint::AfterToolExecution,
279                    AgentHookPoint::BeforeFinalize,
280                ]
281                .into_iter()
282                .any(|point| bamboo_hooks::portable::has_active_hooks_for(root, point))
283            })
284    }
285}
286
287fn prompt_fingerprint(prompt: &str) -> String {
288    use sha2::{Digest, Sha256};
289    hex::encode(Sha256::digest(prompt.as_bytes()))
290}
291
292async fn record_dispatch_report(
293    report: bamboo_hooks::HookDispatchReport,
294    runtime_state: &mut AgentRuntimeState,
295    event_tx: Option<&mpsc::Sender<AgentEvent>>,
296) -> HookRunOutcome {
297    let bamboo_hooks::HookDispatchReport {
298        outcome,
299        executions,
300    } = report;
301    for execution in executions {
302        runtime_state.checkpoints.push(HookCheckpoint {
303            hook_point: format!("{:?}", execution.point),
304            timestamp: Utc::now(),
305            result: format!("{:?}", execution.result),
306            duration_ms: execution.duration_ms,
307        });
308        if let Some(event_tx) = event_tx {
309            let _ = event_tx
310                .send(AgentEvent::HookLifecycle {
311                    hook_name: execution.hook_name,
312                    point: execution.point,
313                    phase: "completed".to_string(),
314                    duration_ms: execution.duration_ms,
315                    decision: execution.result,
316                })
317                .await;
318        }
319    }
320    outcome
321}
322
323/// Fire cleanup/notification hooks after a terminal run. Decisions and context
324/// are intentionally ignored: `SessionEnd` observes a settled outcome and may
325/// not reverse it. Suspended runs are non-terminal and do not fire this event.
326pub(crate) async fn run_session_end_hooks(
327    runner: &HookRunner,
328    result: &Result<(), AgentError>,
329    session: &mut Session,
330    event_tx: &mpsc::Sender<AgentEvent>,
331) {
332    let suspended_non_terminal = result.is_ok()
333        && session
334            .metadata
335            .get("runtime.suspend_reason")
336            .is_some_and(|reason| !reason.trim().is_empty());
337    if suspended_non_terminal || !runner.has_hooks_for(AgentHookPoint::AfterSessionEnd) {
338        return;
339    }
340
341    let (status, completion_reason) = match result {
342        Ok(()) => (
343            SessionEndStatus::Completed,
344            session
345                .metadata
346                .get("runtime.completion_reason")
347                .cloned()
348                .or_else(|| Some("completed".to_string())),
349        ),
350        Err(error) if error.is_cancelled() => {
351            (SessionEndStatus::Cancelled, Some(error.to_string()))
352        }
353        Err(error) => (SessionEndStatus::Failed, Some(error.to_string())),
354    };
355    let mut runtime_state = session
356        .agent_runtime_state
357        .clone()
358        .unwrap_or_else(|| AgentRuntimeState::new(&session.id));
359    runner
360        .run_observer_hooks(
361            AgentHookPoint::AfterSessionEnd,
362            &HookPayload::SessionEnd {
363                status,
364                completion_reason,
365            },
366            session,
367            &mut runtime_state,
368            Some(event_tx),
369        )
370        .await;
371    session.agent_runtime_state = Some(runtime_state);
372}
373
374/// Apply context injections and non-tool control decisions consistently across
375/// lifecycle seams.
376pub(crate) fn apply_hook_outcome(
377    point: AgentHookPoint,
378    outcome: HookRunOutcome,
379    session: &mut Session,
380    runtime_state: &mut AgentRuntimeState,
381) -> Result<(), AgentError> {
382    inject_plugin_contexts(session, outcome.plugin_contexts);
383    if matches!(point, AgentHookPoint::AfterSessionSetup) {
384        runtime_state.hook_contexts.extend(
385            outcome
386                .injected_contexts
387                .into_iter()
388                .filter(|text| !text.trim().is_empty()),
389        );
390    } else {
391        inject_contexts(session, point, outcome.injected_contexts);
392    }
393
394    match outcome.decision {
395        HookResult::Continue
396        | HookResult::Mutated
397        | HookResult::Allow
398        | HookResult::InjectContext { .. } => Ok(()),
399        HookResult::Suspend { reason } => {
400            let hook_point = format!("{point:?}");
401            runtime_state.status = AgentStatusState::Suspended;
402            runtime_state.suspension = Some(SuspensionState {
403                reason: reason.clone(),
404                suspended_at: Utc::now(),
405                resumable: true,
406                hook_point: Some(hook_point.clone()),
407            });
408            session.metadata.insert(
409                "runtime.suspend_reason".to_string(),
410                "hook_suspended".to_string(),
411            );
412            Err(AgentError::HookSuspended(format!("{hook_point}: {reason}")))
413        }
414        HookResult::Abort { reason } => Err(AgentError::Tool(format!(
415            "hook aborted at {point:?}: {reason}"
416        ))),
417        HookResult::Deny { reason } => Err(AgentError::Tool(format!(
418            "hook denied lifecycle seam {point:?}: {reason}"
419        ))),
420        HookResult::Ask => Err(AgentError::Tool(format!(
421            "hook requested parent approval at non-tool seam {point:?}"
422        ))),
423        HookResult::WithContext { result, text } => apply_hook_outcome(
424            point,
425            HookRunOutcome {
426                decision: *result,
427                injected_contexts: vec![text],
428                plugin_contexts: vec![],
429            },
430            session,
431            runtime_state,
432        ),
433    }
434}
435
436/// Stage bounded untrusted snapshots for the existing chronological context
437/// ledger. Do not insert messages into an unfinished assistant/tool group.
438pub(crate) fn inject_plugin_contexts(
439    session: &mut Session,
440    contexts: Vec<bamboo_hooks::portable::PluginContext>,
441) {
442    if contexts.is_empty() {
443        return;
444    }
445    let key = "runtime.plugin_hook_contexts";
446    let mut chunks: Vec<String> = session
447        .metadata
448        .get(key)
449        .and_then(|value| serde_json::from_str(value).ok())
450        .unwrap_or_default();
451    chunks.extend(contexts.into_iter().map(|context| context.rendered_text()));
452    while chunks.iter().map(String::len).sum::<usize>() + chunks.len().saturating_sub(1) * 2
453        > bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES
454    {
455        if chunks.is_empty() {
456            break;
457        }
458        chunks.remove(0);
459    }
460    session.metadata.insert(
461        key.into(),
462        serde_json::to_string(&chunks).expect("string serialization"),
463    );
464}
465
466pub(crate) fn inject_contexts(
467    session: &mut Session,
468    point: AgentHookPoint,
469    injected_contexts: Vec<String>,
470) {
471    for text in injected_contexts {
472        if text.trim().is_empty() {
473            continue;
474        }
475        let block =
476            format!("\n\n<agent_hook_context point=\"{point:?}\">\n{text}\n</agent_hook_context>");
477        if let Some(system_message) = session
478            .messages
479            .iter_mut()
480            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
481        {
482            system_message.content.push_str(&block);
483            system_message.never_compress = true;
484        } else {
485            let mut message = Message::system(block.trim().to_string());
486            message.never_compress = true;
487            message.metadata = Some(serde_json::json!({
488                "runtime_kind": "hook_context",
489                "hook_point": point,
490            }));
491            session.add_message(message);
492        }
493    }
494}
495
496/// Merge hook checkpoints produced through a session-local seam (notably
497/// compression) into the runner-owned state without losing checkpoints written
498/// directly by tool/round seams.
499pub(crate) fn merge_session_hook_checkpoints(
500    session: &Session,
501    runtime_state: &mut AgentRuntimeState,
502) {
503    let Some(session_state) = session.agent_runtime_state.as_ref() else {
504        return;
505    };
506    for checkpoint in &session_state.checkpoints {
507        if !runtime_state.checkpoints.contains(checkpoint) {
508            runtime_state.checkpoints.push(checkpoint.clone());
509        }
510    }
511    if matches!(session_state.status, AgentStatusState::Suspended) {
512        runtime_state.status = AgentStatusState::Suspended;
513        runtime_state.suspension = session_state.suspension.clone();
514    }
515}
516
517impl Default for HookRunner {
518    fn default() -> Self {
519        Self::new()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525
526    #[cfg(unix)]
527    #[tokio::test]
528    async fn combined_shell_ask_checks_trusted_portable_output_policy() {
529        use bamboo_plugin::{
530            InstalledPlugin, InstalledPlugins, PluginInstallStatus, PluginManifest, PluginSource,
531            RegisteredCapabilities,
532        };
533        use serde_json::json;
534        for decision in ["deny", "continue", "allow"] {
535            let temp = tempfile::tempdir().unwrap();
536            let root = temp.path().join("plugins");
537            let bundle = root.join("combined-ask");
538            std::fs::create_dir_all(&bundle).unwrap();
539            let manifest: PluginManifest = serde_json::from_value(json!({
540                "id":"combined-ask", "name":"Combined Ask", "version":"0.1.0",
541                "provides":{"hooks":[{"config":"hooks.json", "scripts":["policy.sh"]}]}
542            }))
543            .unwrap();
544            std::fs::write(
545                bundle.join("plugin.json"),
546                serde_json::to_vec(&manifest).unwrap(),
547            )
548            .unwrap();
549            std::fs::write(bundle.join("policy.sh"), "# reviewed fixture").unwrap();
550            let response = if decision == "continue" {
551                String::new()
552            } else {
553                json!({"hookSpecificOutput":{"hookEventName":"PreToolUse", "permissionDecision":decision, "permissionDecisionReason":"portable output denied"}}).to_string()
554            };
555            let command = format!("touch \"$PLUGIN_DATA/seen\"; printf '%s' '{}'", response);
556            std::fs::write(bundle.join("hooks.json"), serde_json::to_vec(&json!({"hooks":{"PreToolUse":[{
557                "matcher":"^BashOutput$", "hooks":[{"type":"command", "command":command, "timeout":1}]
558            }]}})).unwrap()).unwrap();
559            let mut hooks = bamboo_plugin::hooks::registrations(&manifest, &bundle).unwrap();
560            let digest = hooks[0].digest.clone();
561            hooks[0].confirm_review(&digest).unwrap();
562            InstalledPlugins {
563                plugins: vec![InstalledPlugin {
564                    id: manifest.id,
565                    version: manifest.version,
566                    source: PluginSource::LocalDir {
567                        path: bundle.clone(),
568                    },
569                    plugin_dir: bundle,
570                    installed_at: Utc::now(),
571                    status: PluginInstallStatus::Installed,
572                    registered: RegisteredCapabilities {
573                        hooks,
574                        ..Default::default()
575                    },
576                }],
577            }
578            .save(&root.join("installed.json"))
579            .await
580            .unwrap();
581            let marker = root.join(".hook-data/combined-ask/seen");
582            for name in ["write_stdin", "default::write_stdin"] {
583                for (primary, args) in [
584                    ("BashInput", json!({"session_id":"exec", "chars":"yes"})),
585                    ("BashInput", json!({"session_id":"exec", "eof":true})),
586                    ("KillShell", json!({"session_id":"exec", "chars":"\u{3}"})),
587                ] {
588                    let _ = std::fs::remove_file(&marker);
589                    let mut runner = HookRunner::new().with_lifecycle_config(
590                        &bamboo_config::LifecycleHooksConfig::default(),
591                        Some(temp.path().to_owned()),
592                    );
593                    let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
594                    runner.register(Arc::new(ToolNameHook {
595                        point: AgentHookPoint::BeforeToolExecution,
596                        name: primary.into(),
597                        seen: seen.clone(),
598                        result: HookResult::Ask,
599                    }));
600                    let outcome = runner
601                        .run_hooks_with_inputs(
602                            AgentHookPoint::BeforeToolExecution,
603                            &HookPayload::ToolExecution {
604                                tool_name: name.into(),
605                                tool_call_id: "combined-ask".into(),
606                                parsed_args: args.clone(),
607                            },
608                            &test_session(),
609                            &mut AgentRuntimeState::new("combined-ask"),
610                            None,
611                            bamboo_hooks::portable::PortableInputs {
612                                resolved_tool_name: Some("write_stdin"),
613                                original_tool_input: Some(&args),
614                                ..Default::default()
615                            },
616                        )
617                        .await;
618                    let expected = if decision == "deny" {
619                        HookResult::Deny {
620                            reason: "portable output denied".into(),
621                        }
622                    } else {
623                        HookResult::Ask
624                    };
625                    assert_eq!(outcome.decision, expected, "{decision}, {name}: {args}");
626                    assert_eq!(seen.lock().unwrap().len(), 1);
627                    assert!(
628                        marker.exists(),
629                        "trusted portable output handler must run after native Ask"
630                    );
631                }
632            }
633            // Legacy and pure-poll calls retain the native Ask short circuit.
634            for (name, args, resolved) in [
635                ("BashOutput", json!({"bash_id":"legacy"}), None),
636                (
637                    "write_stdin",
638                    json!({"session_id":"exec", "chars":""}),
639                    Some("write_stdin"),
640                ),
641                (
642                    "default::write_stdin",
643                    json!({"session_id":"exec", "eof":false}),
644                    Some("write_stdin"),
645                ),
646            ] {
647                let _ = std::fs::remove_file(&marker);
648                let mut runner = HookRunner::new().with_lifecycle_config(
649                    &bamboo_config::LifecycleHooksConfig::default(),
650                    Some(temp.path().to_owned()),
651                );
652                runner.register(Arc::new(ToolNameHook {
653                    point: AgentHookPoint::BeforeToolExecution,
654                    name: "BashOutput".into(),
655                    seen: Arc::new(std::sync::Mutex::new(Vec::new())),
656                    result: HookResult::Ask,
657                }));
658                let outcome = runner
659                    .run_hooks_with_inputs(
660                        AgentHookPoint::BeforeToolExecution,
661                        &HookPayload::ToolExecution {
662                            tool_name: name.into(),
663                            tool_call_id: "legacy-ask".into(),
664                            parsed_args: args.clone(),
665                        },
666                        &test_session(),
667                        &mut AgentRuntimeState::new("legacy-ask"),
668                        None,
669                        bamboo_hooks::portable::PortableInputs {
670                            resolved_tool_name: resolved,
671                            original_tool_input: Some(&args),
672                            ..Default::default()
673                        },
674                    )
675                    .await;
676                assert_eq!(outcome.decision, HookResult::Ask);
677                assert!(
678                    !marker.exists(),
679                    "ordinary legacy Ask must skip portable execution"
680                );
681            }
682        }
683    }
684    use super::*;
685
686    /// A no-op hook that always returns Continue.
687    struct ContinueHook {
688        point: AgentHookPoint,
689        pri: u32,
690        name: String,
691    }
692
693    #[async_trait::async_trait]
694    impl AgentHook for ContinueHook {
695        fn point(&self) -> AgentHookPoint {
696            self.point
697        }
698
699        async fn run(
700            &self,
701            _point: AgentHookPoint,
702            _payload: &HookPayload,
703            _session: &Session,
704        ) -> HookResult {
705            HookResult::Continue
706        }
707
708        fn priority(&self) -> u32 {
709            self.pri
710        }
711
712        fn name(&self) -> &str {
713            &self.name
714        }
715    }
716
717    /// A hook that always returns Abort.
718    struct AbortHook;
719
720    #[async_trait::async_trait]
721    impl AgentHook for AbortHook {
722        fn point(&self) -> AgentHookPoint {
723            AgentHookPoint::BeforeLlmCall
724        }
725
726        async fn run(
727            &self,
728            _point: AgentHookPoint,
729            _payload: &HookPayload,
730            _session: &Session,
731        ) -> HookResult {
732            HookResult::Abort {
733                reason: "test abort".to_string(),
734            }
735        }
736
737        fn name(&self) -> &str {
738            "abort_hook"
739        }
740    }
741
742    fn test_session() -> Session {
743        Session::new("test", "test-model")
744    }
745
746    struct ToolNameHook {
747        point: AgentHookPoint,
748        name: String,
749        seen: Arc<std::sync::Mutex<Vec<HookPayload>>>,
750        result: HookResult,
751    }
752
753    #[async_trait::async_trait]
754    impl AgentHook for ToolNameHook {
755        fn point(&self) -> AgentHookPoint {
756            self.point
757        }
758
759        fn matches(&self, payload: &HookPayload) -> bool {
760            match payload {
761                HookPayload::ToolExecution { tool_name, .. }
762                | HookPayload::ToolResult { tool_name, .. } => tool_name == &self.name,
763                _ => false,
764            }
765        }
766
767        async fn run(
768            &self,
769            _point: AgentHookPoint,
770            payload: &HookPayload,
771            _session: &Session,
772        ) -> HookResult {
773            self.seen.lock().unwrap().push(payload.clone());
774            self.result.clone()
775        }
776    }
777
778    #[tokio::test]
779    async fn resolved_builtin_aliases_keep_native_legacy_shell_policy() {
780        use bamboo_agent_core::{FunctionCall, ToolCall};
781        use serde_json::json;
782
783        for (alias, resolved, legacy, input) in [
784            (
785                "default::exec_command",
786                "exec_command",
787                "Bash",
788                json!({"cmd":"cargo test"}),
789            ),
790            (
791                "default::write_stdin",
792                "write_stdin",
793                "BashInput",
794                json!({"session_id":"exec", "chars":"yes\n"}),
795            ),
796        ] {
797            let mut session = test_session();
798            session.messages.push(Message::assistant(
799                "",
800                Some(vec![ToolCall {
801                    id: "alias-call".into(),
802                    tool_type: "function".into(),
803                    function: FunctionCall {
804                        name: alias.into(),
805                        arguments: input.to_string(),
806                    },
807                }]),
808            ));
809            for (point, payload) in [
810                (
811                    AgentHookPoint::BeforeToolExecution,
812                    HookPayload::ToolExecution {
813                        tool_name: alias.into(),
814                        tool_call_id: "alias-call".into(),
815                        parsed_args: input.clone(),
816                    },
817                ),
818                (
819                    AgentHookPoint::AfterToolExecution,
820                    HookPayload::ToolResult {
821                        tool_name: alias.into(),
822                        tool_call_id: "alias-call".into(),
823                        outcome: bamboo_domain::HookToolOutcome {
824                            success: true,
825                            result: Some("done".into()),
826                            error: None,
827                            needs_human: false,
828                            duration_ms: 1,
829                        },
830                    },
831                ),
832            ] {
833                let mut runner = HookRunner::new();
834                let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
835                let deny = HookResult::Deny {
836                    reason: "legacy shell policy".into(),
837                };
838                runner.register(Arc::new(ToolNameHook {
839                    point,
840                    name: legacy.into(),
841                    seen: seen.clone(),
842                    result: deny.clone(),
843                }));
844                let outcome = runner
845                    .run_hooks_with_inputs(
846                        point,
847                        &payload,
848                        &session,
849                        &mut AgentRuntimeState::new("alias-run"),
850                        None,
851                        bamboo_hooks::portable::PortableInputs {
852                            resolved_tool_name: Some(resolved),
853                            original_tool_input: Some(&input),
854                            ..Default::default()
855                        },
856                    )
857                    .await;
858                assert_eq!(outcome.decision, deny);
859                let seen = seen.lock().unwrap();
860                assert_eq!(seen.len(), 1);
861                match &seen[0] {
862                    HookPayload::ToolExecution {
863                        tool_name,
864                        parsed_args,
865                        ..
866                    } => {
867                        assert_eq!(tool_name, legacy);
868                        if resolved == "exec_command" {
869                            assert_eq!(parsed_args, &json!({"command":"cargo test"}));
870                        } else {
871                            assert_eq!(parsed_args["bash_id"], "exec");
872                            assert_eq!(parsed_args["input"], "yes\n");
873                        }
874                    }
875                    HookPayload::ToolResult { tool_name, .. } => assert_eq!(tool_name, legacy),
876                    _ => panic!("expected tool hook payload"),
877                }
878            }
879        }
880    }
881
882    #[tokio::test]
883    async fn resolved_non_shell_aliases_preserve_native_matchers_and_payloads() {
884        for alias in ["read_file", "default::Read"] {
885            for (point, payload) in [
886                (
887                    AgentHookPoint::BeforeToolExecution,
888                    HookPayload::ToolExecution {
889                        tool_name: alias.into(),
890                        tool_call_id: "read-alias".into(),
891                        parsed_args: serde_json::json!({"path":"input.txt"}),
892                    },
893                ),
894                (
895                    AgentHookPoint::AfterToolExecution,
896                    HookPayload::ToolResult {
897                        tool_name: alias.into(),
898                        tool_call_id: "read-alias".into(),
899                        outcome: bamboo_domain::HookToolOutcome {
900                            success: true,
901                            result: Some("file contents".into()),
902                            error: None,
903                            needs_human: false,
904                            duration_ms: 1,
905                        },
906                    },
907                ),
908            ] {
909                let mut runner = HookRunner::new();
910                let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
911                runner.register(Arc::new(ToolNameHook {
912                    point,
913                    name: "Read".into(),
914                    seen: seen.clone(),
915                    result: HookResult::Deny {
916                        reason: "must not newly match the resolved name".into(),
917                    },
918                }));
919                let expected = HookResult::Deny {
920                    reason: "original alias policy".into(),
921                };
922                runner.register(Arc::new(ToolNameHook {
923                    point,
924                    name: alias.into(),
925                    seen: seen.clone(),
926                    result: expected.clone(),
927                }));
928                let outcome = runner
929                    .run_hooks_with_inputs(
930                        point,
931                        &payload,
932                        &test_session(),
933                        &mut AgentRuntimeState::new("read-alias-run"),
934                        None,
935                        bamboo_hooks::portable::PortableInputs {
936                            resolved_tool_name: Some("Read"),
937                            ..Default::default()
938                        },
939                    )
940                    .await;
941                assert_eq!(outcome.decision, expected);
942                assert_eq!(*seen.lock().unwrap(), vec![payload]);
943            }
944        }
945    }
946
947    #[tokio::test]
948    async fn foreign_and_unresolved_tool_names_do_not_inherit_native_shell_policy() {
949        for (name, resolved) in [
950            ("vendor::exec_command", Some("vendor::exec_command")),
951            ("vendor::write_stdin", Some("vendor::write_stdin")),
952            ("default::exec_command", None),
953            ("default::write_stdin", None),
954        ] {
955            let point = AgentHookPoint::BeforeToolExecution;
956            let mut runner = HookRunner::new();
957            let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
958            for legacy in ["Bash", "BashInput"] {
959                runner.register(Arc::new(ToolNameHook {
960                    point,
961                    name: legacy.into(),
962                    seen: seen.clone(),
963                    result: HookResult::Deny {
964                        reason: "must not govern a foreign tool".into(),
965                    },
966                }));
967            }
968            runner.register(Arc::new(ToolNameHook {
969                point,
970                name: name.into(),
971                seen: seen.clone(),
972                result: HookResult::Continue,
973            }));
974            let payload = HookPayload::ToolExecution {
975                tool_name: name.into(),
976                tool_call_id: "foreign-call".into(),
977                parsed_args: serde_json::json!({"cmd":"cargo test", "session_id":"exec", "chars":"yes"}),
978            };
979            let outcome = runner
980                .run_hooks_with_inputs(
981                    point,
982                    &payload,
983                    &test_session(),
984                    &mut AgentRuntimeState::new("foreign-run"),
985                    None,
986                    bamboo_hooks::portable::PortableInputs {
987                        resolved_tool_name: resolved,
988                        ..Default::default()
989                    },
990                )
991                .await;
992            assert_eq!(outcome.decision, HookResult::Continue);
993            assert_eq!(*seen.lock().unwrap(), vec![payload]);
994        }
995    }
996
997    #[tokio::test]
998    async fn empty_runner_returns_continue() {
999        let runner = HookRunner::new();
1000        let mut state = AgentRuntimeState::new("run-1");
1001        let session = test_session();
1002        let (tx, _rx) = mpsc::channel(4);
1003
1004        let result = runner
1005            .run_hooks(
1006                AgentHookPoint::BeforeRound,
1007                &HookPayload::Round { round: 1 },
1008                &session,
1009                &mut state,
1010                Some(&tx),
1011            )
1012            .await;
1013
1014        assert_eq!(result.decision, HookResult::Continue);
1015        assert!(state.checkpoints.is_empty());
1016    }
1017
1018    #[tokio::test]
1019    async fn hooks_run_in_priority_order() {
1020        let mut runner = HookRunner::new();
1021        runner.register(Arc::new(ContinueHook {
1022            point: AgentHookPoint::BeforeRound,
1023            pri: 200,
1024            name: "slow".to_string(),
1025        }));
1026        runner.register(Arc::new(ContinueHook {
1027            point: AgentHookPoint::BeforeRound,
1028            pri: 50,
1029            name: "fast".to_string(),
1030        }));
1031
1032        let mut state = AgentRuntimeState::new("run-2");
1033        let session = test_session();
1034        let (tx, mut rx) = mpsc::channel(4);
1035
1036        let result = runner
1037            .run_hooks(
1038                AgentHookPoint::BeforeRound,
1039                &HookPayload::Round { round: 1 },
1040                &session,
1041                &mut state,
1042                Some(&tx),
1043            )
1044            .await;
1045
1046        assert_eq!(result.decision, HookResult::Continue);
1047        assert_eq!(state.checkpoints.len(), 2);
1048        // Lower priority runs first
1049        assert!(state.checkpoints[0].result.contains("Continue"));
1050        assert!(matches!(
1051            rx.recv().await,
1052            Some(AgentEvent::HookLifecycle { hook_name, .. }) if hook_name == "fast"
1053        ));
1054    }
1055
1056    #[tokio::test]
1057    async fn abort_short_circuits() {
1058        let mut runner = HookRunner::new();
1059        runner.register(Arc::new(AbortHook));
1060
1061        let mut state = AgentRuntimeState::new("run-3");
1062        let session = test_session();
1063        let (tx, _rx) = mpsc::channel(4);
1064
1065        let result = runner
1066            .run_hooks(
1067                AgentHookPoint::BeforeLlmCall,
1068                &HookPayload::None,
1069                &session,
1070                &mut state,
1071                Some(&tx),
1072            )
1073            .await;
1074
1075        assert!(matches!(result.decision, HookResult::Abort { .. }));
1076        assert_eq!(state.checkpoints.len(), 1);
1077    }
1078
1079    #[tokio::test]
1080    async fn wrong_point_hooks_are_skipped() {
1081        let mut runner = HookRunner::new();
1082        runner.register(Arc::new(AbortHook)); // registered for BeforeLlmCall
1083
1084        let mut state = AgentRuntimeState::new("run-4");
1085        let session = test_session();
1086        let (tx, _rx) = mpsc::channel(4);
1087
1088        let result = runner
1089            .run_hooks(
1090                AgentHookPoint::AfterRound,
1091                &HookPayload::Round { round: 1 },
1092                &session,
1093                &mut state,
1094                Some(&tx),
1095            )
1096            .await;
1097
1098        assert_eq!(result.decision, HookResult::Continue);
1099        assert!(state.checkpoints.is_empty());
1100    }
1101
1102    struct RecordingSessionEndHook {
1103        payloads: Arc<std::sync::Mutex<Vec<HookPayload>>>,
1104    }
1105
1106    #[async_trait::async_trait]
1107    impl AgentHook for RecordingSessionEndHook {
1108        fn point(&self) -> AgentHookPoint {
1109            AgentHookPoint::AfterSessionEnd
1110        }
1111
1112        async fn run(
1113            &self,
1114            _point: AgentHookPoint,
1115            payload: &HookPayload,
1116            _session: &Session,
1117        ) -> HookResult {
1118            self.payloads.lock().unwrap().push(payload.clone());
1119            // Decisions at SessionEnd are observability-only and must not
1120            // change the already-settled terminal outcome.
1121            HookResult::Deny {
1122                reason: "ignored cleanup decision".to_string(),
1123            }
1124        }
1125    }
1126
1127    #[tokio::test]
1128    async fn session_end_fires_for_completed_failed_and_cancelled_and_ignores_decisions() {
1129        for (result, expected_status) in [
1130            (Ok(()), SessionEndStatus::Completed),
1131            (
1132                Err(AgentError::Tool("terminal failure".to_string())),
1133                SessionEndStatus::Failed,
1134            ),
1135            (Err(AgentError::Cancelled), SessionEndStatus::Cancelled),
1136        ] {
1137            let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
1138            let mut runner = HookRunner::new();
1139            runner.register(Arc::new(RecordingSessionEndHook {
1140                payloads: payloads.clone(),
1141            }));
1142            runner.register(Arc::new(RecordingSessionEndHook {
1143                payloads: payloads.clone(),
1144            }));
1145            let mut session = test_session();
1146            let (tx, _rx) = mpsc::channel(4);
1147
1148            run_session_end_hooks(&runner, &result, &mut session, &tx).await;
1149
1150            let recorded = payloads.lock().unwrap();
1151            assert_eq!(
1152                recorded.len(),
1153                2,
1154                "a denied observer must not suppress later cleanup hooks"
1155            );
1156            assert!(recorded.iter().all(|payload| matches!(
1157                payload,
1158                HookPayload::SessionEnd { status, .. } if *status == expected_status
1159            )));
1160            assert_eq!(
1161                session
1162                    .agent_runtime_state
1163                    .as_ref()
1164                    .map(|state| state.checkpoints.len()),
1165                Some(2)
1166            );
1167        }
1168    }
1169
1170    #[tokio::test]
1171    async fn session_end_skips_suspended_non_terminal_runs() {
1172        let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
1173        let mut runner = HookRunner::new();
1174        runner.register(Arc::new(RecordingSessionEndHook {
1175            payloads: payloads.clone(),
1176        }));
1177        let mut session = test_session();
1178        session.metadata.insert(
1179            "runtime.suspend_reason".to_string(),
1180            "waiting_for_children".to_string(),
1181        );
1182        let (tx, _rx) = mpsc::channel(4);
1183
1184        run_session_end_hooks(&runner, &Ok(()), &mut session, &tx).await;
1185
1186        assert!(payloads.lock().unwrap().is_empty());
1187        assert!(session.agent_runtime_state.is_none());
1188    }
1189}
1190
1191#[cfg(test)]
1192mod portable_context_tests {
1193    use super::*;
1194    #[test]
1195    fn retained_plugin_snapshot_is_bounded_without_damaging_native_context() {
1196        let mut session = Session::new("fixture", "model");
1197        let mut state = AgentRuntimeState::new("run");
1198        state.hook_contexts = vec!["native context".into()];
1199        session.agent_runtime_state = Some(state);
1200        for _ in 0..100 {
1201            inject_plugin_contexts(
1202                &mut session,
1203                vec![bamboo_hooks::portable::PluginContext {
1204                    source: "fixture@1.0.0:hooks.json:PreToolUse".into(),
1205                    text: "x".repeat(200),
1206                }],
1207            );
1208        }
1209        let chunks: Vec<String> =
1210            serde_json::from_str(&session.metadata["runtime.plugin_hook_contexts"]).unwrap();
1211        assert!(chunks.join("\n\n").len() <= bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES);
1212        assert!(chunks
1213            .iter()
1214            .all(|chunk| chunk.starts_with("Plugin hook supplemental context")));
1215        assert!(session.messages.is_empty());
1216        let block =
1217            crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
1218                &session,
1219            )
1220            .unwrap();
1221        assert!(block.content.starts_with("native context"));
1222    }
1223    #[test]
1224    fn plugin_context_is_chronological_sourced_and_compressible() {
1225        let mut session = Session::new("test", "model");
1226        let mut system = Message::system("host authority");
1227        system.never_compress = true;
1228        session.add_message(system);
1229        session.add_message(Message::user("original prompt"));
1230        inject_plugin_contexts(
1231            &mut session,
1232            vec![bamboo_hooks::portable::PluginContext {
1233                source: "fixture@0.1.0:hooks.json:PreToolUse".into(),
1234                text: "plugin content".into(),
1235            }],
1236        );
1237        assert_eq!(session.messages[0].content, "host authority");
1238        assert!(session.messages[0].never_compress);
1239        assert_eq!(session.messages.len(), 2);
1240        let block =
1241            crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
1242                &session,
1243            )
1244            .unwrap();
1245        assert!(block.content.contains("fixture@0.1.0"));
1246        assert!(block.content.contains("untrusted"));
1247        let event = bamboo_domain::ModelContextEvent {
1248            id: "fixture".into(),
1249            epoch: 0,
1250            sequence: 0,
1251            anchor_message_id: None,
1252            block_type: block.block_type,
1253            revision: 1,
1254            supersedes_revision: None,
1255            kind: bamboo_domain::ModelContextEventKind::Snapshot,
1256            content_sha256: bamboo_domain::model_context_block_sha256(&block),
1257            rendered_text: block.render_runtime_context_text(),
1258        };
1259        let message = event.render_message();
1260        assert!(matches!(message.role, bamboo_agent_core::Role::User));
1261        assert!(!message.never_compress);
1262    }
1263}