Skip to main content

bamboo_hooks/
dispatcher.rs

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/// Aggregate control and context output from one lifecycle seam.
10#[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/// One completed handler execution, returned to the engine for observability.
28#[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/// Complete dispatch result for one lifecycle seam.
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
38pub struct HookDispatchReport {
39    pub outcome: HookRunOutcome,
40    pub executions: Vec<HookExecution>,
41}
42
43/// Registry and deterministic dispatcher for programmatic and configured hooks.
44#[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    /// Register a hook. Lower numeric priorities run first.
55    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    /// Clone this registry and append configured handlers from a frozen config
61    /// snapshot. The original registry remains reusable by future runs.
62    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    /// Preserve the original native hook payload while allowing a resolved
83    /// builtin shell identity and its legacy projection as fallback matches.
84    /// Foreign identities are never normalized by this compatibility seam.
85    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    /// Run all matching handlers without allowing a control decision to
105    /// suppress later observer handlers.
106    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            // Original/resolved-name and wildcard handlers run once. A legacy-
158            // only matcher must evaluate each distinct operation it covers:
159            // stdin/kill also reads output, whose policy can independently deny.
160            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                    // An output-only legacy policy may restrict the read side of a
190                    // combined continuation, but cannot grant stdin/kill authority.
191                    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                        // Approval of the input side cannot override an unchecked
207                        // output denial from this same legacy-only handler.
208                        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                    // A separate handler may still restrict the output side.
226                    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        // These single-operation calls retain the existing Ask short circuit.
344        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        // A combined post-tool payload keeps its prior Ask short circuit.
387        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}