use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::atoms::{PostToolExecHook, PreToolUseDecision, PreToolUseHook};
use crate::hook_executor::{
BashHookDispatcher, BashHookExecutor, ExecutorOpts, HookExecutor, HookPayload,
};
use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
use crate::traits::ToolContext;
use crate::user_hook_types::{
ExecutorSpec, HookEvent, HookOutcome, HookSource, OnError, UserHookSpec,
};
pub struct PostToolUseHookAdapter {
spec: UserHookSpec,
executor: Arc<dyn HookExecutor>,
opts: ExecutorOpts,
hook_id: crate::user_hook_types::HookId,
}
impl PostToolUseHookAdapter {
pub fn new(spec: UserHookSpec, executor: Arc<dyn HookExecutor>) -> Self {
Self::with_index(spec, executor, 0)
}
pub fn with_index(spec: UserHookSpec, executor: Arc<dyn HookExecutor>, index: usize) -> Self {
let opts = ExecutorOpts {
timeout_ms: spec.timeout_ms,
max_output_bytes: 64 * 1024,
};
let hook_id = spec.resolve_id(index);
Self {
spec,
executor,
opts,
hook_id,
}
}
fn build_payload(
&self,
tool_call: &ToolCall,
result: &ToolResult,
context: &ToolContext,
) -> HookPayload {
let success = result.error.is_none();
HookPayload {
event: HookEvent::PostToolUse,
hook_id: self.hook_id.clone(),
session_id: context.session_id,
turn_id: None,
org_id: context.org_id,
agent_id: None,
ts: chrono::Utc::now().to_rfc3339(),
data: json!({
"tool_name": tool_call.name,
"tool_call_id": tool_call.id,
"arguments": tool_call.arguments,
"result": result.result,
"error": result.error,
"success": success,
}),
}
}
}
#[async_trait]
impl PostToolExecHook for PostToolUseHookAdapter {
async fn after_exec(
&self,
tool_call: &ToolCall,
_tool_def: &ToolDefinition,
result: &mut ToolResult,
context: &ToolContext,
) {
if !self
.spec
.matcher
.matches(&tool_call.name, &tool_call.arguments)
{
return;
}
let payload = self.build_payload(tool_call, result, context);
let outcome = self.executor.run(payload, &self.opts).await;
let hook_id = &self.hook_id;
match outcome {
HookOutcome::Allow => {}
HookOutcome::Mutate { patch, .. } => apply_post_tool_use_patch(result, &patch),
HookOutcome::Block { reason, .. } => {
tracing::warn!(
hook_id = %hook_id.as_str(),
tool_call_id = %tool_call.id,
reason = %reason,
"post_tool_use hook returned Block, which is not allowed for this event; ignoring"
);
}
HookOutcome::Error { message } => match self.spec.on_error {
OnError::Block => {
result.error = Some(format!("hook {}: {}", hook_id.as_str(), message));
tracing::warn!(
hook_id = %hook_id.as_str(),
tool_call_id = %tool_call.id,
message = %message,
"post_tool_use hook errored with on_error=block; replacing tool result with error"
);
}
OnError::Warn => {
tracing::warn!(
hook_id = %hook_id.as_str(),
tool_call_id = %tool_call.id,
message = %message,
"post_tool_use hook errored"
);
}
OnError::Allow => {}
},
}
}
}
fn apply_post_tool_use_patch(result: &mut ToolResult, patch: &serde_json::Value) {
if let Some(new_result) = patch.get("result") {
result.result = Some(new_result.clone());
}
if let Some(new_error) = patch.get("error").and_then(|v| v.as_str()) {
result.error = Some(new_error.to_string());
}
if let Some(ctx) = patch.get("additional_context").and_then(|v| v.as_str()) {
match result.result.as_mut() {
Some(serde_json::Value::Object(map)) => {
map.insert("hook_context".to_string(), json!(ctx));
}
Some(other) => {
let prior = other.clone();
*other = json!({ "value": prior, "hook_context": ctx });
}
None => {
result.result = Some(json!({ "hook_context": ctx }));
}
}
}
}
pub fn build_post_tool_use_hooks(
specs: &[UserHookSpec],
dispatcher: Arc<dyn BashHookDispatcher>,
) -> Vec<Arc<dyn PostToolExecHook>> {
let mut out: Vec<Arc<dyn PostToolExecHook>> = Vec::new();
for (index, spec) in specs.iter().enumerate() {
if spec.event != HookEvent::PostToolUse {
continue;
}
if let Err(e) = spec.validate() {
let hook_id_for_log = spec.resolve_id(index);
tracing::warn!(
hook_id = %hook_id_for_log.as_str(),
error = %e,
"skipping invalid post_tool_use hook spec"
);
continue;
}
let executor: Arc<dyn HookExecutor> = match &spec.executor {
ExecutorSpec::Bash { command, env } => Arc::new(BashHookExecutor::with_dispatcher(
command.clone(),
env.clone(),
dispatcher.clone(),
)),
};
out.push(Arc::new(PostToolUseHookAdapter::with_index(
spec.clone(),
executor,
index,
)));
}
out
}
pub fn finalize_hook_specs(
contributions: Vec<(String, Vec<UserHookSpec>)>,
disabled: &[String],
) -> Vec<UserHookSpec> {
let disabled: std::collections::HashSet<&str> = disabled.iter().map(String::as_str).collect();
let mut out: Vec<UserHookSpec> = Vec::new();
for (capability_id, specs) in contributions {
for (idx, mut spec) in specs.into_iter().enumerate() {
if capability_id != "user_hooks" {
spec.source = HookSource::Capability {
capability_id: capability_id.clone(),
};
if spec.id.is_none() {
spec.id = Some(format!("{}_{}", spec.event.as_str(), idx));
}
}
let resolved = spec.resolve_id(idx);
if disabled.contains(resolved.as_str()) {
tracing::info!(
hook_id = %resolved.as_str(),
"muting hook via disabled_contributions"
);
continue;
}
out.push(spec);
}
}
out
}
pub fn hook_id_namespace(spec: &UserHookSpec) -> &'static str {
match spec.source {
HookSource::UserConfig => "user",
HookSource::Capability { .. } => "capability",
}
}
pub struct PreToolUseHookAdapter {
spec: UserHookSpec,
executor: Arc<dyn HookExecutor>,
opts: ExecutorOpts,
hook_id: crate::user_hook_types::HookId,
}
impl PreToolUseHookAdapter {
pub fn new(spec: UserHookSpec, executor: Arc<dyn HookExecutor>) -> Self {
Self::with_index(spec, executor, 0)
}
pub fn with_index(spec: UserHookSpec, executor: Arc<dyn HookExecutor>, index: usize) -> Self {
let opts = ExecutorOpts {
timeout_ms: spec.timeout_ms,
max_output_bytes: 64 * 1024,
};
let hook_id = spec.resolve_id(index);
Self {
spec,
executor,
opts,
hook_id,
}
}
fn build_payload(&self, tool_call: &ToolCall, context: &ToolContext) -> HookPayload {
HookPayload {
event: HookEvent::PreToolUse,
hook_id: self.hook_id.clone(),
session_id: context.session_id,
turn_id: None,
org_id: context.org_id,
agent_id: None,
ts: chrono::Utc::now().to_rfc3339(),
data: json!({
"tool_name": tool_call.name,
"tool_call_id": tool_call.id,
"arguments": tool_call.arguments,
}),
}
}
}
#[async_trait]
impl PreToolUseHook for PreToolUseHookAdapter {
async fn before_exec(
&self,
tool_call: ToolCall,
_tool_def: &ToolDefinition,
context: &ToolContext,
) -> PreToolUseDecision {
if !self
.spec
.matcher
.matches(&tool_call.name, &tool_call.arguments)
{
return PreToolUseDecision::Continue(tool_call);
}
let payload = self.build_payload(&tool_call, context);
let outcome = self.executor.run(payload, &self.opts).await;
let hook_id = &self.hook_id;
match outcome {
HookOutcome::Allow => PreToolUseDecision::Continue(tool_call),
HookOutcome::Mutate { patch, .. } => {
let mutated = apply_pre_tool_use_patch(tool_call, &patch);
PreToolUseDecision::Continue(mutated)
}
HookOutcome::Block {
reason,
user_message,
} => PreToolUseDecision::Block {
tool_call,
reason,
user_message,
},
HookOutcome::Error { message } => match self.spec.on_error {
OnError::Block => PreToolUseDecision::Block {
tool_call,
reason: format!("hook {} errored: {}", hook_id.as_str(), message),
user_message: None,
},
OnError::Warn => {
tracing::warn!(
hook_id = %hook_id.as_str(),
tool_call_id = %tool_call.id,
message = %message,
"pre_tool_use hook errored"
);
PreToolUseDecision::Continue(tool_call)
}
OnError::Allow => PreToolUseDecision::Continue(tool_call),
},
}
}
}
fn apply_pre_tool_use_patch(mut tool_call: ToolCall, patch: &serde_json::Value) -> ToolCall {
if let Some(new_args) = patch.get("arguments")
&& let Some(new_obj) = new_args.as_object()
{
match tool_call.arguments.as_object_mut() {
Some(existing) => {
for (k, v) in new_obj {
existing.insert(k.clone(), v.clone());
}
}
None => {
tool_call.arguments = serde_json::Value::Object(new_obj.clone());
}
}
}
tool_call
}
pub fn build_pre_tool_use_hooks(
specs: &[UserHookSpec],
dispatcher: Arc<dyn BashHookDispatcher>,
) -> Vec<Arc<dyn PreToolUseHook>> {
let mut out: Vec<Arc<dyn PreToolUseHook>> = Vec::new();
for (index, spec) in specs.iter().enumerate() {
if spec.event != HookEvent::PreToolUse {
continue;
}
if let Err(e) = spec.validate() {
let hook_id_for_log = spec.resolve_id(index);
tracing::warn!(
hook_id = %hook_id_for_log.as_str(),
error = %e,
"skipping invalid pre_tool_use hook spec"
);
continue;
}
let executor: Arc<dyn HookExecutor> = match &spec.executor {
ExecutorSpec::Bash { command, env } => Arc::new(BashHookExecutor::with_dispatcher(
command.clone(),
env.clone(),
dispatcher.clone(),
)),
};
out.push(Arc::new(PreToolUseHookAdapter::with_index(
spec.clone(),
executor,
index,
)));
}
out
}
#[cfg(test)]
mod pre_tool_use_tests {
use super::*;
use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
use serde_json::json;
use std::sync::Mutex;
fn make_spec(matcher: crate::user_hook_types::HookMatcher) -> UserHookSpec {
UserHookSpec {
id: Some("pre".into()),
event: HookEvent::PreToolUse,
matcher,
executor: ExecutorSpec::Bash {
command: "true".into(),
env: Default::default(),
},
timeout_ms: 5000,
on_error: OnError::Warn,
description: None,
source: HookSource::UserConfig,
}
}
fn make_tool_call() -> ToolCall {
ToolCall {
id: "call_x".into(),
name: "bash".into(),
arguments: json!({"command": "rm -rf /"}),
}
}
fn make_tool_def() -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: "bash".into(),
display_name: None,
description: "".into(),
parameters: json!({}),
policy: ToolPolicy::Auto,
category: None,
deferrable: DeferrablePolicy::Never,
hints: ToolHints::default(),
})
}
struct ProgrammedExecutor {
outcome: HookOutcome,
calls: Mutex<Vec<HookPayload>>,
}
#[async_trait]
impl HookExecutor for ProgrammedExecutor {
fn kind(&self) -> &'static str {
"test"
}
async fn run(&self, payload: HookPayload, _opts: &ExecutorOpts) -> HookOutcome {
self.calls.lock().unwrap().push(payload);
self.outcome.clone()
}
}
fn programmed(outcome: HookOutcome) -> Arc<ProgrammedExecutor> {
Arc::new(ProgrammedExecutor {
outcome,
calls: Mutex::new(Vec::new()),
})
}
#[tokio::test]
async fn matcher_miss_skips_executor() {
let exec = programmed(HookOutcome::Allow);
let exec_arc: Arc<dyn HookExecutor> = exec.clone();
let matcher = crate::user_hook_types::HookMatcher {
tool_name: Some("edit_file".into()),
..Default::default()
};
let adapter = PreToolUseHookAdapter::new(make_spec(matcher), exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let decision = adapter
.before_exec(make_tool_call(), &make_tool_def(), &ctx)
.await;
assert!(matches!(decision, PreToolUseDecision::Continue(_)));
assert_eq!(exec.calls.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn allow_outcome_returns_continue_unchanged() {
let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Allow);
let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let original = make_tool_call();
let decision = adapter
.before_exec(original.clone(), &make_tool_def(), &ctx)
.await;
match decision {
PreToolUseDecision::Continue(tc) => assert_eq!(tc.arguments, original.arguments),
other => panic!("expected Continue, got {other:?}"),
}
}
#[tokio::test]
async fn block_outcome_propagates() {
let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Block {
reason: "denied".into(),
user_message: Some("nope".into()),
});
let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let decision = adapter
.before_exec(make_tool_call(), &make_tool_def(), &ctx)
.await;
match decision {
PreToolUseDecision::Block {
reason,
user_message,
..
} => {
assert_eq!(reason, "denied");
assert_eq!(user_message.as_deref(), Some("nope"));
}
other => panic!("expected Block, got {other:?}"),
}
}
#[tokio::test]
async fn mutate_patch_merges_arguments() {
let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Mutate {
patch: json!({ "arguments": { "command": "ls" } }),
reason: None,
});
let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let decision = adapter
.before_exec(make_tool_call(), &make_tool_def(), &ctx)
.await;
match decision {
PreToolUseDecision::Continue(tc) => {
assert_eq!(tc.arguments["command"], "ls");
}
other => panic!("expected Continue, got {other:?}"),
}
}
#[tokio::test]
async fn error_with_on_error_block_returns_block() {
let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Error {
message: "boom".into(),
});
let mut spec = make_spec(Default::default());
spec.on_error = OnError::Block;
let adapter = PreToolUseHookAdapter::new(spec, exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let decision = adapter
.before_exec(make_tool_call(), &make_tool_def(), &ctx)
.await;
match decision {
PreToolUseDecision::Block { reason, .. } => {
assert!(reason.contains("boom"), "{reason}");
}
other => panic!("expected Block, got {other:?}"),
}
}
#[tokio::test]
async fn error_with_on_error_warn_returns_continue() {
let exec_arc: Arc<dyn HookExecutor> = programmed(HookOutcome::Error {
message: "boom".into(),
});
let adapter = PreToolUseHookAdapter::new(make_spec(Default::default()), exec_arc);
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
let decision = adapter
.before_exec(make_tool_call(), &make_tool_def(), &ctx)
.await;
assert!(matches!(decision, PreToolUseDecision::Continue(_)));
}
#[test]
fn factory_filters_to_pre_tool_use_event() {
let specs = vec![
make_spec(Default::default()),
UserHookSpec {
event: HookEvent::PostToolUse,
..make_spec(Default::default())
},
];
struct NoopDispatcher;
#[async_trait]
impl BashHookDispatcher for NoopDispatcher {
async fn dispatch(
&self,
_payload: &HookPayload,
_command: &str,
_extra_env: &std::collections::BTreeMap<String, String>,
_opts: &ExecutorOpts,
) -> Result<crate::hook_executor::BashExecOutput, String> {
Ok(crate::hook_executor::BashExecOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let dispatcher: Arc<dyn BashHookDispatcher> = Arc::new(NoopDispatcher);
let hooks = build_pre_tool_use_hooks(&specs, dispatcher);
assert_eq!(hooks.len(), 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
use serde_json::json;
use std::sync::Mutex;
fn make_spec(event: HookEvent, command: &str) -> UserHookSpec {
UserHookSpec {
id: Some("t".into()),
event,
matcher: Default::default(),
executor: ExecutorSpec::Bash {
command: command.into(),
env: Default::default(),
},
timeout_ms: 5000,
on_error: OnError::Warn,
description: None,
source: HookSource::UserConfig,
}
}
fn make_tool_call(name: &str) -> ToolCall {
ToolCall {
id: "call_1".into(),
name: name.into(),
arguments: json!({}),
}
}
fn make_tool_def(name: &str) -> ToolDefinition {
ToolDefinition::Builtin(BuiltinTool {
name: name.into(),
display_name: None,
description: "x".into(),
parameters: json!({}),
policy: ToolPolicy::Auto,
category: None,
deferrable: DeferrablePolicy::Never,
hints: ToolHints::default(),
})
}
fn empty_result() -> ToolResult {
ToolResult {
tool_call_id: "call_1".into(),
result: Some(json!({"out": "stuff"})),
images: None,
error: None,
connection_required: None,
raw_output: None,
}
}
struct ProgrammedExecutor {
outcome: HookOutcome,
calls: Mutex<Vec<HookPayload>>,
}
#[async_trait]
impl HookExecutor for ProgrammedExecutor {
fn kind(&self) -> &'static str {
"test"
}
async fn run(&self, payload: HookPayload, _opts: &ExecutorOpts) -> HookOutcome {
self.calls.lock().unwrap().push(payload);
self.outcome.clone()
}
}
fn programmed(outcome: HookOutcome) -> Arc<ProgrammedExecutor> {
Arc::new(ProgrammedExecutor {
outcome,
calls: Mutex::new(Vec::new()),
})
}
#[tokio::test]
async fn adapter_runs_executor_when_matcher_passes() {
let exec = programmed(HookOutcome::Allow);
let exec_arc: Arc<dyn HookExecutor> = exec.clone();
let adapter =
PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
assert_eq!(exec.calls.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn adapter_skips_when_matcher_rejects() {
let exec = programmed(HookOutcome::Allow);
let mut spec = make_spec(HookEvent::PostToolUse, "true");
spec.matcher.tool_name = Some("read_file".into()); let exec_arc: Arc<dyn HookExecutor> = exec.clone();
let adapter = PostToolUseHookAdapter::new(spec, exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
assert_eq!(exec.calls.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn mutate_patch_replaces_result_and_error() {
let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
outcome: HookOutcome::Mutate {
patch: json!({
"result": {"replaced": true},
"error": "redacted",
}),
reason: None,
},
calls: Mutex::new(Vec::new()),
});
let adapter =
PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
assert_eq!(result.result, Some(json!({"replaced": true})));
assert_eq!(result.error.as_deref(), Some("redacted"));
}
#[tokio::test]
async fn mutate_additional_context_appends_hook_context() {
let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
outcome: HookOutcome::Mutate {
patch: json!({"additional_context": "fmt clean"}),
reason: None,
},
calls: Mutex::new(Vec::new()),
});
let adapter =
PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
let r = result.result.as_ref().unwrap();
assert_eq!(r["hook_context"], "fmt clean");
assert_eq!(r["out"], "stuff"); }
#[tokio::test]
async fn block_outcome_is_ignored_for_post_tool_use() {
let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
outcome: HookOutcome::Block {
reason: "bogus".into(),
user_message: None,
},
calls: Mutex::new(Vec::new()),
});
let adapter =
PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let original = result.result.clone();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
assert_eq!(result.result, original);
assert!(result.error.is_none());
}
#[tokio::test]
async fn error_with_on_error_block_replaces_result_with_error() {
let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
outcome: HookOutcome::Error {
message: "boom".into(),
},
calls: Mutex::new(Vec::new()),
});
let mut spec = make_spec(HookEvent::PostToolUse, "true");
spec.on_error = OnError::Block;
let adapter = PostToolUseHookAdapter::new(spec, exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
let err = result.error.unwrap();
assert!(err.contains("boom"), "{err}");
}
#[tokio::test]
async fn error_with_on_error_warn_keeps_result_intact() {
let exec_arc: Arc<dyn HookExecutor> = Arc::new(ProgrammedExecutor {
outcome: HookOutcome::Error {
message: "boom".into(),
},
calls: Mutex::new(Vec::new()),
});
let adapter =
PostToolUseHookAdapter::new(make_spec(HookEvent::PostToolUse, "true"), exec_arc);
let tc = make_tool_call("edit_file");
let td = make_tool_def("edit_file");
let mut result = empty_result();
let original = result.result.clone();
let ctx = ToolContext::new(crate::typed_id::SessionId::from_uuid(uuid::Uuid::nil()));
adapter.after_exec(&tc, &td, &mut result, &ctx).await;
assert_eq!(result.result, original);
assert!(result.error.is_none());
}
#[test]
fn factory_filters_to_post_tool_use_event() {
let specs = vec![
make_spec(HookEvent::PostToolUse, "true"),
make_spec(HookEvent::PreToolUse, "true"),
make_spec(HookEvent::SessionStart, "true"),
];
struct NoopDispatcher;
#[async_trait]
impl BashHookDispatcher for NoopDispatcher {
async fn dispatch(
&self,
_payload: &HookPayload,
_command: &str,
_extra_env: &std::collections::BTreeMap<String, String>,
_opts: &ExecutorOpts,
) -> Result<crate::hook_executor::BashExecOutput, String> {
Ok(crate::hook_executor::BashExecOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let dispatcher: Arc<dyn BashHookDispatcher> = Arc::new(NoopDispatcher);
let hooks = build_post_tool_use_hooks(&specs, dispatcher);
assert_eq!(hooks.len(), 1, "only the PostToolUse spec should be built");
}
}