1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Instant;
4
5use bamboo_agent_core::{AgentHook, Session};
6use bamboo_config::LifecycleHooksConfig;
7use bamboo_domain::{AgentHookPoint, HookPayload, HookResult};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HookRunOutcome {
12 pub decision: HookResult,
13 pub injected_contexts: Vec<String>,
14 pub plugin_contexts: Vec<crate::portable::PluginContext>,
15}
16
17impl Default for HookRunOutcome {
18 fn default() -> Self {
19 Self {
20 decision: HookResult::Continue,
21 injected_contexts: Vec::new(),
22 plugin_contexts: Vec::new(),
23 }
24 }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct HookExecution {
30 pub hook_name: String,
31 pub point: AgentHookPoint,
32 pub duration_ms: u64,
33 pub result: HookResult,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Default)]
38pub struct HookDispatchReport {
39 pub outcome: HookRunOutcome,
40 pub executions: Vec<HookExecution>,
41}
42
43#[derive(Clone, Default)]
45pub struct HookDispatcher {
46 hooks: Vec<Arc<dyn AgentHook>>,
47}
48
49impl HookDispatcher {
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn register(&mut self, hook: Arc<dyn AgentHook>) {
56 self.hooks.push(hook);
57 self.hooks.sort_by_key(|hook| hook.priority());
58 }
59
60 pub fn with_lifecycle_config(
63 &self,
64 config: &LifecycleHooksConfig,
65 fallback_cwd: Option<PathBuf>,
66 ) -> Self {
67 let mut dispatcher = self.clone();
68 crate::configured::register_configured_hooks(&mut dispatcher, config, fallback_cwd);
69 dispatcher
70 }
71
72 pub async fn run_hooks(
73 &self,
74 point: AgentHookPoint,
75 payload: &HookPayload,
76 session: &Session,
77 ) -> HookDispatchReport {
78 self.run_hooks_with_control(point, payload, session, true, None, None)
79 .await
80 }
81
82 pub async fn run_hooks_with_resolved_shell(
86 &self,
87 point: AgentHookPoint,
88 payload: &HookPayload,
89 session: &Session,
90 resolved_tool_name: Option<&str>,
91 original_tool_input: Option<&serde_json::Value>,
92 ) -> HookDispatchReport {
93 self.run_hooks_with_control(
94 point,
95 payload,
96 session,
97 true,
98 resolved_tool_name,
99 original_tool_input,
100 )
101 .await
102 }
103
104 pub async fn run_observer_hooks(
107 &self,
108 point: AgentHookPoint,
109 payload: &HookPayload,
110 session: &Session,
111 ) -> HookDispatchReport {
112 self.run_hooks_with_control(point, payload, session, false, None, None)
113 .await
114 }
115
116 async fn run_hooks_with_control(
117 &self,
118 point: AgentHookPoint,
119 payload: &HookPayload,
120 session: &Session,
121 honor_control_decisions: bool,
122 resolved_tool_name: Option<&str>,
123 original_tool_input: Option<&serde_json::Value>,
124 ) -> HookDispatchReport {
125 let mut report = HookDispatchReport::default();
126 let resolved_shell = resolved_tool_name
127 .filter(|name| matches!(*name, "exec_command" | "write_stdin"))
128 .and_then(|name| {
129 let mut resolved = payload.clone();
130 match &mut resolved {
131 HookPayload::ToolExecution { tool_name, .. }
132 | HookPayload::ToolResult { tool_name, .. } => {
133 *tool_name = name.to_owned();
134 Some(resolved)
135 }
136 _ => None,
137 }
138 });
139 let legacy = crate::shell_compatibility::legacy_shell_projections(
140 resolved_shell.as_ref().unwrap_or(payload),
141 session,
142 original_tool_input,
143 );
144
145 let complete_operation_checks = crate::requires_complete_shell_policy_checks(
146 point,
147 resolved_shell.as_ref().unwrap_or(payload),
148 session,
149 original_tool_input,
150 );
151 let mut pending_composite_ask = false;
152
153 for hook in &self.hooks {
154 if hook.point() != point {
155 continue;
156 }
157 let selected: Vec<_> = if hook.matches(payload) {
161 vec![(payload, false)]
162 } else if let Some(resolved) = resolved_shell
163 .as_ref()
164 .filter(|resolved| hook.matches(resolved))
165 {
166 vec![(resolved, false)]
167 } else {
168 legacy
169 .iter()
170 .filter(|projected| hook.matches(&projected.payload))
171 .map(|projected| (&projected.payload, projected.restriction_only))
172 .collect()
173 };
174
175 let mut deferred_ask = false;
176 for (selected_payload, restriction_only) in selected {
177 let started = Instant::now();
178 let result = hook.run(point, selected_payload, session).await;
179 report.executions.push(HookExecution {
180 hook_name: hook.name().to_string(),
181 point,
182 duration_ms: started.elapsed().as_millis() as u64,
183 result: result.clone(),
184 });
185
186 let (result, mut contexts) = unwrap_context_result(result);
187 report.outcome.injected_contexts.append(&mut contexts);
188 let result = if restriction_only && matches!(result, HookResult::Allow) {
189 HookResult::Continue
192 } else {
193 result
194 };
195
196 match &result {
197 HookResult::Abort { .. }
198 | HookResult::Suspend { .. }
199 | HookResult::Deny { .. } => {
200 if honor_control_decisions {
201 report.outcome.decision = result;
202 return report;
203 }
204 }
205 HookResult::Ask => {
206 deferred_ask |= honor_control_decisions;
209 }
210 HookResult::InjectContext { text } => {
211 report.outcome.injected_contexts.push(text.clone());
212 }
213 HookResult::Mutated => {
214 if matches!(report.outcome.decision, HookResult::Continue) {
215 report.outcome.decision = HookResult::Mutated;
216 }
217 }
218 HookResult::Allow => report.outcome.decision = HookResult::Allow,
219 HookResult::Continue => {}
220 HookResult::WithContext { .. } => unreachable!("context results are unwrapped"),
221 }
222 }
223 if deferred_ask {
224 if complete_operation_checks {
225 pending_composite_ask = true;
227 } else {
228 report.outcome.decision = HookResult::Ask;
229 return report;
230 }
231 }
232 }
233
234 if pending_composite_ask {
235 report.outcome.decision = HookResult::Ask;
236 }
237 report
238 }
239
240 pub fn has_hooks_for(&self, point: AgentHookPoint) -> bool {
241 self.hooks.iter().any(|hook| hook.point() == point)
242 }
243
244 pub fn len(&self) -> usize {
245 self.hooks.len()
246 }
247
248 pub fn is_empty(&self) -> bool {
249 self.hooks.is_empty()
250 }
251}
252
253fn unwrap_context_result(mut result: HookResult) -> (HookResult, Vec<String>) {
254 let mut contexts = Vec::new();
255 while let HookResult::WithContext {
256 result: inner,
257 text,
258 } = result
259 {
260 if !text.trim().is_empty() {
261 contexts.push(text);
262 }
263 result = *inner;
264 }
265 (result, contexts)
266}
267
268#[cfg(test)]
269mod tests {
270
271 #[tokio::test]
272 async fn combined_shell_ask_checks_later_native_output_policy() {
273 for name in ["write_stdin", "default::write_stdin"] {
274 for args in [
275 json!({"session_id":"exec", "chars":"yes"}),
276 json!({"session_id":"exec", "eof":true}),
277 json!({"session_id":"exec", "chars":"\u{3}"}),
278 ] {
279 for output in [
280 HookResult::Deny {
281 reason: "output denied".into(),
282 },
283 HookResult::Abort {
284 reason: "output aborted".into(),
285 },
286 HookResult::Suspend {
287 reason: "output suspended".into(),
288 },
289 HookResult::Continue,
290 HookResult::Allow,
291 HookResult::Mutated,
292 ] {
293 for primary_matcher in ["^(BashInput|KillShell)$", "^write_stdin$", ".*"] {
294 let mut dispatcher = HookDispatcher::new();
295 let seen = Arc::new(Mutex::new(Vec::new()));
296 for (matcher, result) in [
297 (primary_matcher, HookResult::Ask),
298 ("^BashOutput$", output.clone()),
299 ] {
300 dispatcher.register(Arc::new(RecordingHook {
301 point: AgentHookPoint::BeforeToolExecution,
302 matcher: regex::Regex::new(matcher).unwrap(),
303 seen: seen.clone(),
304 result,
305 }));
306 }
307 let expected = if matches!(
308 output,
309 HookResult::Deny { .. }
310 | HookResult::Abort { .. }
311 | HookResult::Suspend { .. }
312 ) {
313 output.clone()
314 } else {
315 HookResult::Ask
316 };
317 let report = dispatcher
318 .run_hooks_with_resolved_shell(
319 AgentHookPoint::BeforeToolExecution,
320 &HookPayload::ToolExecution {
321 tool_name: name.into(),
322 tool_call_id: "combined-ask".into(),
323 parsed_args: args.clone(),
324 },
325 &Session::new("combined-ask", "model"),
326 Some("write_stdin"),
327 Some(&args),
328 )
329 .await;
330 assert_eq!(report.outcome.decision, expected, "{name}: {args}");
331 assert_eq!(
332 report.executions.len(),
333 2,
334 "distinct handlers must both run"
335 );
336 assert!(
337 matches!(&seen.lock().unwrap()[1], HookPayload::ToolExecution { tool_name, .. } if tool_name == "BashOutput")
338 );
339 }
340 }
341 }
342 }
343 for (name, args) in [
345 ("BashInput", json!({"bash_id":"legacy", "input":"yes"})),
346 ("KillShell", json!({"bash_id":"legacy"})),
347 ("exec_command", json!({"cmd":"echo yes"})),
348 ("write_stdin", json!({"session_id":"exec", "chars":""})),
349 (
350 "vendor::write_stdin",
351 json!({"session_id":"foreign", "chars":"yes"}),
352 ),
353 ] {
354 let mut dispatcher = HookDispatcher::new();
355 let seen = Arc::new(Mutex::new(Vec::new()));
356 for result in [
357 HookResult::Ask,
358 HookResult::Deny {
359 reason: "must remain skipped".into(),
360 },
361 ] {
362 dispatcher.register(Arc::new(RecordingHook {
363 point: AgentHookPoint::BeforeToolExecution,
364 matcher: regex::Regex::new(".*").unwrap(),
365 seen: seen.clone(),
366 result,
367 }));
368 }
369 let report = dispatcher
370 .run_hooks_with_resolved_shell(
371 AgentHookPoint::BeforeToolExecution,
372 &HookPayload::ToolExecution {
373 tool_name: name.into(),
374 tool_call_id: "ordinary-ask".into(),
375 parsed_args: args.clone(),
376 },
377 &Session::new("ordinary-ask", "model"),
378 Some(name),
379 Some(&args),
380 )
381 .await;
382 assert_eq!(report.outcome.decision, HookResult::Ask, "{name}");
383 assert_eq!(report.executions.len(), 1, "{name}");
384 assert_eq!(seen.lock().unwrap().len(), 1);
385 }
386 for name in ["write_stdin", "default::write_stdin"] {
388 let mut dispatcher = HookDispatcher::new();
389 let seen = Arc::new(Mutex::new(Vec::new()));
390 for result in [
391 HookResult::Ask,
392 HookResult::Deny {
393 reason: "post-tool skipped".into(),
394 },
395 ] {
396 dispatcher.register(Arc::new(RecordingHook {
397 point: AgentHookPoint::AfterToolExecution,
398 matcher: regex::Regex::new(".*").unwrap(),
399 seen: seen.clone(),
400 result,
401 }));
402 }
403 let report = dispatcher
404 .run_hooks_with_resolved_shell(
405 AgentHookPoint::AfterToolExecution,
406 &HookPayload::ToolResult {
407 tool_name: name.into(),
408 tool_call_id: "post-ask".into(),
409 outcome: bamboo_domain::HookToolOutcome {
410 success: true,
411 result: Some("done".into()),
412 error: None,
413 needs_human: false,
414 duration_ms: 1,
415 },
416 },
417 &Session::new("post-ask", "model"),
418 Some("write_stdin"),
419 Some(&json!({"session_id":"exec", "chars":"yes"})),
420 )
421 .await;
422 assert_eq!(report.outcome.decision, HookResult::Ask);
423 assert_eq!(report.executions.len(), 1);
424 }
425 }
426 use super::*;
427 use async_trait::async_trait;
428 use serde_json::json;
429 use std::sync::Mutex;
430
431 struct RecordingHook {
432 point: AgentHookPoint,
433 matcher: regex::Regex,
434 seen: Arc<Mutex<Vec<HookPayload>>>,
435 result: HookResult,
436 }
437
438 #[async_trait]
439 impl AgentHook for RecordingHook {
440 fn point(&self) -> AgentHookPoint {
441 self.point
442 }
443 fn matches(&self, payload: &HookPayload) -> bool {
444 match payload {
445 HookPayload::ToolExecution { tool_name, .. }
446 | HookPayload::ToolResult { tool_name, .. } => self.matcher.is_match(tool_name),
447 _ => false,
448 }
449 }
450 async fn run(
451 &self,
452 _point: AgentHookPoint,
453 payload: &HookPayload,
454 _session: &Session,
455 ) -> HookResult {
456 self.seen.lock().unwrap().push(payload.clone());
457 self.result.clone()
458 }
459 }
460
461 struct LegacyOperationPolicyHook {
462 matcher: regex::Regex,
463 seen: Arc<Mutex<Vec<HookPayload>>>,
464 output_result: HookResult,
465 primary_result: HookResult,
466 }
467
468 #[async_trait]
469 impl AgentHook for LegacyOperationPolicyHook {
470 fn point(&self) -> AgentHookPoint {
471 AgentHookPoint::BeforeToolExecution
472 }
473
474 fn matches(&self, payload: &HookPayload) -> bool {
475 matches!(payload, HookPayload::ToolExecution { tool_name, .. }
476 if self.matcher.is_match(tool_name))
477 }
478
479 async fn run(&self, _: AgentHookPoint, payload: &HookPayload, _: &Session) -> HookResult {
480 self.seen.lock().unwrap().push(payload.clone());
481 if matches!(payload, HookPayload::ToolExecution { tool_name, .. } if tool_name == "BashOutput")
482 {
483 self.output_result.clone()
484 } else {
485 self.primary_result.clone()
486 }
487 }
488 }
489
490 #[tokio::test]
491 async fn legacy_only_overlap_output_deny_precedes_input_ask() {
492 for args in [
493 json!({"session_id":"exec", "chars":"yes"}),
494 json!({"session_id":"exec", "eof":true}),
495 json!({"session_id":"exec", "chars":char::from(3).to_string()}),
496 ] {
497 for output_result in [
498 HookResult::Deny {
499 reason: "output remains denied".into(),
500 },
501 HookResult::Continue,
502 HookResult::Allow,
503 ] {
504 let mut dispatcher = HookDispatcher::new();
505 let seen = Arc::new(Mutex::new(Vec::new()));
506 let expected = if matches!(output_result, HookResult::Deny { .. }) {
507 output_result.clone()
508 } else {
509 HookResult::Ask
510 };
511 dispatcher.register(Arc::new(LegacyOperationPolicyHook {
512 matcher: regex::Regex::new("^(BashInput|KillShell|BashOutput)$").unwrap(),
513 seen: seen.clone(),
514 primary_result: HookResult::Ask,
515 output_result,
516 }));
517 let report = dispatcher
518 .run_hooks_with_resolved_shell(
519 AgentHookPoint::BeforeToolExecution,
520 &HookPayload::ToolExecution {
521 tool_name: "write_stdin".into(),
522 tool_call_id: "ask-before-output".into(),
523 parsed_args: args.clone(),
524 },
525 &Session::new("ask-before-output", "model"),
526 Some("write_stdin"),
527 Some(&args),
528 )
529 .await;
530 assert_eq!(report.outcome.decision, expected);
531 assert_eq!(report.executions.len(), 2);
532 assert!(
533 matches!(&seen.lock().unwrap()[1], HookPayload::ToolExecution {tool_name,..} if tool_name == "BashOutput")
534 );
535 }
536 }
537 }
538
539 #[tokio::test]
540 async fn legacy_only_overlap_evaluates_output_policy_after_input_eof_or_kill() {
541 for (input, primary_name) in [
542 (json!({"session_id":"exec", "chars":"yes\n"}), "BashInput"),
543 (json!({"session_id":"exec", "eof":true}), "BashInput"),
544 (json!({"session_id":"exec", "chars":"\u{3}"}), "KillShell"),
545 ] {
546 for output_result in [
547 HookResult::Deny {
548 reason: "legacy output policy".into(),
549 },
550 HookResult::Ask,
551 HookResult::Allow,
552 ] {
553 let mut dispatcher = HookDispatcher::new();
554 let seen = Arc::new(Mutex::new(Vec::new()));
555 for matcher in [
556 "^default::write_stdin$",
557 "^write_stdin$",
558 ".*",
559 "^(write_stdin|BashInput|KillShell|BashOutput)$",
560 ] {
561 dispatcher.register(Arc::new(RecordingHook {
562 point: AgentHookPoint::BeforeToolExecution,
563 matcher: regex::Regex::new(matcher).unwrap(),
564 seen: seen.clone(),
565 result: HookResult::Continue,
566 }));
567 }
568 dispatcher.register(Arc::new(LegacyOperationPolicyHook {
569 matcher: regex::Regex::new("^(BashInput|KillShell|BashOutput)$").unwrap(),
570 seen: seen.clone(),
571 output_result: output_result.clone(),
572 primary_result: HookResult::Continue,
573 }));
574 let payload = HookPayload::ToolExecution {
575 tool_name: "default::write_stdin".into(),
576 tool_call_id: "overlap-call".into(),
577 parsed_args: input.clone(),
578 };
579 let report = dispatcher
580 .run_hooks_with_resolved_shell(
581 AgentHookPoint::BeforeToolExecution,
582 &payload,
583 &Session::new("overlap", "model"),
584 Some("write_stdin"),
585 Some(&input),
586 )
587 .await;
588 let expected = if matches!(output_result, HookResult::Allow) {
589 HookResult::Continue
590 } else {
591 output_result
592 };
593 assert_eq!(report.outcome.decision, expected);
594 assert_eq!(report.executions.len(), 6);
595 let seen = seen.lock().unwrap();
596 let names: Vec<_> = seen
597 .iter()
598 .map(|payload| match payload {
599 HookPayload::ToolExecution { tool_name, .. } => tool_name.as_str(),
600 _ => unreachable!(),
601 })
602 .collect();
603 assert_eq!(
604 names,
605 [
606 "default::write_stdin",
607 "write_stdin",
608 "default::write_stdin",
609 "write_stdin",
610 primary_name,
611 "BashOutput"
612 ]
613 );
614 assert!(
615 matches!(&seen[5], HookPayload::ToolExecution { parsed_args, .. }
616 if parsed_args == &json!({"bash_id":"exec"}))
617 );
618 }
619 }
620 }
621
622 #[tokio::test]
623 async fn legacy_shell_policy_applies_without_duplicate_primary_or_wildcard_hooks() {
624 for control in [
625 HookResult::Deny {
626 reason: "legacy policy".into(),
627 },
628 HookResult::Ask,
629 ] {
630 let mut dispatcher = HookDispatcher::new();
631 let seen = Arc::new(Mutex::new(Vec::new()));
632 for (matcher, result) in [
633 ("^exec_command$", HookResult::Allow),
634 (".*", HookResult::Continue),
635 ("^(exec_command|Bash)$", HookResult::Continue),
636 ("^Bash$", control.clone()),
637 ] {
638 dispatcher.register(Arc::new(RecordingHook {
639 point: AgentHookPoint::BeforeToolExecution,
640 matcher: regex::Regex::new(matcher).unwrap(),
641 seen: seen.clone(),
642 result,
643 }));
644 }
645 let payload = HookPayload::ToolExecution {
646 tool_name: "exec_command".into(),
647 tool_call_id: "exec-call".into(),
648 parsed_args: json!({"cmd":"cargo test"}),
649 };
650 let report = dispatcher
651 .run_hooks(
652 AgentHookPoint::BeforeToolExecution,
653 &payload,
654 &Session::new("test", "model"),
655 )
656 .await;
657 assert_eq!(report.outcome.decision, control);
658 assert_eq!(report.executions.len(), 4);
659 let seen = seen.lock().unwrap();
660 assert_eq!(&seen[..3], &[payload.clone(), payload.clone(), payload]);
661 assert!(
662 matches!(&seen[3], HookPayload::ToolExecution { tool_name, parsed_args, .. }
663 if tool_name == "Bash" && parsed_args["command"] == "cargo test" && parsed_args.get("cmd").is_none())
664 );
665 }
666 }
667
668 #[tokio::test]
669 async fn resolved_shell_aliases_preserve_primary_spelling_and_run_fallbacks_once() {
670 for (alias, resolved, legacy, input) in [
671 (
672 "default::exec_command",
673 "exec_command",
674 "Bash",
675 json!({"cmd":"cargo test"}),
676 ),
677 (
678 "default::write_stdin",
679 "write_stdin",
680 "BashInput",
681 json!({"session_id":"exec", "chars":"yes\n"}),
682 ),
683 ] {
684 for (point, payload) in [
685 (
686 AgentHookPoint::BeforeToolExecution,
687 HookPayload::ToolExecution {
688 tool_name: alias.into(),
689 tool_call_id: "alias-call".into(),
690 parsed_args: input.clone(),
691 },
692 ),
693 (
694 AgentHookPoint::AfterToolExecution,
695 HookPayload::ToolResult {
696 tool_name: alias.into(),
697 tool_call_id: "alias-call".into(),
698 outcome: bamboo_domain::HookToolOutcome {
699 success: true,
700 result: Some("done".into()),
701 error: None,
702 needs_human: false,
703 duration_ms: 1,
704 },
705 },
706 ),
707 ] {
708 let mut dispatcher = HookDispatcher::new();
709 let seen = Arc::new(Mutex::new(Vec::new()));
710 for matcher in [
711 format!("^{alias}$"),
712 ".*".into(),
713 format!("^({alias}|{resolved}|{legacy})$"),
714 format!("^{resolved}$"),
715 format!("^{legacy}$"),
716 ] {
717 dispatcher.register(Arc::new(RecordingHook {
718 point,
719 matcher: regex::Regex::new(&matcher).unwrap(),
720 seen: seen.clone(),
721 result: HookResult::Continue,
722 }));
723 }
724 let report = dispatcher
725 .run_hooks_with_resolved_shell(
726 point,
727 &payload,
728 &Session::new("test", "model"),
729 Some(resolved),
730 Some(&input),
731 )
732 .await;
733 assert_eq!(report.executions.len(), 5);
734 let seen = seen.lock().unwrap();
735 assert_eq!(&seen[..3], &[payload.clone(), payload.clone(), payload]);
736 let name = |payload: &HookPayload| match payload {
737 HookPayload::ToolExecution { tool_name, .. }
738 | HookPayload::ToolResult { tool_name, .. } => tool_name.clone(),
739 _ => panic!("expected tool payload"),
740 };
741 assert_eq!(name(&seen[3]), resolved);
742 assert_eq!(name(&seen[4]), legacy);
743 }
744 }
745 }
746
747 #[tokio::test]
748 async fn combined_continuations_apply_output_restrictions_without_duplicate_primary_hooks() {
749 for (input, primary) in [
750 (
751 json!({"session_id":"exec", "chars":"yes\n", "eof":false}),
752 "BashInput",
753 ),
754 (json!({"session_id":"exec", "eof":true}), "BashInput"),
755 (json!({"session_id":"exec", "chars":"\u{3}"}), "KillShell"),
756 ] {
757 for control in [
758 HookResult::Deny {
759 reason: "output policy".into(),
760 },
761 HookResult::Ask,
762 ] {
763 for (point, payload) in [
764 (
765 AgentHookPoint::BeforeToolExecution,
766 HookPayload::ToolExecution {
767 tool_name: "default::write_stdin".into(),
768 tool_call_id: "combined-call".into(),
769 parsed_args: input.clone(),
770 },
771 ),
772 (
773 AgentHookPoint::AfterToolExecution,
774 HookPayload::ToolResult {
775 tool_name: "default::write_stdin".into(),
776 tool_call_id: "combined-call".into(),
777 outcome: bamboo_domain::HookToolOutcome {
778 success: true,
779 result: Some("output".into()),
780 error: None,
781 needs_human: false,
782 duration_ms: 1,
783 },
784 },
785 ),
786 ] {
787 let mut dispatcher = HookDispatcher::new();
788 let seen = Arc::new(Mutex::new(Vec::new()));
789 for (matcher, result) in [
790 ("^default::write_stdin$", HookResult::Continue),
791 (".*", HookResult::Continue),
792 ("^(BashInput|KillShell|BashOutput)$", HookResult::Continue),
793 ("^write_stdin$", HookResult::Continue),
794 ("^BashOutput$", control.clone()),
795 ] {
796 dispatcher.register(Arc::new(RecordingHook {
797 point,
798 matcher: regex::Regex::new(matcher).unwrap(),
799 seen: seen.clone(),
800 result,
801 }));
802 }
803 let report = dispatcher
804 .run_hooks_with_resolved_shell(
805 point,
806 &payload,
807 &Session::new("test", "model"),
808 Some("write_stdin"),
809 Some(&input),
810 )
811 .await;
812 assert_eq!(report.outcome.decision, control);
813 assert_eq!(report.executions.len(), 6);
814 let seen = seen.lock().unwrap();
815 assert_eq!(&seen[..2], &[payload.clone(), payload]);
816 let name = |payload: &HookPayload| match payload {
817 HookPayload::ToolExecution { tool_name, .. }
818 | HookPayload::ToolResult { tool_name, .. } => tool_name.clone(),
819 _ => unreachable!(),
820 };
821 assert_eq!(name(&seen[2]), primary);
822 assert_eq!(name(&seen[3]), "BashOutput");
823 assert_eq!(name(&seen[4]), "write_stdin");
824 assert_eq!(name(&seen[5]), "BashOutput");
825 if let HookPayload::ToolExecution { parsed_args, .. } = &seen[5] {
826 assert_eq!(parsed_args, &json!({"bash_id":"exec"}));
827 }
828 }
829 }
830 }
831 }
832
833 #[tokio::test]
834 async fn secondary_output_allow_preserves_context_without_granting_input_authority() {
835 for (matcher, chars, expected) in [
836 ("^BashOutput$", "yes\n", HookResult::Continue),
837 ("^BashInput$", "yes\n", HookResult::Allow),
838 ("^write_stdin$", "yes\n", HookResult::Allow),
839 ("^BashOutput$", "", HookResult::Allow),
840 ] {
841 let mut dispatcher = HookDispatcher::new();
842 let seen = Arc::new(Mutex::new(Vec::new()));
843 dispatcher.register(Arc::new(RecordingHook {
844 point: AgentHookPoint::BeforeToolExecution,
845 matcher: regex::Regex::new(matcher).unwrap(),
846 seen: seen.clone(),
847 result: HookResult::WithContext {
848 result: Box::new(HookResult::Allow),
849 text: "policy context".into(),
850 },
851 }));
852 let payload = HookPayload::ToolExecution {
853 tool_name: "write_stdin".into(),
854 tool_call_id: "allow-call".into(),
855 parsed_args: json!({"session_id":"exec", "chars":chars}),
856 };
857 let report = dispatcher
858 .run_hooks(
859 AgentHookPoint::BeforeToolExecution,
860 &payload,
861 &Session::new("test", "model"),
862 )
863 .await;
864 assert_eq!(report.outcome.decision, expected);
865 assert_eq!(report.outcome.injected_contexts, ["policy context"]);
866 assert_eq!(seen.lock().unwrap().len(), 1);
867 }
868 }
869
870 #[tokio::test]
871 async fn legacy_post_tool_hooks_resolve_stdin_operation_from_original_call() {
872 use bamboo_agent_core::{FunctionCall, Message, ToolCall};
873 for (input, legacy_name) in [
874 (json!({"session_id":"exec", "chars":""}), "BashOutput"),
875 (json!({"session_id":"exec", "chars":"yes\n"}), "BashInput"),
876 (
877 json!({"session_id":"exec", "chars":"", "eof":true}),
878 "BashInput",
879 ),
880 (json!({"session_id":"exec", "chars":"\u{3}"}), "KillShell"),
881 ] {
882 let mut session = Session::new("test", "model");
883 session.messages.push(Message::assistant(
884 "",
885 Some(vec![ToolCall {
886 id: "stdin-call".into(),
887 tool_type: "function".into(),
888 function: FunctionCall {
889 name: "write_stdin".into(),
890 arguments: input.to_string(),
891 },
892 }]),
893 ));
894 let payload = HookPayload::ToolResult {
895 tool_name: "write_stdin".into(),
896 tool_call_id: "stdin-call".into(),
897 outcome: bamboo_domain::HookToolOutcome {
898 success: true,
899 result: Some("done".into()),
900 error: None,
901 needs_human: false,
902 duration_ms: 1,
903 },
904 };
905 let seen = Arc::new(Mutex::new(Vec::new()));
906 let mut dispatcher = HookDispatcher::new();
907 dispatcher.register(Arc::new(RecordingHook {
908 point: AgentHookPoint::AfterToolExecution,
909 matcher: regex::Regex::new(&format!("^{legacy_name}$")).unwrap(),
910 seen: seen.clone(),
911 result: HookResult::Ask,
912 }));
913 let report = dispatcher
914 .run_hooks(AgentHookPoint::AfterToolExecution, &payload, &session)
915 .await;
916 assert_eq!(report.outcome.decision, HookResult::Ask);
917 assert!(
918 matches!(&seen.lock().unwrap()[0], HookPayload::ToolResult { tool_name, .. } if tool_name == legacy_name)
919 );
920 }
921 }
922}