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