1use 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
29pub struct PostToolUseHookAdapter {
51 spec: UserHookSpec,
52 executor: Arc<dyn HookExecutor>,
53 opts: ExecutorOpts,
54 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 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
165fn 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 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
193pub 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
238pub 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 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
289pub fn hook_id_namespace(spec: &UserHookSpec) -> &'static str {
292 match spec.source {
293 HookSource::UserConfig => "user",
294 HookSource::Capability { .. } => "capability",
295 }
296}
297
298pub struct PreToolUseHookAdapter {
320 spec: UserHookSpec,
321 executor: Arc<dyn HookExecutor>,
322 opts: ExecutorOpts,
323 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
419fn 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
441pub 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 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()); 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 let r = result.result.as_ref().unwrap();
850 assert_eq!(r["hook_context"], "fmt clean");
851 assert_eq!(r["out"], "stuff"); }
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}