1mod 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#[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 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 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 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 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 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 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 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
373pub(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
424pub(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
486pub(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
546pub(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 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 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 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 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)); 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 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}