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