use crate::config::HookSpec; use crate::discovery::HookRegistry; use crate::event::{HookEventEnvelope, HookEventName}; use crate::result::{HookDecision, HookRunResult}; use crate::runner::{self, GateKind, HookRunnerResult, RunContext}; fn dispatch_span(event: HookEventName, hook_count: usize) -> tracing::Span { tracing::info_span!( "hooks.dispatch", hook_event = %event, hook_count = hook_count as i64, num_success = tracing::field::Empty, num_failed = tracing::field::Empty, num_blocking = tracing::field::Empty, num_skipped = tracing::field::Empty, total_duration_ms = tracing::field::Empty, ) } /// Disabled/trust-disabled specs record a `Skipped` result; a matcher miss /// records nothing. fn eligible_or_record_skip( spec: &HookSpec, match_value: Option<&str>, results: &mut Vec, ) -> bool { if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) { tracing::info!(hook_name = %spec.name, "hook skipped (disabled)"); results.push(HookRunResult::Skipped { hook_name: spec.name.clone(), }); return false; } crate::matcher::matcher_allows(spec.matcher.as_ref(), match_value) } /// Result of a `pre_tool_use` dispatch: the final decision plus per-hook /// execution details (for scrollback enrichment). pub struct PreToolUseResult { pub decision: HookDecision, pub results: Vec, } /// Dispatch a `pre_tool_use` event against all matching hooks. /// /// Runs hooks sequentially in config order. Only an explicit `deny` /// decision from a hook stops the chain and blocks the tool call. /// /// Hook failures (timeouts, crashes, command-not-found, env-var /// pre-spawn refusals, malformed output) are **fail-open**: the failure /// is logged and surfaced in the per-hook results for the UI scrollback, /// but the tool call continues as if the hook had allowed it. Grok /// runs in protected environments where induced-failure bypass of /// security hooks is not part of the threat model; the previous /// fail-closed posture over-blocked innocent tool calls when /// hooks timed out or had unrelated configuration errors. /// /// Returns `Allow` if no hooks match, all hooks allow, or all failing /// hooks are non-blocking by virtue of this fail-open policy. pub async fn dispatch_pre_tool_use( registry: &HookRegistry, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, ) -> PreToolUseResult { let hooks = registry.hooks_for(HookEventName::PreToolUse); if hooks.is_empty() { return PreToolUseResult { decision: HookDecision::Allow, results: Vec::new(), }; } let span = dispatch_span(HookEventName::PreToolUse, hooks.len()); let _enter = span.enter(); let match_value = envelope.payload.match_value().map(str::to_string); let mut run_results = Vec::new(); for spec in hooks { if !eligible_or_record_skip(spec, match_value.as_deref(), &mut run_results) { continue; } let _hook_span = tracing::info_span!( "hook.run", hook_name = %spec.name, hook_event = %HookEventName::PreToolUse, ) .entered(); let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, GateKind::Tool).await; match result { HookRunnerResult::Decision(HookDecision::Deny { reason, .. }) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, reason = %reason, "hook denied" ); run_results.push(HookRunResult::Blocked { hook_name: spec.name.clone(), detail: format!("denied: {reason}"), elapsed, http_info, }); record_dispatch_counts(&span, &run_results); return PreToolUseResult { decision: HookDecision::Deny { reason, hook_name: spec.name.clone(), }, results: run_results, }; } HookRunnerResult::Decision(HookDecision::Allow) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, "hook allowed" ); run_results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }); } HookRunnerResult::Failed(err) => { tracing::warn!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, error = %err, "hook failed; ignoring (fail-open)" ); run_results.push(HookRunResult::Failed { hook_name: spec.name.clone(), error: err.clone(), elapsed, http_info, }); } HookRunnerResult::Success | HookRunnerResult::Stop(_) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, "hook completed" ); run_results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }); } } } record_dispatch_counts(&span, &run_results); PreToolUseResult { decision: HookDecision::Allow, results: run_results, } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct StopBlock { pub hook_name: String, pub reason: String, } /// Aggregated signals from a `Stop`/`SubagentStop` gate dispatch. #[derive(Debug, Default)] pub struct StopDispatchResult { pub blocks: Vec, pub additional_context: Vec, /// First `continue: false` wins and overrides any blocks. pub prevent_continuation: Option, pub results: Vec, } impl StopDispatchResult { pub fn wants_continuation(&self) -> bool { self.prevent_continuation.is_none() && (!self.blocks.is_empty() || !self.additional_context.is_empty()) } /// The first force-stop wins (later ones are dropped); blocks and context /// accumulate in call order. pub fn absorb(&mut self, hook_name: &str, signals: StopSignals) { if let Some(reason) = signals.stop_reason && self.prevent_continuation.is_none() { self.prevent_continuation = Some(StopBlock { hook_name: hook_name.to_string(), reason, }); } if let Some(reason) = signals.block_reason { self.blocks.push(StopBlock { hook_name: hook_name.to_string(), reason, }); } if let Some(context) = signals.additional_context { self.additional_context.push(context); } } } /// One hook's stop signals, normalized for [`StopDispatchResult::absorb`]. /// A `Some` in `stop_reason` is what marks the hook as force-stopping. #[derive(Debug, Default)] pub struct StopSignals { pub block_reason: Option, pub stop_reason: Option, pub additional_context: Option, } /// Scrollback detail for a stop signal, shared by the file and client gates so /// the wording can't drift. A force-stop wins over a block; its reason may be absent. pub fn stop_detail( prevented: bool, prevent_reason: Option<&str>, block_reason: Option<&str>, ) -> Option { if prevented { return Some(match prevent_reason { Some(reason) => format!("prevented continuation: {reason}"), None => "prevented continuation".to_string(), }); } block_reason.map(|reason| format!("blocked stop: {reason}")) } fn stop_outcome_detail(outcome: &crate::result::StopHookOutcome) -> Option { stop_detail( outcome.force_stop.is_some(), outcome .force_stop .as_ref() .and_then(|f| f.reason.as_deref()), outcome.block_reason.as_deref(), ) } /// Dispatch a `Stop` or `SubagentStop` gate against all matching hooks. /// /// Every hook runs (no short-circuit) so the model sees all block reasons and /// additional context at once. Hook failures (timeouts, crashes, malformed /// output) are fail-open: recorded for the UI but contribute no signal, so the /// agent stops normally. pub async fn dispatch_stop( registry: &HookRegistry, event: HookEventName, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, ) -> StopDispatchResult { if event.traits().gate != GateKind::Stop { debug_assert!(false, "dispatch_stop called with non-stop event {event:?}"); tracing::error!(%event, "dispatch_stop called with a non-stop event; ignoring"); return StopDispatchResult::default(); } let event = event.canonical(); let hooks = registry.hooks_for_canonical(event); if hooks.is_empty() { return StopDispatchResult::default(); } let span = dispatch_span(event, hooks.len()); let _enter = span.enter(); let mut out = StopDispatchResult::default(); let match_value = envelope.payload.match_value().map(str::to_string); for spec in hooks { if !eligible_or_record_skip(spec, match_value.as_deref(), &mut out.results) { continue; } let _hook_span = tracing::info_span!( "hook.run", hook_name = %spec.name, hook_event = %event, ) .entered(); let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, GateKind::Stop).await; match result { HookRunnerResult::Stop(outcome) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, block = outcome.block_reason.is_some(), additional_context = outcome.additional_context.is_some(), prevent_continuation = outcome.force_stop.is_some(), "stop hook completed" ); match stop_outcome_detail(&outcome) { Some(detail) => { out.results.push(HookRunResult::Blocked { hook_name: spec.name.clone(), detail, elapsed, http_info, }); } None => out.results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }), } out.absorb( &spec.name, StopSignals { block_reason: outcome.block_reason, stop_reason: outcome.force_stop.map(|force| { force .reason .unwrap_or_else(|| "stopped by hook".to_string()) }), additional_context: outcome.additional_context, }, ); } HookRunnerResult::Failed(err) => { tracing::warn!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, error = %err, "stop hook failed; ignoring (fail-open)" ); out.results.push(HookRunResult::Failed { hook_name: spec.name.clone(), error: err, elapsed, http_info, }); } HookRunnerResult::Success | HookRunnerResult::Decision(_) => { out.results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }); } } } record_dispatch_counts(&span, &out.results); out } /// Dispatch an observe-only event against all matching hooks; never denies. pub async fn dispatch_non_blocking( registry: &HookRegistry, event: HookEventName, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, ) -> Vec { debug_assert!( event.traits().gate == GateKind::Observe, "dispatch_non_blocking called with gate event {event:?}" ); let hooks = registry.hooks_for_canonical(event); if hooks.is_empty() { return Vec::new(); } let span = dispatch_span(event, hooks.len()); let _enter = span.enter(); let match_value = envelope.payload.match_value().map(str::to_string); let mut results = Vec::with_capacity(hooks.len()); for spec in hooks { if !eligible_or_record_skip(spec, match_value.as_deref(), &mut results) { continue; } let _hook_span = tracing::info_span!( "hook.run", hook_name = %spec.name, hook_event = %event, ) .entered(); let (result, elapsed, http_info) = runner::run_hook(spec, envelope, ctx, GateKind::Observe).await; match result { HookRunnerResult::Success => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, "hook completed" ); results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }); } HookRunnerResult::Failed(err) => { tracing::warn!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, error = %err, "hook failed" ); results.push(HookRunResult::Failed { hook_name: spec.name.clone(), error: err, elapsed, http_info, }); } HookRunnerResult::Decision(_) | HookRunnerResult::Stop(_) => { tracing::info!( hook_name = %spec.name, elapsed_ms = elapsed.as_millis() as u64, "hook completed" ); results.push(HookRunResult::Success { hook_name: spec.name.clone(), elapsed, http_info, }); } } } record_dispatch_counts(&span, &results); results } fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult]) { let mut num_success = 0i64; let mut num_failed = 0i64; let mut num_skipped = 0i64; let mut total_duration_ms = 0i64; let mut num_blocked = 0i64; for r in results { match r { HookRunResult::Success { elapsed, .. } => { num_success += 1; total_duration_ms += elapsed.as_millis() as i64; } HookRunResult::Blocked { elapsed, .. } => { num_blocked += 1; total_duration_ms += elapsed.as_millis() as i64; } HookRunResult::Failed { elapsed, .. } => { num_failed += 1; total_duration_ms += elapsed.as_millis() as i64; } HookRunResult::Skipped { .. } => num_skipped += 1, } } span.record("num_success", num_success); span.record("num_failed", num_failed); span.record("num_blocking", num_blocked); span.record("num_skipped", num_skipped); span.record("total_duration_ms", total_duration_ms); } /// `"hook."` for hub-forwarded events, or `None` for /// local-only events (`PreToolUse`). pub fn hub_hook_kind(event: HookEventName) -> Option { event.traits().hub_forward.then(|| format!("hook.{event}")) } #[cfg(test)] mod tests { use super::*; use crate::config::HookSpec; use crate::event::{HookEventEnvelope, HookEventName, HookPayload}; use crate::matcher::HookMatcher; use std::collections::HashMap; use std::path::PathBuf; fn pre_tool_use_envelope(tool_name: &str) -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::PreToolUse, session_id: "test-session".into(), cwd: "/tmp".into(), workspace_root: "/tmp".into(), timestamp: "2025-01-01T00:00:00Z".into(), transcript_path: None, client_identifier: None, prompt_id: None, permission_mode: None, payload: HookPayload::PreToolUse { tool_name: tool_name.into(), tool_use_id: "tu-1".into(), tool_input: serde_json::json!({"command": "ls"}), tool_input_truncated: false, subagent_type: None, }, } } fn session_start_envelope() -> HookEventEnvelope { HookEventEnvelope { hook_event_name: HookEventName::SessionStart, session_id: "test-session".into(), cwd: "/tmp".into(), workspace_root: "/tmp".into(), timestamp: "2025-01-01T00:00:00Z".into(), transcript_path: None, client_identifier: None, prompt_id: None, permission_mode: None, payload: HookPayload::SessionStart { source: "new".into(), model_id: None, agent_type: None, }, } } fn run_ctx() -> RunContext<'static> { RunContext { session_id: "test-session", workspace_root: "/tmp", } } /// Helper: create a HookSpec pointing at `sh -c '