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