Skip to main content

everruns_core/
hook_adapter.rs

1// User-defined hooks: adapters that bridge `UserHookSpec` to the runtime's
2// per-event hook traits.
3//
4// `HookAdapterBuilder` is the single place specs become `Arc<dyn …Hook>`.
5// Capability authors return data (`Vec<UserHookSpec>`); this module owns the
6// translation to executable adapters so timeout/output/sandbox limits stay
7// centrally enforced.
8//
9// Wired adapters: `PreToolUseHookAdapter` (with `build_pre_tool_use_hooks`)
10// and `PostToolUseHookAdapter` (with `build_post_tool_use_hooks`). Adapters
11// for `session_*`, `user_prompt_submit`, `turn_end` land alongside their
12// respective runtime wire-in PRs.
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use serde_json::json;
18
19use crate::atoms::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
20use crate::hook_executor::{
21    BashHookDispatcher, BashHookExecutor, ExecutorOpts, HookExecutor, HookPayload,
22};
23use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
24use crate::traits::ToolContext;
25use crate::user_hook_types::{
26    ExecutorSpec, HookEvent, HookOutcome, HookSource, OnError, UserHookSpec,
27};
28
29// ============================================================================
30// PostToolUseHookAdapter
31// ============================================================================
32
33/// Adapter that implements `PostToolExecHook` for a single `UserHookSpec`
34/// whose event is `post_tool_use`.
35///
36/// Lifecycle per `after_exec` call:
37///
38/// 1. Skip when the matcher rejects the (tool_name, arguments) pair.
39/// 2. Build a `HookPayload` from the tool call + result.
40/// 3. Run the executor with the spec's `timeout_ms`.
41/// 4. On `Allow`, do nothing.
42/// 5. On `Mutate { patch }`, apply the patch to the `ToolResult`:
43///    - `patch.result` overwrites `result.result`
44///    - `patch.error` overwrites `result.error`
45///    - `patch.additional_context` is appended as a `hook_context` field on
46///      `result.result` so the model sees the hook's commentary.
47/// 6. On `Block`, log a warning — `post_tool_use` cannot block by spec.
48/// 7. On `Error`, apply the spec's `on_error` policy. `Block` policy
49///    overwrites `result.error`; `Warn`/`Allow` just log.
50pub struct PostToolUseHookAdapter {
51    spec: UserHookSpec,
52    executor: Arc<dyn HookExecutor>,
53    opts: ExecutorOpts,
54    /// Resolved once at construction so every payload/log line carries a
55    /// stable, unique id even for specs that omit an explicit `id` (the
56    /// chain position disambiguates them). See `finalize_hook_specs`.
57    hook_id: crate::user_hook_types::HookId,
58}
59
60impl PostToolUseHookAdapter {
61    pub fn new(spec: UserHookSpec, executor: Arc<dyn HookExecutor>) -> Self {
62        Self::with_index(spec, executor, 0)
63    }
64
65    pub fn with_index(spec: UserHookSpec, executor: Arc<dyn HookExecutor>, index: usize) -> Self {
66        let opts = ExecutorOpts {
67            timeout_ms: spec.timeout_ms,
68            max_output_bytes: 64 * 1024,
69        };
70        let hook_id = spec.resolve_id(index);
71        Self {
72            spec,
73            executor,
74            opts,
75            hook_id,
76        }
77    }
78
79    fn build_payload(
80        &self,
81        tool_call: &ToolCall,
82        result: &ToolResult,
83        context: &ToolContext,
84    ) -> HookPayload {
85        let success = result.error.is_none();
86        HookPayload {
87            event: HookEvent::PostToolUse,
88            hook_id: self.hook_id.clone(),
89            session_id: context.session_id,
90            turn_id: None,
91            org_id: context.org_id,
92            agent_id: None,
93            ts: chrono::Utc::now().to_rfc3339(),
94            data: json!({
95                "tool_name": tool_call.name,
96                "tool_call_id": tool_call.id,
97                "arguments": tool_call.arguments,
98                "result": result.result,
99                "error": result.error,
100                "success": success,
101            }),
102        }
103    }
104}
105
106#[async_trait]
107impl PostToolExecHook for PostToolUseHookAdapter {
108    async fn after_exec(
109        &self,
110        tool_call: &ToolCall,
111        _tool_def: &ToolDefinition,
112        result: &mut ToolResult,
113        context: &ToolContext,
114    ) {
115        if !self
116            .spec
117            .matcher
118            .matches(&tool_call.name, &tool_call.arguments)
119        {
120            return;
121        }
122
123        let payload = self.build_payload(tool_call, result, context);
124        let outcome = self.executor.run(payload, &self.opts).await;
125        let hook_id = &self.hook_id;
126
127        match outcome {
128            HookOutcome::Allow => {}
129            HookOutcome::Mutate { patch, .. } => apply_post_tool_use_patch(result, &patch),
130            HookOutcome::Block { reason, .. } => {
131                tracing::warn!(
132                    hook_id = %hook_id.as_str(),
133                    tool_call_id = %tool_call.id,
134                    reason = %reason,
135                    "post_tool_use hook returned Block, which is not allowed for this event; ignoring"
136                );
137            }
138            HookOutcome::Error { message } => match self.spec.on_error {
139                OnError::Block => {
140                    // post_tool_use cannot block execution that already
141                    // happened, but we can replace the result with an
142                    // error so the model sees the failure.
143                    result.error = Some(format!("hook {}: {}", hook_id.as_str(), message));
144                    tracing::warn!(
145                        hook_id = %hook_id.as_str(),
146                        tool_call_id = %tool_call.id,
147                        message = %message,
148                        "post_tool_use hook errored with on_error=block; replacing tool result with error"
149                    );
150                }
151                OnError::Warn => {
152                    tracing::warn!(
153                        hook_id = %hook_id.as_str(),
154                        tool_call_id = %tool_call.id,
155                        message = %message,
156                        "post_tool_use hook errored"
157                    );
158                }
159                OnError::Allow => {}
160            },
161        }
162    }
163}
164
165/// Apply a `post_tool_use` `Mutate` patch to a `ToolResult` in place.
166///
167/// Patch shape (any subset, see `specs/user-hooks.md`):
168///   { "result": …, "error": …, "additional_context": "..." }
169fn apply_post_tool_use_patch(result: &mut ToolResult, patch: &serde_json::Value) {
170    if let Some(new_result) = patch.get("result") {
171        result.result = Some(new_result.clone());
172    }
173    if let Some(new_error) = patch.get("error").and_then(|v| v.as_str()) {
174        result.error = Some(new_error.to_string());
175    }
176    if let Some(ctx) = patch.get("additional_context").and_then(|v| v.as_str()) {
177        // Append as `hook_context` so the model sees the hook's narration.
178        match result.result.as_mut() {
179            Some(serde_json::Value::Object(map)) => {
180                map.insert("hook_context".to_string(), json!(ctx));
181            }
182            Some(other) => {
183                let prior = other.clone();
184                *other = json!({ "value": prior, "hook_context": ctx });
185            }
186            None => {
187                result.result = Some(json!({ "hook_context": ctx }));
188            }
189        }
190    }
191}
192
193// ============================================================================
194// Factory
195// ============================================================================
196
197/// Build `PostToolExecHook` adapters from every `UserHookSpec` in `specs`
198/// whose event is `PostToolUse`. `dispatcher` is the bash backend used for
199/// every spec whose executor is `Bash`; other backends will land alongside
200/// their own dispatchers.
201///
202/// Specs that fail `validate()` are silently dropped with a warning log so
203/// one bad spec doesn't take down the entire chain.
204pub fn build_post_tool_use_hooks(
205    specs: &[UserHookSpec],
206    dispatcher: Arc<dyn BashHookDispatcher>,
207) -> Vec<Arc<dyn PostToolExecHook>> {
208    let mut out: Vec<Arc<dyn PostToolExecHook>> = Vec::new();
209    for (index, spec) in specs.iter().enumerate() {
210        if spec.event != HookEvent::PostToolUse {
211            continue;
212        }
213        if let Err(e) = spec.validate() {
214            let hook_id_for_log = spec.resolve_id(index);
215            tracing::warn!(
216                hook_id = %hook_id_for_log.as_str(),
217                error = %e,
218                "skipping invalid post_tool_use hook spec"
219            );
220            continue;
221        }
222        let executor: Arc<dyn HookExecutor> = match &spec.executor {
223            ExecutorSpec::Bash { command, env } => Arc::new(BashHookExecutor::with_dispatcher(
224                command.clone(),
225                env.clone(),
226                dispatcher.clone(),
227            )),
228        };
229        out.push(Arc::new(PostToolUseHookAdapter::with_index(
230            spec.clone(),
231            executor,
232            index,
233        )));
234    }
235    out
236}
237
238/// Finalize contributed hook specs before they become adapters.
239///
240/// Each entry in `contributions` is `(capability_id, specs)` as returned by a
241/// single capability's `user_hooks_with_config`. This function:
242///
243/// 1. **Stamps the source namespace.** Specs from any capability other than
244///    the user-facing `user_hooks` capability are stamped
245///    `HookSource::Capability { capability_id }` so their resolved `HookId`
246///    lands in the `{capability_id}:` namespace. (The `user_hooks` capability
247///    already stamps its own entries `UserConfig`.) This guarantees correct
248///    namespacing regardless of whether the capability author set `source` —
249///    per the `HookSource` doc contract, the runtime owns this stamp.
250/// 2. **Assigns a stable default id** (`{event}_{idx}`, indexed within the
251///    contributing capability) to any spec that omits an explicit `id`, so
252///    every hook is individually addressable and mutable.
253/// 3. **Applies `disabled_contributions`.** Any spec whose resolved `HookId`
254///    appears in `disabled` is dropped, so operators can mute
255///    capability-contributed hooks (TM-HOOK-004).
256pub fn finalize_hook_specs(
257    contributions: Vec<(String, Vec<UserHookSpec>)>,
258    disabled: &[String],
259) -> Vec<UserHookSpec> {
260    let disabled: std::collections::HashSet<&str> = disabled.iter().map(String::as_str).collect();
261    let mut out: Vec<UserHookSpec> = Vec::new();
262    for (capability_id, specs) in contributions {
263        for (idx, mut spec) in specs.into_iter().enumerate() {
264            // The `user_hooks` capability stamps its own entries `UserConfig`
265            // (and assigns ids) in `parse_config`. Every other capability is a
266            // capability contribution; stamp it here so authors can't forget.
267            if capability_id != "user_hooks" {
268                spec.source = HookSource::Capability {
269                    capability_id: capability_id.clone(),
270                };
271                if spec.id.is_none() {
272                    spec.id = Some(format!("{}_{}", spec.event.as_str(), idx));
273                }
274            }
275            let resolved = spec.resolve_id(idx);
276            if disabled.contains(resolved.as_str()) {
277                tracing::info!(
278                    hook_id = %resolved.as_str(),
279                    "muting hook via disabled_contributions"
280                );
281                continue;
282            }
283            out.push(spec);
284        }
285    }
286    out
287}
288
289/// Convenience: classify a stable hook id from a spec source. Useful for
290/// audit logs that need the `{capability_id}:{name}` namespace.
291pub fn hook_id_namespace(spec: &UserHookSpec) -> &'static str {
292    match spec.source {
293        HookSource::UserConfig => "user",
294        HookSource::Capability { .. } => "capability",
295    }
296}
297
298// ============================================================================
299// PreToolUseHookAdapter
300// ============================================================================
301
302/// Adapter that implements `PreToolUseHook` for a single `UserHookSpec`
303/// whose event is `pre_tool_use`.
304///
305/// Lifecycle per `before_exec` call:
306///
307/// 1. Skip when the matcher rejects the (tool_name, arguments) pair —
308///    `Continue(tool_call)` with no mutation.
309/// 2. Build a `HookPayload` from the tool call.
310/// 3. Run the executor with the spec's `timeout_ms`.
311/// 4. On `Allow`, return `Continue(tool_call)` unchanged.
312/// 5. On `Mutate { patch }`, merge `patch.arguments` into
313///    `tool_call.arguments` and return `Continue`.
314/// 6. On `Block { reason, user_message }`, return `Block` — `ActAtom`
315///    short-circuits the tool call.
316/// 7. On `Error`, apply the spec's `on_error` policy:
317///    - `Block` → `Block { reason = "hook errored: …" }`
318///    - `Warn` / `Allow` → log + `Continue(tool_call)` unchanged.
319pub struct PreToolUseHookAdapter {
320    spec: UserHookSpec,
321    executor: Arc<dyn HookExecutor>,
322    opts: ExecutorOpts,
323    /// Resolved once at construction; see `PostToolUseHookAdapter::hook_id`.
324    hook_id: crate::user_hook_types::HookId,
325}
326
327impl PreToolUseHookAdapter {
328    pub fn new(spec: UserHookSpec, executor: Arc<dyn HookExecutor>) -> Self {
329        Self::with_index(spec, executor, 0)
330    }
331
332    pub fn with_index(spec: UserHookSpec, executor: Arc<dyn HookExecutor>, index: usize) -> Self {
333        let opts = ExecutorOpts {
334            timeout_ms: spec.timeout_ms,
335            max_output_bytes: 64 * 1024,
336        };
337        let hook_id = spec.resolve_id(index);
338        Self {
339            spec,
340            executor,
341            opts,
342            hook_id,
343        }
344    }
345
346    fn build_payload(&self, tool_call: &ToolCall, context: &ToolContext) -> HookPayload {
347        HookPayload {
348            event: HookEvent::PreToolUse,
349            hook_id: self.hook_id.clone(),
350            session_id: context.session_id,
351            turn_id: None,
352            org_id: context.org_id,
353            agent_id: None,
354            ts: chrono::Utc::now().to_rfc3339(),
355            data: json!({
356                "tool_name": tool_call.name,
357                "tool_call_id": tool_call.id,
358                "arguments": tool_call.arguments,
359            }),
360        }
361    }
362}
363
364#[async_trait]
365impl PreToolUseHook for PreToolUseHookAdapter {
366    async fn before_exec(
367        &self,
368        tool_call: ToolCall,
369        _tool_def: &ToolDefinition,
370        context: &ToolContext,
371    ) -> PreToolUseDecision {
372        if !self
373            .spec
374            .matcher
375            .matches(&tool_call.name, &tool_call.arguments)
376        {
377            return PreToolUseDecision::Continue(tool_call);
378        }
379
380        let payload = self.build_payload(&tool_call, context);
381        let outcome = self.executor.run(payload, &self.opts).await;
382        let hook_id = &self.hook_id;
383
384        match outcome {
385            HookOutcome::Allow => PreToolUseDecision::Continue(tool_call),
386            HookOutcome::Mutate { patch, .. } => {
387                let mutated = apply_pre_tool_use_patch(tool_call, &patch);
388                PreToolUseDecision::Continue(mutated)
389            }
390            HookOutcome::Block {
391                reason,
392                user_message,
393            } => PreToolUseDecision::Block {
394                tool_call,
395                reason,
396                user_message,
397            },
398            HookOutcome::Error { message } => match self.spec.on_error {
399                OnError::Block => PreToolUseDecision::Block {
400                    tool_call,
401                    reason: format!("hook {} errored: {}", hook_id.as_str(), message),
402                    user_message: None,
403                },
404                OnError::Warn => {
405                    tracing::warn!(
406                        hook_id = %hook_id.as_str(),
407                        tool_call_id = %tool_call.id,
408                        message = %message,
409                        "pre_tool_use hook errored"
410                    );
411                    PreToolUseDecision::Continue(tool_call)
412                }
413                OnError::Allow => PreToolUseDecision::Continue(tool_call),
414            },
415        }
416    }
417}
418
419/// Apply a `pre_tool_use` `Mutate` patch to a `ToolCall`. Only the
420/// `patch.arguments` object is honored; it is merged into the existing
421/// arguments, with patch keys winning on conflict. Non-object patches
422/// and missing fields are silently ignored.
423fn apply_pre_tool_use_patch(mut tool_call: ToolCall, patch: &serde_json::Value) -> ToolCall {
424    if let Some(new_args) = patch.get("arguments")
425        && let Some(new_obj) = new_args.as_object()
426    {
427        match tool_call.arguments.as_object_mut() {
428            Some(existing) => {
429                for (k, v) in new_obj {
430                    existing.insert(k.clone(), v.clone());
431                }
432            }
433            None => {
434                tool_call.arguments = serde_json::Value::Object(new_obj.clone());
435            }
436        }
437    }
438    tool_call
439}
440
441/// Build `PreToolUseHook` adapters from every `UserHookSpec` in `specs`
442/// whose event is `PreToolUse`. Specs that fail `validate()` are dropped
443/// with a warning log so one bad spec doesn't take down the chain.
444pub fn build_pre_tool_use_hooks(
445    specs: &[UserHookSpec],
446    dispatcher: Arc<dyn BashHookDispatcher>,
447) -> Vec<Arc<dyn PreToolUseHook>> {
448    let mut out: Vec<Arc<dyn PreToolUseHook>> = Vec::new();
449    for (index, spec) in specs.iter().enumerate() {
450        if spec.event != HookEvent::PreToolUse {
451            continue;
452        }
453        if let Err(e) = spec.validate() {
454            let hook_id_for_log = spec.resolve_id(index);
455            tracing::warn!(
456                hook_id = %hook_id_for_log.as_str(),
457                error = %e,
458                "skipping invalid pre_tool_use hook spec"
459            );
460            continue;
461        }
462        let executor: Arc<dyn HookExecutor> = match &spec.executor {
463            ExecutorSpec::Bash { command, env } => Arc::new(BashHookExecutor::with_dispatcher(
464                command.clone(),
465                env.clone(),
466                dispatcher.clone(),
467            )),
468        };
469        out.push(Arc::new(PreToolUseHookAdapter::with_index(
470            spec.clone(),
471            executor,
472            index,
473        )));
474    }
475    out
476}
477
478#[cfg(test)]
479mod pre_tool_use_tests {
480    use super::*;
481    use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
482    use serde_json::json;
483    use std::sync::Mutex;
484
485    fn make_spec(matcher: crate::user_hook_types::HookMatcher) -> UserHookSpec {
486        UserHookSpec {
487            id: Some("pre".into()),
488            event: HookEvent::PreToolUse,
489            matcher,
490            executor: ExecutorSpec::Bash {
491                command: "true".into(),
492                env: Default::default(),
493            },
494            timeout_ms: 5000,
495            on_error: OnError::Warn,
496            description: None,
497            source: HookSource::UserConfig,
498        }
499    }
500
501    fn make_tool_call() -> ToolCall {
502        ToolCall {
503            id: "call_x".into(),
504            name: "bash".into(),
505            arguments: json!({"command": "rm -rf /"}),
506        }
507    }
508
509    fn make_tool_def() -> ToolDefinition {
510        ToolDefinition::Builtin(BuiltinTool {
511            name: "bash".into(),
512            display_name: None,
513            description: "".into(),
514            parameters: json!({}),
515            policy: ToolPolicy::Auto,
516            category: None,
517            deferrable: DeferrablePolicy::Never,
518            hints: ToolHints::default(),
519            full_parameters: None,
520        })
521    }
522
523    struct ProgrammedExecutor {
524        outcome: HookOutcome,
525        calls: Mutex<Vec<HookPayload>>,
526    }
527
528    #[async_trait]
529    impl HookExecutor for ProgrammedExecutor {
530        fn kind(&self) -> &'static str {
531            "test"
532        }
533        async fn run(&self, payload: HookPayload, _opts: &ExecutorOpts) -> HookOutcome {
534            self.calls.lock().unwrap().push(payload);
535            self.outcome.clone()
536        }
537    }
538
539    fn programmed(outcome: HookOutcome) -> Arc<ProgrammedExecutor> {
540        Arc::new(ProgrammedExecutor {
541            outcome,
542            calls: Mutex::new(Vec::new()),
543        })
544    }
545
546    #[tokio::test]
547    async fn matcher_miss_skips_executor() {
548        let exec = programmed(HookOutcome::Allow);
549        let exec_arc: Arc<dyn HookExecutor> = exec.clone();
550        let matcher = crate::user_hook_types::HookMatcher {
551            tool_name: Some("edit_file".into()),
552            ..Default::default()
553        };
554        let adapter = PreToolUseHookAdapter::new(make_spec(matcher), exec_arc);
555        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
556
557        let decision = adapter
558            .before_exec(make_tool_call(), &make_tool_def(), &ctx)
559            .await;
560        assert!(matches!(decision, PreToolUseDecision::Continue(_)));
561        assert_eq!(exec.calls.lock().unwrap().len(), 0);
562    }
563
564    #[tokio::test]
565    async fn allow_outcome_returns_continue_unchanged() {
566        let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Allow);
567        let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
568        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
569        let original = make_tool_call();
570
571        let decision = adapter
572            .before_exec(original.clone(), &make_tool_def(), &ctx)
573            .await;
574        match decision {
575            PreToolUseDecision::Continue(tc) => assert_eq!(tc.arguments, original.arguments),
576            other => panic!("expected Continue, got {other:?}"),
577        }
578    }
579
580    #[tokio::test]
581    async fn block_outcome_propagates() {
582        let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Block {
583            reason: "denied".into(),
584            user_message: Some("nope".into()),
585        });
586        let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
587        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
588
589        let decision = adapter
590            .before_exec(make_tool_call(), &make_tool_def(), &ctx)
591            .await;
592        match decision {
593            PreToolUseDecision::Block {
594                reason,
595                user_message,
596                ..
597            } => {
598                assert_eq!(reason, "denied");
599                assert_eq!(user_message.as_deref(), Some("nope"));
600            }
601            other => panic!("expected Block, got {other:?}"),
602        }
603    }
604
605    #[tokio::test]
606    async fn mutate_patch_merges_arguments() {
607        let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Mutate {
608            patch: json!({ "arguments": { "command": "ls" } }),
609            reason: None,
610        });
611        let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
612        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
613
614        let decision = adapter
615            .before_exec(make_tool_call(), &make_tool_def(), &ctx)
616            .await;
617        match decision {
618            PreToolUseDecision::Continue(tc) => {
619                assert_eq!(tc.arguments["command"], "ls");
620            }
621            other => panic!("expected Continue, got {other:?}"),
622        }
623    }
624
625    #[tokio::test]
626    async fn error_with_on_error_block_returns_block() {
627        let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Error {
628            message: "boom".into(),
629        });
630        let mut spec = make_spec(Default::default());
631        spec.on_error = OnError::Block;
632        let adapter = PreToolUseHookAdapter::new(spec, exec_arc);
633        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
634
635        let decision = adapter
636            .before_exec(make_tool_call(), &make_tool_def(), &ctx)
637            .await;
638        match decision {
639            PreToolUseDecision::Block { reason, .. } => {
640                assert!(reason.contains("boom"), "{reason}");
641            }
642            other => panic!("expected Block, got {other:?}"),
643        }
644    }
645
646    #[tokio::test]
647    async fn error_with_on_error_warn_returns_continue() {
648        let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Error {
649            message: "boom".into(),
650        });
651        let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
652        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
653
654        let decision = adapter
655            .before_exec(make_tool_call(), &make_tool_def(), &ctx)
656            .await;
657        assert!(matches!(decision, PreToolUseDecision::Continue(_)));
658    }
659
660    #[test]
661    fn factory_filters_to_pre_tool_use_event() {
662        let specs = vec![
663            make_spec(Default::default()),
664            UserHookSpec {
665                event: HookEvent::PostToolUse,
666                ..make_spec(Default::default())
667            },
668        ];
669        struct NoopDispatcher;
670        #[async_trait]
671        impl BashHookDispatcher for NoopDispatcher {
672            async fn dispatch(
673                &self,
674                _payload: &HookPayload,
675                _command: &str,
676                _extra_env: &std::collections::BTreeMap<String, String>,
677                _opts: &ExecutorOpts,
678            ) -> Result<crate::hook_executor::BashExecOutput, String> {
679                Ok(crate::hook_executor::BashExecOutput {
680                    exit_code: 0,
681                    stdout: String::new(),
682                    stderr: String::new(),
683                })
684            }
685        }
686        let dispatcher: Arc<dyn BashHookDispatcher> = Arc::new(NoopDispatcher);
687        let hooks = build_pre_tool_use_hooks(&specs, dispatcher);
688        assert_eq!(hooks.len(), 1);
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
696    use serde_json::json;
697    use std::sync::Mutex;
698
699    fn make_spec(event: HookEvent, command: &str) -> UserHookSpec {
700        UserHookSpec {
701            id: Some("t".into()),
702            event,
703            matcher: Default::default(),
704            executor: ExecutorSpec::Bash {
705                command: command.into(),
706                env: Default::default(),
707            },
708            timeout_ms: 5000,
709            on_error: OnError::Warn,
710            description: None,
711            source: HookSource::UserConfig,
712        }
713    }
714
715    fn make_tool_call(name: &str) -> ToolCall {
716        ToolCall {
717            id: "call_1".into(),
718            name: name.into(),
719            arguments: json!({}),
720        }
721    }
722
723    fn make_tool_def(name: &str) -> ToolDefinition {
724        ToolDefinition::Builtin(BuiltinTool {
725            name: name.into(),
726            display_name: None,
727            description: "x".into(),
728            parameters: json!({}),
729            policy: ToolPolicy::Auto,
730            category: None,
731            deferrable: DeferrablePolicy::Never,
732            hints: ToolHints::default(),
733            full_parameters: None,
734        })
735    }
736
737    fn empty_result() -> ToolResult {
738        ToolResult {
739            tool_call_id: "call_1".into(),
740            result: Some(json!({"out": "stuff"})),
741            images: None,
742            error: None,
743            connection_required: None,
744            raw_output: None,
745        }
746    }
747
748    /// Test executor that records calls and returns a programmed outcome.
749    struct ProgrammedExecutor {
750        outcome: HookOutcome,
751        calls: Mutex<Vec<HookPayload>>,
752    }
753
754    #[async_trait]
755    impl HookExecutor for ProgrammedExecutor {
756        fn kind(&self) -> &'static str {
757            "test"
758        }
759        async fn run(&self, payload: HookPayload, _opts: &ExecutorOpts) -> HookOutcome {
760            self.calls.lock().unwrap().push(payload);
761            self.outcome.clone()
762        }
763    }
764
765    fn programmed(outcome: HookOutcome) -> Arc<ProgrammedExecutor> {
766        Arc::new(ProgrammedExecutor {
767            outcome,
768            calls: Mutex::new(Vec::new()),
769        })
770    }
771
772    #[tokio::test]
773    async fn adapter_runs_executor_when_matcher_passes() {
774        let exec = programmed(HookOutcome::Allow);
775        let exec_arc: Arc<dyn HookExecutor> = exec.clone();
776        let adapter =
777            PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
778
779        let tc = make_tool_call("edit_file");
780        let td = make_tool_def("edit_file");
781        let mut result = empty_result();
782        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
783        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
784
785        assert_eq!(exec.calls.lock().unwrap().len(), 1);
786    }
787
788    #[tokio::test]
789    async fn adapter_skips_when_matcher_rejects() {
790        let exec = programmed(HookOutcome::Allow);
791        let mut spec = make_spec(HookEvent::PostToolUse, "true");
792        spec.matcher.tool_name = Some("read_file".into()); // won't match edit_file
793        let exec_arc: Arc<dyn HookExecutor> = exec.clone();
794        let adapter = PostToolUseHookAdapter::new(spec, exec_arc);
795
796        let tc = make_tool_call("edit_file");
797        let td = make_tool_def("edit_file");
798        let mut result = empty_result();
799        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
800        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
801
802        assert_eq!(exec.calls.lock().unwrap().len(), 0);
803    }
804
805    #[tokio::test]
806    async fn mutate_patch_replaces_result_and_error() {
807        let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
808            outcome: HookOutcome::Mutate {
809                patch: json!({
810                    "result": {"replaced": true},
811                    "error": "redacted",
812                }),
813                reason: None,
814            },
815            calls: Mutex::new(Vec::new()),
816        });
817        let adapter =
818            PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
819
820        let tc = make_tool_call("edit_file");
821        let td = make_tool_def("edit_file");
822        let mut result = empty_result();
823        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
824        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
825
826        assert_eq!(result.result, Some(json!({"replaced": true})));
827        assert_eq!(result.error.as_deref(), Some("redacted"));
828    }
829
830    #[tokio::test]
831    async fn mutate_additional_context_appends_hook_context() {
832        let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
833            outcome: HookOutcome::Mutate {
834                patch: json!({"additional_context": "fmt clean"}),
835                reason: None,
836            },
837            calls: Mutex::new(Vec::new()),
838        });
839        let adapter =
840            PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
841
842        let tc = make_tool_call("edit_file");
843        let td = make_tool_def("edit_file");
844        let mut result = empty_result();
845        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
846        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
847
848        // Should append a hook_context key to the existing object result.
849        let r = result.result.as_ref().unwrap();
850        assert_eq!(r["hook_context"], "fmt clean");
851        assert_eq!(r["out"], "stuff"); // original keys preserved
852    }
853
854    #[tokio::test]
855    async fn block_outcome_is_ignored_for_post_tool_use() {
856        let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
857            outcome: HookOutcome::Block {
858                reason: "bogus".into(),
859                user_message: None,
860            },
861            calls: Mutex::new(Vec::new()),
862        });
863        let adapter =
864            PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
865
866        let tc = make_tool_call("edit_file");
867        let td = make_tool_def("edit_file");
868        let mut result = empty_result();
869        let original = result.result.clone();
870        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
871        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
872
873        assert_eq!(result.result, original);
874        assert!(result.error.is_none());
875    }
876
877    #[tokio::test]
878    async fn error_with_on_error_block_replaces_result_with_error() {
879        let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
880            outcome: HookOutcome::Error {
881                message: "boom".into(),
882            },
883            calls: Mutex::new(Vec::new()),
884        });
885        let mut spec = make_spec(HookEvent::PostToolUse, "true");
886        spec.on_error = OnError::Block;
887        let adapter = PostToolUseHookAdapter::new(spec, exec_arc);
888
889        let tc = make_tool_call("edit_file");
890        let td = make_tool_def("edit_file");
891        let mut result = empty_result();
892        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
893        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
894
895        let err = result.error.unwrap();
896        assert!(err.contains("boom"), "{err}");
897    }
898
899    #[tokio::test]
900    async fn error_with_on_error_warn_keeps_result_intact() {
901        let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
902            outcome: HookOutcome::Error {
903                message: "boom".into(),
904            },
905            calls: Mutex::new(Vec::new()),
906        });
907        let adapter =
908            PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
909
910        let tc = make_tool_call("edit_file");
911        let td = make_tool_def("edit_file");
912        let mut result = empty_result();
913        let original = result.result.clone();
914        let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
915        adapter.after_exec(&tc, &td, &mut result, &ctx).await;
916
917        assert_eq!(result.result, original);
918        assert!(result.error.is_none());
919    }
920
921    #[test]
922    fn factory_filters_to_post_tool_use_event() {
923        let specs = vec![
924            make_spec(HookEvent::PostToolUse, "true"),
925            make_spec(HookEvent::PreToolUse, "true"),
926            make_spec(HookEvent::SessionStart, "true"),
927        ];
928        struct NoopDispatcher;
929        #[async_trait]
930        impl BashHookDispatcher for NoopDispatcher {
931            async fn dispatch(
932                &self,
933                _payload: &HookPayload,
934                _command: &str,
935                _extra_env: &std::collections::BTreeMap<String, String>,
936                _opts: &ExecutorOpts,
937            ) -> Result<crate::hook_executor::BashExecOutput, String> {
938                Ok(crate::hook_executor::BashExecOutput {
939                    exit_code: 0,
940                    stdout: String::new(),
941                    stderr: String::new(),
942                })
943            }
944        }
945        let dispatcher: Arc<dyn BashHookDispatcher> = Arc::new(NoopDispatcher);
946        let hooks = build_post_tool_use_hooks(&specs, dispatcher);
947        assert_eq!(hooks.len(), 1, "only the PostToolUse spec should be built");
948    }
949}