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    pub fn len(&self) -> usize {
255        self.dispatcher.len()
256    }
257
258    pub fn is_empty(&self) -> bool {
259        self.dispatcher.is_empty()
260            && !self.plugin_root.as_ref().is_some_and(|root| {
261                [
262                    AgentHookPoint::BeforeSessionSetup,
263                    AgentHookPoint::BeforeToolExecution,
264                    AgentHookPoint::AfterToolExecution,
265                    AgentHookPoint::BeforeFinalize,
266                ]
267                .into_iter()
268                .any(|point| bamboo_hooks::portable::has_active_hooks_for(root, point))
269            })
270    }
271}
272
273fn prompt_fingerprint(prompt: &str) -> String {
274    use sha2::{Digest, Sha256};
275    hex::encode(Sha256::digest(prompt.as_bytes()))
276}
277
278async fn record_dispatch_report(
279    report: bamboo_hooks::HookDispatchReport,
280    runtime_state: &mut AgentRuntimeState,
281    event_tx: Option<&mpsc::Sender<AgentEvent>>,
282) -> HookRunOutcome {
283    let bamboo_hooks::HookDispatchReport {
284        outcome,
285        executions,
286    } = report;
287    for execution in executions {
288        runtime_state.checkpoints.push(HookCheckpoint {
289            hook_point: format!("{:?}", execution.point),
290            timestamp: Utc::now(),
291            result: format!("{:?}", execution.result),
292            duration_ms: execution.duration_ms,
293        });
294        if let Some(event_tx) = event_tx {
295            let _ = event_tx
296                .send(AgentEvent::HookLifecycle {
297                    hook_name: execution.hook_name,
298                    point: execution.point,
299                    phase: "completed".to_string(),
300                    duration_ms: execution.duration_ms,
301                    decision: execution.result,
302                })
303                .await;
304        }
305    }
306    outcome
307}
308
309/// Fire cleanup/notification hooks after a terminal run. Decisions and context
310/// are intentionally ignored: `SessionEnd` observes a settled outcome and may
311/// not reverse it. Suspended runs are non-terminal and do not fire this event.
312pub(crate) async fn run_session_end_hooks(
313    runner: &HookRunner,
314    result: &Result<(), AgentError>,
315    session: &mut Session,
316    event_tx: &mpsc::Sender<AgentEvent>,
317) {
318    let suspended_non_terminal = result.is_ok()
319        && session
320            .metadata
321            .get("runtime.suspend_reason")
322            .is_some_and(|reason| !reason.trim().is_empty());
323    if suspended_non_terminal || !runner.has_hooks_for(AgentHookPoint::AfterSessionEnd) {
324        return;
325    }
326
327    let (status, completion_reason) = match result {
328        Ok(()) => (
329            SessionEndStatus::Completed,
330            session
331                .metadata
332                .get("runtime.completion_reason")
333                .cloned()
334                .or_else(|| Some("completed".to_string())),
335        ),
336        Err(error) if error.is_cancelled() => {
337            (SessionEndStatus::Cancelled, Some(error.to_string()))
338        }
339        Err(error) => (SessionEndStatus::Failed, Some(error.to_string())),
340    };
341    let mut runtime_state = session
342        .agent_runtime_state
343        .clone()
344        .unwrap_or_else(|| AgentRuntimeState::new(&session.id));
345    runner
346        .run_observer_hooks(
347            AgentHookPoint::AfterSessionEnd,
348            &HookPayload::SessionEnd {
349                status,
350                completion_reason,
351            },
352            session,
353            &mut runtime_state,
354            Some(event_tx),
355        )
356        .await;
357    session.agent_runtime_state = Some(runtime_state);
358}
359
360/// Apply context injections and non-tool control decisions consistently across
361/// lifecycle seams.
362pub(crate) fn apply_hook_outcome(
363    point: AgentHookPoint,
364    outcome: HookRunOutcome,
365    session: &mut Session,
366    runtime_state: &mut AgentRuntimeState,
367) -> Result<(), AgentError> {
368    inject_plugin_contexts(session, outcome.plugin_contexts);
369    if matches!(point, AgentHookPoint::AfterSessionSetup) {
370        runtime_state.hook_contexts.extend(
371            outcome
372                .injected_contexts
373                .into_iter()
374                .filter(|text| !text.trim().is_empty()),
375        );
376    } else {
377        inject_contexts(session, point, outcome.injected_contexts);
378    }
379
380    match outcome.decision {
381        HookResult::Continue
382        | HookResult::Mutated
383        | HookResult::Allow
384        | HookResult::InjectContext { .. } => Ok(()),
385        HookResult::Suspend { reason } => {
386            let hook_point = format!("{point:?}");
387            runtime_state.status = AgentStatusState::Suspended;
388            runtime_state.suspension = Some(SuspensionState {
389                reason: reason.clone(),
390                suspended_at: Utc::now(),
391                resumable: true,
392                hook_point: Some(hook_point.clone()),
393            });
394            session.metadata.insert(
395                "runtime.suspend_reason".to_string(),
396                "hook_suspended".to_string(),
397            );
398            Err(AgentError::HookSuspended(format!("{hook_point}: {reason}")))
399        }
400        HookResult::Abort { reason } => Err(AgentError::Tool(format!(
401            "hook aborted at {point:?}: {reason}"
402        ))),
403        HookResult::Deny { reason } => Err(AgentError::Tool(format!(
404            "hook denied lifecycle seam {point:?}: {reason}"
405        ))),
406        HookResult::Ask => Err(AgentError::Tool(format!(
407            "hook requested parent approval at non-tool seam {point:?}"
408        ))),
409        HookResult::WithContext { result, text } => apply_hook_outcome(
410            point,
411            HookRunOutcome {
412                decision: *result,
413                injected_contexts: vec![text],
414                plugin_contexts: vec![],
415            },
416            session,
417            runtime_state,
418        ),
419    }
420}
421
422/// Stage bounded untrusted snapshots for the existing chronological context
423/// ledger. Do not insert messages into an unfinished assistant/tool group.
424pub(crate) fn inject_plugin_contexts(
425    session: &mut Session,
426    contexts: Vec<bamboo_hooks::portable::PluginContext>,
427) {
428    if contexts.is_empty() {
429        return;
430    }
431    let key = "runtime.plugin_hook_contexts";
432    let mut chunks: Vec<String> = session
433        .metadata
434        .get(key)
435        .and_then(|value| serde_json::from_str(value).ok())
436        .unwrap_or_default();
437    chunks.extend(contexts.into_iter().map(|context| context.rendered_text()));
438    while chunks.iter().map(String::len).sum::<usize>() + chunks.len().saturating_sub(1) * 2
439        > bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES
440    {
441        if chunks.is_empty() {
442            break;
443        }
444        chunks.remove(0);
445    }
446    session.metadata.insert(
447        key.into(),
448        serde_json::to_string(&chunks).expect("string serialization"),
449    );
450}
451
452pub(crate) fn inject_contexts(
453    session: &mut Session,
454    point: AgentHookPoint,
455    injected_contexts: Vec<String>,
456) {
457    for text in injected_contexts {
458        if text.trim().is_empty() {
459            continue;
460        }
461        let block =
462            format!("\n\n<agent_hook_context point=\"{point:?}\">\n{text}\n</agent_hook_context>");
463        if let Some(system_message) = session
464            .messages
465            .iter_mut()
466            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
467        {
468            system_message.content.push_str(&block);
469            system_message.never_compress = true;
470        } else {
471            let mut message = Message::system(block.trim().to_string());
472            message.never_compress = true;
473            message.metadata = Some(serde_json::json!({
474                "runtime_kind": "hook_context",
475                "hook_point": point,
476            }));
477            session.add_message(message);
478        }
479    }
480}
481
482/// Merge hook checkpoints produced through a session-local seam (notably
483/// compression) into the runner-owned state without losing checkpoints written
484/// directly by tool/round seams.
485pub(crate) fn merge_session_hook_checkpoints(
486    session: &Session,
487    runtime_state: &mut AgentRuntimeState,
488) {
489    let Some(session_state) = session.agent_runtime_state.as_ref() else {
490        return;
491    };
492    for checkpoint in &session_state.checkpoints {
493        if !runtime_state.checkpoints.contains(checkpoint) {
494            runtime_state.checkpoints.push(checkpoint.clone());
495        }
496    }
497    if matches!(session_state.status, AgentStatusState::Suspended) {
498        runtime_state.status = AgentStatusState::Suspended;
499        runtime_state.suspension = session_state.suspension.clone();
500    }
501}
502
503impl Default for HookRunner {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509#[cfg(test)]
510mod tests {
511
512    #[cfg(unix)]
513    #[tokio::test]
514    async fn combined_shell_ask_checks_trusted_portable_output_policy() {
515        use bamboo_plugin::{
516            InstalledPlugin, InstalledPlugins, PluginInstallStatus, PluginManifest, PluginSource,
517            RegisteredCapabilities,
518        };
519        use serde_json::json;
520        for decision in ["deny", "continue", "allow"] {
521            let temp = tempfile::tempdir().unwrap();
522            let root = temp.path().join("plugins");
523            let bundle = root.join("combined-ask");
524            std::fs::create_dir_all(&bundle).unwrap();
525            let manifest: PluginManifest = serde_json::from_value(json!({
526                "id":"combined-ask", "name":"Combined Ask", "version":"0.1.0",
527                "provides":{"hooks":[{"config":"hooks.json", "scripts":["policy.sh"]}]}
528            }))
529            .unwrap();
530            std::fs::write(
531                bundle.join("plugin.json"),
532                serde_json::to_vec(&manifest).unwrap(),
533            )
534            .unwrap();
535            std::fs::write(bundle.join("policy.sh"), "# reviewed fixture").unwrap();
536            let response = if decision == "continue" {
537                String::new()
538            } else {
539                json!({"hookSpecificOutput":{"hookEventName":"PreToolUse", "permissionDecision":decision, "permissionDecisionReason":"portable output denied"}}).to_string()
540            };
541            let command = format!("touch \"$PLUGIN_DATA/seen\"; printf '%s' '{}'", response);
542            std::fs::write(bundle.join("hooks.json"), serde_json::to_vec(&json!({"hooks":{"PreToolUse":[{
543                "matcher":"^BashOutput$", "hooks":[{"type":"command", "command":command, "timeout":1}]
544            }]}})).unwrap()).unwrap();
545            let mut hooks = bamboo_plugin::hooks::registrations(&manifest, &bundle).unwrap();
546            let digest = hooks[0].digest.clone();
547            hooks[0].confirm_review(&digest).unwrap();
548            InstalledPlugins {
549                plugins: vec![InstalledPlugin {
550                    id: manifest.id,
551                    version: manifest.version,
552                    source: PluginSource::LocalDir {
553                        path: bundle.clone(),
554                    },
555                    plugin_dir: bundle,
556                    installed_at: Utc::now(),
557                    status: PluginInstallStatus::Installed,
558                    registered: RegisteredCapabilities {
559                        hooks,
560                        ..Default::default()
561                    },
562                }],
563            }
564            .save(&root.join("installed.json"))
565            .await
566            .unwrap();
567            let marker = root.join(".hook-data/combined-ask/seen");
568            for name in ["write_stdin", "default::write_stdin"] {
569                for (primary, args) in [
570                    ("BashInput", json!({"session_id":"exec", "chars":"yes"})),
571                    ("BashInput", json!({"session_id":"exec", "eof":true})),
572                    ("KillShell", json!({"session_id":"exec", "chars":"\u{3}"})),
573                ] {
574                    let _ = std::fs::remove_file(&marker);
575                    let mut runner = HookRunner::new().with_lifecycle_config(
576                        &bamboo_config::LifecycleHooksConfig::default(),
577                        Some(temp.path().to_owned()),
578                    );
579                    let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
580                    runner.register(Arc::new(ToolNameHook {
581                        point: AgentHookPoint::BeforeToolExecution,
582                        name: primary.into(),
583                        seen: seen.clone(),
584                        result: HookResult::Ask,
585                    }));
586                    let outcome = runner
587                        .run_hooks_with_inputs(
588                            AgentHookPoint::BeforeToolExecution,
589                            &HookPayload::ToolExecution {
590                                tool_name: name.into(),
591                                tool_call_id: "combined-ask".into(),
592                                parsed_args: args.clone(),
593                            },
594                            &test_session(),
595                            &mut AgentRuntimeState::new("combined-ask"),
596                            None,
597                            bamboo_hooks::portable::PortableInputs {
598                                resolved_tool_name: Some("write_stdin"),
599                                original_tool_input: Some(&args),
600                                ..Default::default()
601                            },
602                        )
603                        .await;
604                    let expected = if decision == "deny" {
605                        HookResult::Deny {
606                            reason: "portable output denied".into(),
607                        }
608                    } else {
609                        HookResult::Ask
610                    };
611                    assert_eq!(outcome.decision, expected, "{decision}, {name}: {args}");
612                    assert_eq!(seen.lock().unwrap().len(), 1);
613                    assert!(
614                        marker.exists(),
615                        "trusted portable output handler must run after native Ask"
616                    );
617                }
618            }
619            // Legacy and pure-poll calls retain the native Ask short circuit.
620            for (name, args, resolved) in [
621                ("BashOutput", json!({"bash_id":"legacy"}), None),
622                (
623                    "write_stdin",
624                    json!({"session_id":"exec", "chars":""}),
625                    Some("write_stdin"),
626                ),
627                (
628                    "default::write_stdin",
629                    json!({"session_id":"exec", "eof":false}),
630                    Some("write_stdin"),
631                ),
632            ] {
633                let _ = std::fs::remove_file(&marker);
634                let mut runner = HookRunner::new().with_lifecycle_config(
635                    &bamboo_config::LifecycleHooksConfig::default(),
636                    Some(temp.path().to_owned()),
637                );
638                runner.register(Arc::new(ToolNameHook {
639                    point: AgentHookPoint::BeforeToolExecution,
640                    name: "BashOutput".into(),
641                    seen: Arc::new(std::sync::Mutex::new(Vec::new())),
642                    result: HookResult::Ask,
643                }));
644                let outcome = runner
645                    .run_hooks_with_inputs(
646                        AgentHookPoint::BeforeToolExecution,
647                        &HookPayload::ToolExecution {
648                            tool_name: name.into(),
649                            tool_call_id: "legacy-ask".into(),
650                            parsed_args: args.clone(),
651                        },
652                        &test_session(),
653                        &mut AgentRuntimeState::new("legacy-ask"),
654                        None,
655                        bamboo_hooks::portable::PortableInputs {
656                            resolved_tool_name: resolved,
657                            original_tool_input: Some(&args),
658                            ..Default::default()
659                        },
660                    )
661                    .await;
662                assert_eq!(outcome.decision, HookResult::Ask);
663                assert!(
664                    !marker.exists(),
665                    "ordinary legacy Ask must skip portable execution"
666                );
667            }
668        }
669    }
670    use super::*;
671
672    /// A no-op hook that always returns Continue.
673    struct ContinueHook {
674        point: AgentHookPoint,
675        pri: u32,
676        name: String,
677    }
678
679    #[async_trait::async_trait]
680    impl AgentHook for ContinueHook {
681        fn point(&self) -> AgentHookPoint {
682            self.point
683        }
684
685        async fn run(
686            &self,
687            _point: AgentHookPoint,
688            _payload: &HookPayload,
689            _session: &Session,
690        ) -> HookResult {
691            HookResult::Continue
692        }
693
694        fn priority(&self) -> u32 {
695            self.pri
696        }
697
698        fn name(&self) -> &str {
699            &self.name
700        }
701    }
702
703    /// A hook that always returns Abort.
704    struct AbortHook;
705
706    #[async_trait::async_trait]
707    impl AgentHook for AbortHook {
708        fn point(&self) -> AgentHookPoint {
709            AgentHookPoint::BeforeLlmCall
710        }
711
712        async fn run(
713            &self,
714            _point: AgentHookPoint,
715            _payload: &HookPayload,
716            _session: &Session,
717        ) -> HookResult {
718            HookResult::Abort {
719                reason: "test abort".to_string(),
720            }
721        }
722
723        fn name(&self) -> &str {
724            "abort_hook"
725        }
726    }
727
728    fn test_session() -> Session {
729        Session::new("test", "test-model")
730    }
731
732    struct ToolNameHook {
733        point: AgentHookPoint,
734        name: String,
735        seen: Arc<std::sync::Mutex<Vec<HookPayload>>>,
736        result: HookResult,
737    }
738
739    #[async_trait::async_trait]
740    impl AgentHook for ToolNameHook {
741        fn point(&self) -> AgentHookPoint {
742            self.point
743        }
744
745        fn matches(&self, payload: &HookPayload) -> bool {
746            match payload {
747                HookPayload::ToolExecution { tool_name, .. }
748                | HookPayload::ToolResult { tool_name, .. } => tool_name == &self.name,
749                _ => false,
750            }
751        }
752
753        async fn run(
754            &self,
755            _point: AgentHookPoint,
756            payload: &HookPayload,
757            _session: &Session,
758        ) -> HookResult {
759            self.seen.lock().unwrap().push(payload.clone());
760            self.result.clone()
761        }
762    }
763
764    #[tokio::test]
765    async fn resolved_builtin_aliases_keep_native_legacy_shell_policy() {
766        use bamboo_agent_core::{FunctionCall, ToolCall};
767        use serde_json::json;
768
769        for (alias, resolved, legacy, input) in [
770            (
771                "default::exec_command",
772                "exec_command",
773                "Bash",
774                json!({"cmd":"cargo test"}),
775            ),
776            (
777                "default::write_stdin",
778                "write_stdin",
779                "BashInput",
780                json!({"session_id":"exec", "chars":"yes\n"}),
781            ),
782        ] {
783            let mut session = test_session();
784            session.messages.push(Message::assistant(
785                "",
786                Some(vec![ToolCall {
787                    id: "alias-call".into(),
788                    tool_type: "function".into(),
789                    function: FunctionCall {
790                        name: alias.into(),
791                        arguments: input.to_string(),
792                    },
793                }]),
794            ));
795            for (point, payload) in [
796                (
797                    AgentHookPoint::BeforeToolExecution,
798                    HookPayload::ToolExecution {
799                        tool_name: alias.into(),
800                        tool_call_id: "alias-call".into(),
801                        parsed_args: input.clone(),
802                    },
803                ),
804                (
805                    AgentHookPoint::AfterToolExecution,
806                    HookPayload::ToolResult {
807                        tool_name: alias.into(),
808                        tool_call_id: "alias-call".into(),
809                        outcome: bamboo_domain::HookToolOutcome {
810                            success: true,
811                            result: Some("done".into()),
812                            error: None,
813                            needs_human: false,
814                            duration_ms: 1,
815                        },
816                    },
817                ),
818            ] {
819                let mut runner = HookRunner::new();
820                let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
821                let deny = HookResult::Deny {
822                    reason: "legacy shell policy".into(),
823                };
824                runner.register(Arc::new(ToolNameHook {
825                    point,
826                    name: legacy.into(),
827                    seen: seen.clone(),
828                    result: deny.clone(),
829                }));
830                let outcome = runner
831                    .run_hooks_with_inputs(
832                        point,
833                        &payload,
834                        &session,
835                        &mut AgentRuntimeState::new("alias-run"),
836                        None,
837                        bamboo_hooks::portable::PortableInputs {
838                            resolved_tool_name: Some(resolved),
839                            original_tool_input: Some(&input),
840                            ..Default::default()
841                        },
842                    )
843                    .await;
844                assert_eq!(outcome.decision, deny);
845                let seen = seen.lock().unwrap();
846                assert_eq!(seen.len(), 1);
847                match &seen[0] {
848                    HookPayload::ToolExecution {
849                        tool_name,
850                        parsed_args,
851                        ..
852                    } => {
853                        assert_eq!(tool_name, legacy);
854                        if resolved == "exec_command" {
855                            assert_eq!(parsed_args, &json!({"command":"cargo test"}));
856                        } else {
857                            assert_eq!(parsed_args["bash_id"], "exec");
858                            assert_eq!(parsed_args["input"], "yes\n");
859                        }
860                    }
861                    HookPayload::ToolResult { tool_name, .. } => assert_eq!(tool_name, legacy),
862                    _ => panic!("expected tool hook payload"),
863                }
864            }
865        }
866    }
867
868    #[tokio::test]
869    async fn resolved_non_shell_aliases_preserve_native_matchers_and_payloads() {
870        for alias in ["read_file", "default::Read"] {
871            for (point, payload) in [
872                (
873                    AgentHookPoint::BeforeToolExecution,
874                    HookPayload::ToolExecution {
875                        tool_name: alias.into(),
876                        tool_call_id: "read-alias".into(),
877                        parsed_args: serde_json::json!({"path":"input.txt"}),
878                    },
879                ),
880                (
881                    AgentHookPoint::AfterToolExecution,
882                    HookPayload::ToolResult {
883                        tool_name: alias.into(),
884                        tool_call_id: "read-alias".into(),
885                        outcome: bamboo_domain::HookToolOutcome {
886                            success: true,
887                            result: Some("file contents".into()),
888                            error: None,
889                            needs_human: false,
890                            duration_ms: 1,
891                        },
892                    },
893                ),
894            ] {
895                let mut runner = HookRunner::new();
896                let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
897                runner.register(Arc::new(ToolNameHook {
898                    point,
899                    name: "Read".into(),
900                    seen: seen.clone(),
901                    result: HookResult::Deny {
902                        reason: "must not newly match the resolved name".into(),
903                    },
904                }));
905                let expected = HookResult::Deny {
906                    reason: "original alias policy".into(),
907                };
908                runner.register(Arc::new(ToolNameHook {
909                    point,
910                    name: alias.into(),
911                    seen: seen.clone(),
912                    result: expected.clone(),
913                }));
914                let outcome = runner
915                    .run_hooks_with_inputs(
916                        point,
917                        &payload,
918                        &test_session(),
919                        &mut AgentRuntimeState::new("read-alias-run"),
920                        None,
921                        bamboo_hooks::portable::PortableInputs {
922                            resolved_tool_name: Some("Read"),
923                            ..Default::default()
924                        },
925                    )
926                    .await;
927                assert_eq!(outcome.decision, expected);
928                assert_eq!(*seen.lock().unwrap(), vec![payload]);
929            }
930        }
931    }
932
933    #[tokio::test]
934    async fn foreign_and_unresolved_tool_names_do_not_inherit_native_shell_policy() {
935        for (name, resolved) in [
936            ("vendor::exec_command", Some("vendor::exec_command")),
937            ("vendor::write_stdin", Some("vendor::write_stdin")),
938            ("default::exec_command", None),
939            ("default::write_stdin", None),
940        ] {
941            let point = AgentHookPoint::BeforeToolExecution;
942            let mut runner = HookRunner::new();
943            let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
944            for legacy in ["Bash", "BashInput"] {
945                runner.register(Arc::new(ToolNameHook {
946                    point,
947                    name: legacy.into(),
948                    seen: seen.clone(),
949                    result: HookResult::Deny {
950                        reason: "must not govern a foreign tool".into(),
951                    },
952                }));
953            }
954            runner.register(Arc::new(ToolNameHook {
955                point,
956                name: name.into(),
957                seen: seen.clone(),
958                result: HookResult::Continue,
959            }));
960            let payload = HookPayload::ToolExecution {
961                tool_name: name.into(),
962                tool_call_id: "foreign-call".into(),
963                parsed_args: serde_json::json!({"cmd":"cargo test", "session_id":"exec", "chars":"yes"}),
964            };
965            let outcome = runner
966                .run_hooks_with_inputs(
967                    point,
968                    &payload,
969                    &test_session(),
970                    &mut AgentRuntimeState::new("foreign-run"),
971                    None,
972                    bamboo_hooks::portable::PortableInputs {
973                        resolved_tool_name: resolved,
974                        ..Default::default()
975                    },
976                )
977                .await;
978            assert_eq!(outcome.decision, HookResult::Continue);
979            assert_eq!(*seen.lock().unwrap(), vec![payload]);
980        }
981    }
982
983    #[tokio::test]
984    async fn empty_runner_returns_continue() {
985        let runner = HookRunner::new();
986        let mut state = AgentRuntimeState::new("run-1");
987        let session = test_session();
988        let (tx, _rx) = mpsc::channel(4);
989
990        let result = runner
991            .run_hooks(
992                AgentHookPoint::BeforeRound,
993                &HookPayload::Round { round: 1 },
994                &session,
995                &mut state,
996                Some(&tx),
997            )
998            .await;
999
1000        assert_eq!(result.decision, HookResult::Continue);
1001        assert!(state.checkpoints.is_empty());
1002    }
1003
1004    #[tokio::test]
1005    async fn hooks_run_in_priority_order() {
1006        let mut runner = HookRunner::new();
1007        runner.register(Arc::new(ContinueHook {
1008            point: AgentHookPoint::BeforeRound,
1009            pri: 200,
1010            name: "slow".to_string(),
1011        }));
1012        runner.register(Arc::new(ContinueHook {
1013            point: AgentHookPoint::BeforeRound,
1014            pri: 50,
1015            name: "fast".to_string(),
1016        }));
1017
1018        let mut state = AgentRuntimeState::new("run-2");
1019        let session = test_session();
1020        let (tx, mut rx) = mpsc::channel(4);
1021
1022        let result = runner
1023            .run_hooks(
1024                AgentHookPoint::BeforeRound,
1025                &HookPayload::Round { round: 1 },
1026                &session,
1027                &mut state,
1028                Some(&tx),
1029            )
1030            .await;
1031
1032        assert_eq!(result.decision, HookResult::Continue);
1033        assert_eq!(state.checkpoints.len(), 2);
1034        // Lower priority runs first
1035        assert!(state.checkpoints[0].result.contains("Continue"));
1036        assert!(matches!(
1037            rx.recv().await,
1038            Some(AgentEvent::HookLifecycle { hook_name, .. }) if hook_name == "fast"
1039        ));
1040    }
1041
1042    #[tokio::test]
1043    async fn abort_short_circuits() {
1044        let mut runner = HookRunner::new();
1045        runner.register(Arc::new(AbortHook));
1046
1047        let mut state = AgentRuntimeState::new("run-3");
1048        let session = test_session();
1049        let (tx, _rx) = mpsc::channel(4);
1050
1051        let result = runner
1052            .run_hooks(
1053                AgentHookPoint::BeforeLlmCall,
1054                &HookPayload::None,
1055                &session,
1056                &mut state,
1057                Some(&tx),
1058            )
1059            .await;
1060
1061        assert!(matches!(result.decision, HookResult::Abort { .. }));
1062        assert_eq!(state.checkpoints.len(), 1);
1063    }
1064
1065    #[tokio::test]
1066    async fn wrong_point_hooks_are_skipped() {
1067        let mut runner = HookRunner::new();
1068        runner.register(Arc::new(AbortHook)); // registered for BeforeLlmCall
1069
1070        let mut state = AgentRuntimeState::new("run-4");
1071        let session = test_session();
1072        let (tx, _rx) = mpsc::channel(4);
1073
1074        let result = runner
1075            .run_hooks(
1076                AgentHookPoint::AfterRound,
1077                &HookPayload::Round { round: 1 },
1078                &session,
1079                &mut state,
1080                Some(&tx),
1081            )
1082            .await;
1083
1084        assert_eq!(result.decision, HookResult::Continue);
1085        assert!(state.checkpoints.is_empty());
1086    }
1087
1088    struct RecordingSessionEndHook {
1089        payloads: Arc<std::sync::Mutex<Vec<HookPayload>>>,
1090    }
1091
1092    #[async_trait::async_trait]
1093    impl AgentHook for RecordingSessionEndHook {
1094        fn point(&self) -> AgentHookPoint {
1095            AgentHookPoint::AfterSessionEnd
1096        }
1097
1098        async fn run(
1099            &self,
1100            _point: AgentHookPoint,
1101            payload: &HookPayload,
1102            _session: &Session,
1103        ) -> HookResult {
1104            self.payloads.lock().unwrap().push(payload.clone());
1105            // Decisions at SessionEnd are observability-only and must not
1106            // change the already-settled terminal outcome.
1107            HookResult::Deny {
1108                reason: "ignored cleanup decision".to_string(),
1109            }
1110        }
1111    }
1112
1113    #[tokio::test]
1114    async fn session_end_fires_for_completed_failed_and_cancelled_and_ignores_decisions() {
1115        for (result, expected_status) in [
1116            (Ok(()), SessionEndStatus::Completed),
1117            (
1118                Err(AgentError::Tool("terminal failure".to_string())),
1119                SessionEndStatus::Failed,
1120            ),
1121            (Err(AgentError::Cancelled), SessionEndStatus::Cancelled),
1122        ] {
1123            let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
1124            let mut runner = HookRunner::new();
1125            runner.register(Arc::new(RecordingSessionEndHook {
1126                payloads: payloads.clone(),
1127            }));
1128            runner.register(Arc::new(RecordingSessionEndHook {
1129                payloads: payloads.clone(),
1130            }));
1131            let mut session = test_session();
1132            let (tx, _rx) = mpsc::channel(4);
1133
1134            run_session_end_hooks(&runner, &result, &mut session, &tx).await;
1135
1136            let recorded = payloads.lock().unwrap();
1137            assert_eq!(
1138                recorded.len(),
1139                2,
1140                "a denied observer must not suppress later cleanup hooks"
1141            );
1142            assert!(recorded.iter().all(|payload| matches!(
1143                payload,
1144                HookPayload::SessionEnd { status, .. } if *status == expected_status
1145            )));
1146            assert_eq!(
1147                session
1148                    .agent_runtime_state
1149                    .as_ref()
1150                    .map(|state| state.checkpoints.len()),
1151                Some(2)
1152            );
1153        }
1154    }
1155
1156    #[tokio::test]
1157    async fn session_end_skips_suspended_non_terminal_runs() {
1158        let payloads = Arc::new(std::sync::Mutex::new(Vec::new()));
1159        let mut runner = HookRunner::new();
1160        runner.register(Arc::new(RecordingSessionEndHook {
1161            payloads: payloads.clone(),
1162        }));
1163        let mut session = test_session();
1164        session.metadata.insert(
1165            "runtime.suspend_reason".to_string(),
1166            "waiting_for_children".to_string(),
1167        );
1168        let (tx, _rx) = mpsc::channel(4);
1169
1170        run_session_end_hooks(&runner, &Ok(()), &mut session, &tx).await;
1171
1172        assert!(payloads.lock().unwrap().is_empty());
1173        assert!(session.agent_runtime_state.is_none());
1174    }
1175}
1176
1177#[cfg(test)]
1178mod portable_context_tests {
1179    use super::*;
1180    #[test]
1181    fn retained_plugin_snapshot_is_bounded_without_damaging_native_context() {
1182        let mut session = Session::new("fixture", "model");
1183        let mut state = AgentRuntimeState::new("run");
1184        state.hook_contexts = vec!["native context".into()];
1185        session.agent_runtime_state = Some(state);
1186        for _ in 0..100 {
1187            inject_plugin_contexts(
1188                &mut session,
1189                vec![bamboo_hooks::portable::PluginContext {
1190                    source: "fixture@1.0.0:hooks.json:PreToolUse".into(),
1191                    text: "x".repeat(200),
1192                }],
1193            );
1194        }
1195        let chunks: Vec<String> =
1196            serde_json::from_str(&session.metadata["runtime.plugin_hook_contexts"]).unwrap();
1197        assert!(chunks.join("\n\n").len() <= bamboo_hooks::portable::PORTABLE_CONTEXT_BYTES);
1198        assert!(chunks
1199            .iter()
1200            .all(|chunk| chunk.starts_with("Plugin hook supplemental context")));
1201        assert!(session.messages.is_empty());
1202        let block =
1203            crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
1204                &session,
1205            )
1206            .unwrap();
1207        assert!(block.content.starts_with("native context"));
1208    }
1209    #[test]
1210    fn plugin_context_is_chronological_sourced_and_compressible() {
1211        let mut session = Session::new("test", "model");
1212        let mut system = Message::system("host authority");
1213        system.never_compress = true;
1214        session.add_message(system);
1215        session.add_message(Message::user("original prompt"));
1216        inject_plugin_contexts(
1217            &mut session,
1218            vec![bamboo_hooks::portable::PluginContext {
1219                source: "fixture@0.1.0:hooks.json:PreToolUse".into(),
1220                text: "plugin content".into(),
1221            }],
1222        );
1223        assert_eq!(session.messages[0].content, "host authority");
1224        assert!(session.messages[0].never_compress);
1225        assert_eq!(session.messages.len(), 2);
1226        let block =
1227            crate::runtime::runner::session_setup::prompt_envelope::build_agent_hook_context_block(
1228                &session,
1229            )
1230            .unwrap();
1231        assert!(block.content.contains("fixture@0.1.0"));
1232        assert!(block.content.contains("untrusted"));
1233        let event = bamboo_domain::ModelContextEvent {
1234            id: "fixture".into(),
1235            epoch: 0,
1236            sequence: 0,
1237            anchor_message_id: None,
1238            block_type: block.block_type,
1239            revision: 1,
1240            supersedes_revision: None,
1241            kind: bamboo_domain::ModelContextEventKind::Snapshot,
1242            content_sha256: bamboo_domain::model_context_block_sha256(&block),
1243            rendered_text: block.render_runtime_context_text(),
1244        };
1245        let message = event.render_message();
1246        assert!(matches!(message.role, bamboo_agent_core::Role::User));
1247        assert!(!message.never_compress);
1248    }
1249}