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)]
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    /// Register a hook. Lower numeric priorities run first.
61    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    /// Clone this registry and append configured handlers from a frozen config
69    /// snapshot. The original registry remains reusable by future runs.
70    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    /// Preserve the original native hook payload while allowing a resolved
91    /// builtin shell identity and its legacy projection as fallback matches.
92    /// Foreign identities are never normalized by this compatibility seam.
93    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    /// Run all matching handlers without allowing a control decision to
113    /// suppress later observer handlers.
114    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 = &registered.hook;
163            if hook.point() != point {
164                continue;
165            }
166            // Original/resolved-name and wildcard handlers run once. A legacy-
167            // only matcher must evaluate each distinct operation it covers:
168            // stdin/kill also reads output, whose policy can independently deny.
169            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                    // An output-only legacy policy may restrict the read side of a
204                    // combined continuation, but cannot grant stdin/kill authority.
205                    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                        // Approval of the input side cannot override an unchecked
221                        // output denial from this same legacy-only handler.
222                        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                    // A separate handler may still restrict the output side.
240                    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    /// Native control hooks are conservatively resumable unless their host
255    /// implementation explicitly declares the non-suspending contract.
256    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        // These single-operation calls retain the existing Ask short circuit.
381        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        // A combined post-tool payload keeps its prior Ask short circuit.
424        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;