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