use crate::discovery::HookRegistry; use crate::event::{HookEventEnvelope, HookEventName}; use crate::result::{HookDecision, HookRunResult}; use crate::runner::{self, HookRunnerResult, RunContext}; /// Result of a `pre_tool_use` dispatch: the final decision plus per-hook /// execution details (for scrollback enrichment). pub struct PreToolUseResult { /// Final blocking decision (Allow or Deny). pub decision: HookDecision, /// Per-hook run results (includes HTTP info when applicable). 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 = tracing::info_span!( "hooks.dispatch", hook_event = %HookEventName::PreToolUse, hook_count = hooks.len() 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, ); let _enter = span.enter(); let tool_name = extract_tool_name(envelope); let mut run_results = Vec::new(); for spec in hooks { if !spec.enabled || crate::trust::is_hook_disabled(&spec.name) { tracing::info!(hook_name = %spec.name, "hook skipped (disabled)"); run_results.push(HookRunResult::Skipped { hook_name: spec.name.clone(), }); continue; } // Check matcher against tool name. if let Some(ref matcher) = spec.matcher && let Some(ref name) = tool_name && !matcher.is_match(name) { 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, true).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::Failed { hook_name: spec.name.clone(), error: format!("denied: {reason}"), elapsed, http_info, }); record_dispatch_counts(&span, &run_results, 1); 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, }); } // Fail-open: hook failures (timeouts, crashes, refusals to // spawn, malformed output) are logged and recorded for the UI // but do not deny the tool call. Only an explicit `deny` // decision blocks. See module docs on dispatch_pre_tool_use // for the rationale (protected-environment threat model). 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 => { // Shouldn't happen for blocking hooks, but treat as allow. 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, 0); PreToolUseResult { decision: HookDecision::Allow, results: run_results, } } /// Dispatch a non-blocking event (`session_start`, `post_tool_use`, `session_end`) /// against all matching hooks. /// /// Runs hooks sequentially, collects results. Never denies — callers log /// results and continue. pub async fn dispatch_non_blocking( registry: &HookRegistry, event: HookEventName, envelope: &HookEventEnvelope, ctx: &RunContext<'_>, ) -> Vec { let hooks = registry.hooks_for(event); if hooks.is_empty() { return Vec::new(); } let span = tracing::info_span!( "hooks.dispatch", hook_event = %event, hook_count = hooks.len() 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, ); let _enter = span.enter(); let tool_name = extract_tool_name(envelope); let mut results = Vec::with_capacity(hooks.len()); for spec in hooks { 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(), }); continue; } // Check matcher against tool name (only for tool events). if let Some(ref matcher) = spec.matcher && let Some(ref name) = tool_name && !matcher.is_match(name) { 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, false).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(_) => { // Shouldn't happen for non-blocking hooks. 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, 0); results } /// Record hook outcome counts on the `hooks.dispatch` span. A blocking deny is /// stored as a `Failed` result, so `num_blocking` is passed in and subtracted /// from `num_failed` to avoid double-counting. fn record_dispatch_counts(span: &tracing::Span, results: &[HookRunResult], num_blocking: i64) { let mut num_success = 0i64; let mut num_failed = 0i64; let mut num_skipped = 0i64; let mut total_duration_ms = 0i64; for r in results { match r { HookRunResult::Success { elapsed, .. } => { num_success += 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 - num_blocking); span.record("num_blocking", num_blocking); span.record("num_skipped", num_skipped); span.record("total_duration_ms", total_duration_ms); } /// Build the hub custom hook `kind` string for a non-blocking hook event. /// /// Returns `None` for `PreToolUse` (blocking, local-only). For all other /// events the kind is `"hook."`, derived from the /// `Display` impl of `HookEventName`. pub fn hub_hook_kind(event: HookEventName) -> Option { if event.is_blocking() { return None; } Some(format!("hook.{event}")) } /// The tool name a matcher is tested against, or `None` for events with no tool /// (lifecycle, prompt, compaction). `Notification` matches on its `notification_type`. /// /// `tool_name` is the resolved underlying tool for meta-dispatch tools (`use_tool` /// and the external MCP-call tool), so a matcher keyed on the real tool fires directly. pub fn extract_tool_name(envelope: &HookEventEnvelope) -> Option { use crate::event::HookPayload; match &envelope.payload { HookPayload::PreToolUse { tool_name, .. } => Some(tool_name.clone()), HookPayload::PostToolUse { tool_name, .. } => Some(tool_name.clone()), HookPayload::PostToolUseFailure { tool_name, .. } => Some(tool_name.clone()), HookPayload::PermissionDenied { tool_name, .. } => Some(tool_name.clone()), HookPayload::Notification { notification_type, .. } => Some(notification_type.clone()), _ => None, } } #[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; /// Helper: build a pre_tool_use envelope for the given tool name. 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, 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, permission_mode: None, subagent_type: None, }, } } /// Helper: build a session_start envelope. 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, 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 '