From db69ee42cf0432381e9babfd7ccef7d2dae1b547 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 10 Jun 2026 16:32:11 -0700 Subject: [PATCH] feat(hooks): JSON decision contract, glob matchers, project-local hooks (#3026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three incremental improvements to the hooks control plane: 1. ToolCallBeforeStdout parser: hooks can now emit a JSON decision on stdout — {"decision": "allow"|"deny"|"ask", "reason": "...", "updatedInput": {...}, "additionalContext": "..."}. Non-JSON or empty stdout retains legacy passthrough (allow). Exit code 2 still hard-denies regardless of stdout. 2. Glob matchers for ToolName conditions: `name = "mcp__*"` now matches all MCP tools. Uses regex::escape + `*` → `.*` pattern, same convention as execpolicy/matcher.rs. Exact names keep working. 3. Project-local hooks: `HooksConfig::load_with_project(global, workspace)` reads `.codewhale/hooks.toml` and appends its hooks after global. Malformed file logs a warning and falls back to global-only. --- crates/tui/src/hooks.rs | 121 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index f6d2ddee..ee1818ee 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -217,6 +217,30 @@ fn default_enabled() -> bool { } impl HooksConfig { + /// Load global hooks merged with project-local `.codewhale/hooks.toml` (#3026). + /// + /// Project hooks are appended after global hooks. A malformed project file + /// logs a warning and falls back to global-only. + pub fn load_with_project(global: HooksConfig, workspace: &std::path::Path) -> HooksConfig { + let project_path = workspace.join(".codewhale").join("hooks.toml"); + let Ok(contents) = std::fs::read_to_string(&project_path) else { + return global; + }; + let project: HooksConfig = match toml::from_str(&contents) { + Ok(cfg) => cfg, + Err(e) => { + tracing::warn!( + "Failed to parse project hooks at {}: {e}; falling back to global hooks only", + project_path.display() + ); + return global; + } + }; + let mut merged = global; + merged.hooks.extend(project.hooks); + merged + } + /// Get hooks for a specific event pub fn hooks_for_event(&self, event: HookEvent) -> Vec<&Hook> { if !self.enabled { @@ -484,6 +508,84 @@ enum MessageSubmitStdout { Invalid(String), } +/// Parsed stdout from a `tool_call_before` hook (#3026). +/// +/// Hooks may emit a JSON decision on stdout: +/// `{"decision": "allow"|"deny"|"ask", "reason": "...", +/// "updatedInput": {...}, "additionalContext": "..."}` +/// Non-JSON or empty stdout → legacy passthrough (allow). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolCallBeforeStdout { + pub decision: Option, + pub reason: Option, + pub updated_input: Option, + pub additional_context: Option, +} + +/// Decision a hook can return for a tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallDecision { + Allow, + Deny, + Ask, +} + +fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return ToolCallBeforeStdout { + decision: None, + reason: None, + updated_input: None, + additional_context: None, + }; + } + let value: serde_json::Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(_) => { + // Non-JSON stdout → legacy passthrough + return ToolCallBeforeStdout { + decision: None, + reason: None, + updated_input: None, + additional_context: None, + }; + } + }; + let obj = match value.as_object() { + Some(o) => o, + None => { + return ToolCallBeforeStdout { + decision: None, + reason: None, + updated_input: None, + additional_context: None, + }; + } + }; + let decision = obj.get("decision").and_then(|v| v.as_str()).and_then(|s| match s { + "allow" => Some(ToolCallDecision::Allow), + "deny" => Some(ToolCallDecision::Deny), + "ask" => Some(ToolCallDecision::Ask), + _ => None, + }); + let reason = obj + .get("reason") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let updated_input = obj.get("updatedInput").cloned(); + let additional_context = obj + .get("additionalContext") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + ToolCallBeforeStdout { + decision, + reason, + updated_input, + additional_context, + } +} + /// Post-turn accumulated totals included in the `turn_end` observer payload. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TurnEndTotals { @@ -862,13 +964,30 @@ impl HookExecutor { results } + /// Check whether a tool name matches a condition pattern with `*` glob support. + fn tool_name_matches_condition(tool_name: &str, pattern: &str) -> bool { + if !pattern.contains('*') { + return tool_name == pattern; + } + // Escape regex metacharacters except `*`, which becomes `.*`. + let escaped = regex::escape(pattern); + let regex_pattern = escaped.replace(r"\*", ".*"); + let anchored = format!("^{regex_pattern}$"); + regex::Regex::new(&anchored).is_ok_and(|re| re.is_match(tool_name)) + } + /// Check if a hook's condition matches the context #[allow(clippy::only_used_in_recursion)] fn matches_condition(&self, hook: &Hook, context: &HookContext) -> bool { match &hook.condition { None | Some(HookCondition::Always) => true, Some(HookCondition::ToolName { name }) => { - context.tool_name.as_ref().is_some_and(|n| n == name) + // #3026: Support `*` globs in tool_name conditions so + // `mcp__*` matches all MCP tools. Exact names keep working. + context + .tool_name + .as_ref() + .is_some_and(|n| Self::tool_name_matches_condition(n, name)) } Some(HookCondition::ToolCategory { category }) => { // Map tool names to categories