From 6f5039101aabd5c9f08247b13401cb3a3a0a7379 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 01:52:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20wire=20the=20#3026=20decision=20?= =?UTF-8?q?contract=20into=20the=20engine=20=E2=80=94=20JSON=20deny/ask/up?= =?UTF-8?q?datedInput=20now=20steer=20tool=20calls=20(deny>ask>allow,=20la?= =?UTF-8?q?st-writer=20updatedInput),=20additionalContext=20piped=20into?= =?UTF-8?q?=20tool=20results,=20project=20.codewhale/hooks.toml=20loaded?= =?UTF-8?q?=20at=20both=20HookExecutor=20sites;=20parser=20hardening=20+?= =?UTF-8?q?=20fold/glob/project=20tests;=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5 --- crates/tui/src/core/engine/turn_loop.rs | 283 ++++++++++++++++++++++-- crates/tui/src/hooks.rs | 271 ++++++++++++++++++++--- crates/tui/src/tui/app.rs | 6 +- crates/tui/src/tui/ui.rs | 5 +- docs/CONFIGURATION.md | 89 +++++++- 5 files changed, 601 insertions(+), 53 deletions(-) diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 53cdf927..ae37b12c 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1332,11 +1332,15 @@ impl Engine { let active_tools_at_batch_start = active_tool_names.clone(); let mut deferred_tools_hydrated_this_batch: std::collections::HashSet = std::collections::HashSet::new(); + // #3026: `additionalContext` strings from tool_call_before hooks, + // keyed by tool id; appended to the tool result sent to the model. + let mut hook_contexts: std::collections::HashMap = + std::collections::HashMap::new(); let mut plans: Vec = Vec::with_capacity(tool_uses.len()); for (index, tool) in tool_uses.iter_mut().enumerate() { let tool_id = tool.id.clone(); let mut tool_name = tool.name.clone(); - let tool_input = tool.input.clone(); + let mut tool_input = tool.input.clone(); let tool_caller = tool.caller.clone(); crate::logging::info(format!( "Planning tool '{tool_name}' with input: {tool_input:?}" @@ -1362,6 +1366,10 @@ impl Engine { let mut read_only = false; let mut blocked_error: Option = None; let mut guard_result: Option = None; + // #3026: set by a hook `ask` decision; applied AFTER the + // registry-based approval computation below so it cannot be + // clobbered by it. + let mut hook_requires_approval = false; if mode == AppMode::Plan && matches!( @@ -1446,29 +1454,25 @@ impl Engine { tracing::error!("Hook executor task panicked: {join_err}"); Vec::new() }); - if let Some(denial) = hook_results - .iter() - .find(|result| result.exit_code == Some(2)) - { - let reason = denial - .stdout - .trim() - .lines() - .next() - .filter(|line| !line.is_empty()) - .or_else(|| { - denial - .stderr - .trim() - .lines() - .next() - .filter(|line| !line.is_empty()) - }) - .or(denial.error.as_deref()) - .unwrap_or("ToolCallBefore hook denied tool execution"); + // #3026: fold all foreground hook results into one + // decision: deny (exit code 2 or JSON) > ask > allow; + // last `updatedInput` writer wins; `additionalContext` + // strings are concatenated. + let fold = fold_tool_call_before_results(&hook_results); + if let Some(reason) = fold.deny_reason { blocked_error = Some(ToolError::permission_denied(format!( "ToolCallBefore hook denied tool '{tool_name}': {reason}" ))); + } else { + if fold.requires_approval { + hook_requires_approval = true; + } + if let Some(updated) = fold.updated_input { + tool_input = updated; + } + if let Some(context) = fold.additional_context { + hook_contexts.insert(tool_id.clone(), context); + } } } @@ -1504,6 +1508,14 @@ impl Engine { read_only = true; } + // #3026: a hook `ask` decision forces the approval prompt even + // for tools the registry would auto-run. Must stay after the + // registry-based computation above, which assigns rather than + // ORs `approval_required`. + if hook_requires_approval { + approval_required = true; + } + let should_emit_hydration_status = !deferred_tools_hydrated_this_batch.contains(&tool_name); if blocked_error.is_none() @@ -2129,6 +2141,15 @@ impl Engine { .await; } + // #3026: pipe `additionalContext` from tool_call_before + // hooks back to the model alongside the tool result. + let output_for_context = match hook_contexts.get(&outcome.id) { + Some(context) => { + format!("{output_for_context}\n\n[hook context] {context}") + } + None => output_for_context, + }; + self.add_session_message(Message { role: "user".to_string(), content: vec![ContentBlock::ToolResult { @@ -2378,6 +2399,81 @@ fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> boo allowed_tools.contains(&tool_name.to_ascii_lowercase()) } +/// Folded outcome of all `tool_call_before` hook results for one tool call +/// (#3026). Precedence: deny (exit code 2 or JSON) > ask > allow; +/// `updatedInput` is last-writer-wins; `additionalContext` is concatenated. +#[derive(Debug, Default, PartialEq)] +struct ToolCallHookFold { + /// Denial reason from an exit-code-2 hook or a JSON `deny` decision. + deny_reason: Option, + /// At least one hook returned a JSON `ask` decision. + requires_approval: bool, + /// Replacement tool input from the last hook that supplied one. + updated_input: Option, + /// Concatenated `additionalContext` strings from all hooks. + additional_context: Option, +} + +fn fold_tool_call_before_results(results: &[crate::hooks::HookResult]) -> ToolCallHookFold { + let mut fold = ToolCallHookFold::default(); + + // Legacy hard deny: exit code 2 wins regardless of stdout (backwards + // compatible with pre-#3026 hooks). + if let Some(denial) = results.iter().find(|result| result.exit_code == Some(2)) { + let reason = denial + .stdout + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + .or_else(|| { + denial + .stderr + .trim() + .lines() + .next() + .filter(|line| !line.is_empty()) + }) + .or(denial.error.as_deref()) + .unwrap_or("ToolCallBefore hook denied tool execution"); + fold.deny_reason = Some(reason.to_string()); + return fold; + } + + for result in results { + // Background hooks return immediately with no process result and + // cannot steer (the caller warns about that configuration). + if result.exit_code.is_none() { + continue; + } + let parsed = crate::hooks::parse_tool_call_before_stdout(&result.stdout); + match parsed.decision { + Some(crate::hooks::ToolCallDecision::Deny) => { + fold.deny_reason = + Some(parsed.reason.unwrap_or_else(|| { + "ToolCallBefore hook denied tool execution".to_string() + })); + return fold; + } + Some(crate::hooks::ToolCallDecision::Ask) => fold.requires_approval = true, + Some(crate::hooks::ToolCallDecision::Allow) | None => {} + } + if let Some(updated) = parsed.updated_input { + fold.updated_input = Some(updated); + } + if let Some(context) = parsed.additional_context { + match &mut fold.additional_context { + Some(existing) => { + existing.push('\n'); + existing.push_str(&context); + } + None => fold.additional_context = Some(context), + } + } + } + fold +} + fn resolve_tool_definition<'a>( tool_name: &mut String, tool_catalog: &'a [Tool], @@ -2831,4 +2927,149 @@ mod tests { assert_eq!(results[0].exit_code, Some(2)); assert!(results[0].stdout.contains("security")); } + + // ── #3026: JSON decision contract fold ───────────────────────────────── + + fn hook_result(stdout: &str, exit_code: Option) -> crate::hooks::HookResult { + crate::hooks::HookResult { + name: None, + success: exit_code == Some(0), + exit_code, + stdout: stdout.to_string(), + stderr: String::new(), + duration: Duration::from_millis(1), + error: None, + } + } + + #[test] + fn hook_fold_json_deny_blocks_with_reason() { + let fold = fold_tool_call_before_results(&[hook_result( + r#"{"decision":"deny","reason":"nope"}"#, + Some(0), + )]); + assert_eq!(fold.deny_reason.as_deref(), Some("nope")); + assert!(!fold.requires_approval); + } + + #[test] + fn hook_fold_exit_code_2_denies_regardless_of_stdout() { + let fold = + fold_tool_call_before_results(&[hook_result(r#"{"decision":"allow"}"#, Some(2))]); + assert!( + fold.deny_reason.is_some(), + "exit code 2 must hard-deny even when stdout says allow" + ); + } + + #[test] + fn hook_fold_deny_wins_over_ask_and_allow() { + let fold = fold_tool_call_before_results(&[ + hook_result(r#"{"decision":"allow"}"#, Some(0)), + hook_result(r#"{"decision":"ask"}"#, Some(0)), + hook_result(r#"{"decision":"deny","reason":"policy"}"#, Some(0)), + ]); + assert_eq!(fold.deny_reason.as_deref(), Some("policy")); + } + + #[test] + fn hook_fold_ask_requires_approval() { + let fold = fold_tool_call_before_results(&[ + hook_result(r#"{"decision":"allow"}"#, Some(0)), + hook_result(r#"{"decision":"ask"}"#, Some(0)), + ]); + assert!(fold.deny_reason.is_none()); + assert!(fold.requires_approval); + } + + #[test] + fn hook_fold_updated_input_last_writer_wins() { + let fold = fold_tool_call_before_results(&[ + hook_result(r#"{"updatedInput":{"command":"first"}}"#, Some(0)), + hook_result(r#"{"updatedInput":{"command":"second"}}"#, Some(0)), + ]); + assert_eq!( + fold.updated_input, + Some(serde_json::json!({"command":"second"})) + ); + } + + #[test] + fn hook_fold_background_results_cannot_steer() { + // Background hooks return exit_code: None immediately — their stdout + // (if any were captured) must not deny, ask, or rewrite input. + let fold = fold_tool_call_before_results(&[hook_result( + r#"{"decision":"deny","reason":"too late"}"#, + None, + )]); + assert_eq!(fold, ToolCallHookFold::default()); + } + + #[test] + fn hook_fold_concatenates_additional_context() { + let fold = fold_tool_call_before_results(&[ + hook_result(r#"{"additionalContext":"one"}"#, Some(0)), + hook_result(r#"{"additionalContext":"two"}"#, Some(0)), + ]); + assert_eq!(fold.additional_context.as_deref(), Some("one\ntwo")); + } + + #[test] + fn hook_fold_legacy_stdout_is_passthrough() { + let fold = fold_tool_call_before_results(&[ + hook_result("", Some(0)), + hook_result("not json at all", Some(0)), + hook_result(r#"{"status":"fine"}"#, Some(1)), + ]); + assert_eq!(fold, ToolCallHookFold::default()); + } + + #[test] + fn hook_gate_denies_with_json_decision_from_executor() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let deny_cmd = if cfg!(windows) { + r#"echo {"decision":"deny","reason":"blocked by project policy"}"# + } else { + r#"echo '{"decision":"deny","reason":"blocked by project policy"}'"# + }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, deny_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("exec_shell"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + let fold = fold_tool_call_before_results(&results); + assert_eq!( + fold.deny_reason.as_deref(), + Some("blocked by project policy"), + "JSON deny with exit code 0 must block: {results:?}" + ); + } + + #[test] + fn hook_gate_ask_forces_approval_from_executor() { + use crate::hooks::{Hook, HookContext, HookEvent, HookExecutor, HooksConfig}; + + let ask_cmd = if cfg!(windows) { + r#"echo {"decision":"ask"}"# + } else { + r#"echo '{"decision":"ask"}'"# + }; + let config = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, ask_cmd)], + ..HooksConfig::default() + }; + let executor = HookExecutor::new(config, std::path::PathBuf::from(".")); + let ctx = HookContext::new().with_tool_name("write_file"); + let results = executor.execute(HookEvent::ToolCallBefore, &ctx); + + let fold = fold_tool_call_before_results(&results); + assert!(fold.deny_reason.is_none()); + assert!(fold.requires_approval); + } } diff --git a/crates/tui/src/hooks.rs b/crates/tui/src/hooks.rs index c6784f05..6511dbd8 100644 --- a/crates/tui/src/hooks.rs +++ b/crates/tui/src/hooks.rs @@ -530,38 +530,28 @@ pub enum ToolCallDecision { Ask, } -fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout { +pub(crate) fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout { + let passthrough = ToolCallBeforeStdout { + decision: None, + reason: None, + updated_input: None, + additional_context: None, + }; let trimmed = stdout.trim(); if trimmed.is_empty() { - return ToolCallBeforeStdout { - decision: None, - reason: None, - updated_input: None, - additional_context: None, - }; + return passthrough; } 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, - }; - } + // Non-JSON stdout → legacy passthrough (allow). + Err(_) => return passthrough, }; - let obj = match value.as_object() { - Some(o) => o, - None => { - return ToolCallBeforeStdout { - decision: None, - reason: None, - updated_input: None, - additional_context: None, - }; - } + let Some(obj) = value.as_object() else { + tracing::warn!( + "tool_call_before hook stdout is JSON but not an object; \ + ignoring it (legacy passthrough)" + ); + return passthrough; }; let decision = obj .get("decision") @@ -570,13 +560,26 @@ fn parse_tool_call_before_stdout(stdout: &str) -> ToolCallBeforeStdout { "allow" => Some(ToolCallDecision::Allow), "deny" => Some(ToolCallDecision::Deny), "ask" => Some(ToolCallDecision::Ask), - _ => None, + other => { + tracing::warn!( + "tool_call_before hook returned unrecognized decision \ + '{other}' (expected allow|deny|ask); treating as allow" + ); + 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 updated_input = obj.get("updatedInput").cloned().filter(|v| { + if v.is_object() { + true + } else { + tracing::warn!("tool_call_before hook updatedInput must be a JSON object; ignoring"); + false + } + }); let additional_context = obj .get("additionalContext") .and_then(|v| v.as_str()) @@ -2269,4 +2272,216 @@ exit 7 assert!(!executor.has_hooks_for_event(HookEvent::OnError)); assert!(!executor.has_hooks_for_event(HookEvent::ModeChange)); } + + // ── #3026: tool_call_before stdout decision contract ────────────────── + + #[test] + fn tool_call_before_stdout_parses_deny_with_reason() { + let parsed = + parse_tool_call_before_stdout(r#"{"decision":"deny","reason":"blocked by policy"}"#); + assert_eq!(parsed.decision, Some(ToolCallDecision::Deny)); + assert_eq!(parsed.reason.as_deref(), Some("blocked by policy")); + assert!(parsed.updated_input.is_none()); + assert!(parsed.additional_context.is_none()); + } + + #[test] + fn tool_call_before_stdout_parses_ask_and_allow() { + let ask = parse_tool_call_before_stdout(r#"{"decision":"ask"}"#); + assert_eq!(ask.decision, Some(ToolCallDecision::Ask)); + + let allow = parse_tool_call_before_stdout(r#"{"decision":"allow"}"#); + assert_eq!(allow.decision, Some(ToolCallDecision::Allow)); + } + + #[test] + fn tool_call_before_stdout_parses_updated_input_object() { + let parsed = + parse_tool_call_before_stdout(r#"{"updatedInput":{"command":"ls -la","timeout":5}}"#); + assert!(parsed.decision.is_none()); + assert_eq!( + parsed.updated_input, + Some(serde_json::json!({"command":"ls -la","timeout":5})) + ); + } + + #[test] + fn tool_call_before_stdout_rejects_non_object_updated_input() { + let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":"rm -rf /"}"#); + assert!( + parsed.updated_input.is_none(), + "updatedInput must be a JSON object" + ); + let parsed = parse_tool_call_before_stdout(r#"{"updatedInput":[1,2]}"#); + assert!(parsed.updated_input.is_none()); + } + + #[test] + fn tool_call_before_stdout_parses_additional_context() { + let parsed = + parse_tool_call_before_stdout(r#"{"additionalContext":"remember the style guide"}"#); + assert_eq!( + parsed.additional_context.as_deref(), + Some("remember the style guide") + ); + } + + #[test] + fn tool_call_before_stdout_empty_and_non_json_are_passthrough() { + for stdout in ["", " \n ", "ok, proceeding", "exit code zero"] { + let parsed = parse_tool_call_before_stdout(stdout); + assert!(parsed.decision.is_none(), "stdout {stdout:?}"); + assert!(parsed.reason.is_none()); + assert!(parsed.updated_input.is_none()); + assert!(parsed.additional_context.is_none()); + } + } + + #[test] + fn tool_call_before_stdout_json_without_decision_is_passthrough() { + let parsed = parse_tool_call_before_stdout(r#"{"status":"fine"}"#); + assert!(parsed.decision.is_none()); + } + + #[test] + fn tool_call_before_stdout_non_object_json_is_passthrough() { + for stdout in [r#""deny""#, "[1,2,3]", "42", "true"] { + let parsed = parse_tool_call_before_stdout(stdout); + assert!(parsed.decision.is_none(), "stdout {stdout:?}"); + } + } + + #[test] + fn tool_call_before_stdout_unknown_decision_treated_as_allow() { + let parsed = parse_tool_call_before_stdout(r#"{"decision":"block"}"#); + assert!(parsed.decision.is_none()); + } + + // ── #3026: glob matchers for tool_name conditions ────────────────────── + + #[test] + fn tool_name_glob_matches_mcp_prefix() { + assert!(HookExecutor::tool_name_matches_condition( + "mcp__github__create_issue", + "mcp__*" + )); + assert!(!HookExecutor::tool_name_matches_condition( + "read_file", + "mcp__*" + )); + } + + #[test] + fn tool_name_exact_match_still_works() { + assert!(HookExecutor::tool_name_matches_condition( + "read_file", + "read_file" + )); + assert!(!HookExecutor::tool_name_matches_condition( + "read_files", + "read_file" + )); + } + + #[test] + fn tool_name_glob_escapes_regex_metacharacters() { + // Without escaping, `.` would match any character. + assert!(!HookExecutor::tool_name_matches_condition( + "mcpXgithub", + "mcp.git*" + )); + assert!(HookExecutor::tool_name_matches_condition( + "mcp.github", + "mcp.git*" + )); + // `+` and parens must be literal too. + assert!(HookExecutor::tool_name_matches_condition( + "weird+tool(name)", + "weird+tool(*)" + )); + } + + #[test] + fn tool_name_glob_supports_infix_and_suffix_positions() { + assert!(HookExecutor::tool_name_matches_condition( + "mcp__github__create_issue", + "mcp__*__create_issue" + )); + assert!(HookExecutor::tool_name_matches_condition( + "task_shell_start", + "*_shell_start" + )); + assert!(!HookExecutor::tool_name_matches_condition( + "task_shell_wait", + "*_shell_start" + )); + } + + // ── #3026: project-local hooks ───────────────────────────────────────── + + #[test] + fn load_with_project_missing_file_keeps_global() { + let dir = tempfile::tempdir().expect("tempdir"); + let global = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")], + ..HooksConfig::default() + }; + + let merged = HooksConfig::load_with_project(global.clone(), dir.path()); + assert_eq!(merged.hooks.len(), 1); + assert_eq!(merged.hooks[0].command, "echo global"); + } + + #[test] + fn load_with_project_appends_project_hooks_after_global() { + let dir = tempfile::tempdir().expect("tempdir"); + let project_dir = dir.path().join(".codewhale"); + std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale"); + std::fs::write( + project_dir.join("hooks.toml"), + r#" +[[hooks]] +event = "tool_call_before" +command = "echo project" +"#, + ) + .expect("write hooks.toml"); + + let global = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")], + ..HooksConfig::default() + }; + + let merged = HooksConfig::load_with_project(global, dir.path()); + assert_eq!(merged.hooks.len(), 2); + assert_eq!( + merged.hooks[0].command, "echo global", + "global hooks run first" + ); + assert_eq!( + merged.hooks[1].command, "echo project", + "project hooks are appended after global" + ); + } + + #[test] + fn load_with_project_malformed_file_falls_back_to_global() { + let dir = tempfile::tempdir().expect("tempdir"); + let project_dir = dir.path().join(".codewhale"); + std::fs::create_dir_all(&project_dir).expect("mkdir .codewhale"); + std::fs::write(project_dir.join("hooks.toml"), "this is [ not toml") + .expect("write hooks.toml"); + + let global = HooksConfig { + enabled: true, + hooks: vec![Hook::new(HookEvent::ToolCallBefore, "echo global")], + ..HooksConfig::default() + }; + + let merged = HooksConfig::load_with_project(global, dir.path()); + assert_eq!(merged.hooks.len(), 1, "malformed project file is ignored"); + assert_eq!(merged.hooks[0].command, "echo global"); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 37bf84c5..9c44c545 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2029,8 +2029,10 @@ impl App { let allow_shell = allow_shell || initial_mode == AppMode::Yolo; let shell_manager = new_shared_shell_manager(workspace.clone()); - // Initialize hooks executor from config - let hooks_config = config.hooks_config(); + // Initialize hooks executor from config, merged with project-local + // `.codewhale/hooks.toml` (#3026). + let hooks_config = + crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace); let hooks = HookExecutor::new(hooks_config, workspace.clone()); // Initialize plan state diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3481d43e..a7ecdbf3 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6469,7 +6469,10 @@ fn spawn_external_url_command(mut command: Command) -> Result<()> { fn apply_workspace_runtime_state(app: &mut App, config: &Config, workspace: PathBuf) { app.workspace = workspace.clone(); - app.hooks = HookExecutor::new(config.hooks_config(), workspace.clone()); + app.hooks = HookExecutor::new( + crate::hooks::HooksConfig::load_with_project(config.hooks_config(), &workspace), + workspace.clone(), + ); app.skills_dir = crate::tui::app::resolve_skills_dir(&workspace, &config.skills_dir(), config); app.refresh_skill_cache(); app.workspace_context = None; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0e0338f..33efc879 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -612,7 +612,94 @@ receives the text produced by the previous hook. Hooks marked `background = true` are observer-only and cannot transform or block the message. Existing environment variables remain available. `shell_env` hooks keep their existing `KEY=VALUE` stdout contract; -the JSON stdout contract applies only to `message_submit`. +JSON stdout contracts exist for `message_submit` (above) and +`tool_call_before` (below). + +### `tool_call_before` decision hooks + +`tool_call_before` hooks run before each tool call executes. In +addition to the legacy hard deny (exit code `2`, which always wins +regardless of stdout), a foreground hook may print a JSON decision on +stdout with exit code `0`: + +```json +{ + "decision": "allow" | "deny" | "ask", + "reason": "human-readable explanation (used for deny)", + "updatedInput": { "command": "ls -la" }, + "additionalContext": "text appended to the tool result for the model" +} +``` + +All fields are optional. Empty stdout, non-JSON stdout, and JSON +without a `decision` field behave exactly as before (allow). An +unrecognized `decision` string logs a warning and is treated as allow. + +- `deny` blocks the tool; the model receives a permission-denied tool + result containing `reason`. +- `ask` forces the interactive approval prompt even for tools that + would otherwise auto-run. +- `updatedInput` must be a JSON object; it replaces the tool input + before execution. When several hooks supply it, the last hook wins. +- `additionalContext` is appended to the tool result sent back to the + model as `[hook context] ...`. Multiple hooks' contexts are + concatenated. + +When multiple hooks match, precedence is deny > ask > allow. Hooks +marked `background = true` cannot steer tool calls — they exit +immediately without a captured result. + +Example deny hook: + +```toml +[[hooks.hooks]] +event = "tool_call_before" +command = '''echo '{"decision":"deny","reason":"blocked by project policy"}' ''' +condition = { type = "tool_name", name = "exec_shell" } +``` + +Example ask hook (force approval for every MCP tool): + +```toml +[[hooks.hooks]] +event = "tool_call_before" +command = '''echo '{"decision":"ask"}' ''' +condition = { type = "tool_name", name = "mcp__*" } +``` + +Example input rewrite: + +```toml +[[hooks.hooks]] +event = "tool_call_before" +command = "~/.codewhale/hooks/clamp-shell-timeout.sh" +condition = { type = "tool_name", name = "exec_shell" } +``` + +where the script reads the hook context, then prints +`{"updatedInput": {...}}` with the adjusted arguments. + +`tool_name` conditions support `*` globs: `mcp__*` matches every MCP +tool (e.g. `mcp__github__create_issue`) but not built-ins like +`read_file`; exact names keep matching exactly. Other regex +metacharacters in the pattern are matched literally. + +### Project-local hooks + +Repositories can ship policy in `/.codewhale/hooks.toml`, +using the same shape as the `[hooks]` table (top-level fields plus +`[[hooks]]` entries). Project hooks are appended after global hooks +from `config.toml`, so they run last and, for `updatedInput`, win +ties. A malformed project file logs a warning and startup falls back +to global hooks only. + +```toml +# .codewhale/hooks.toml +[[hooks]] +event = "tool_call_before" +command = '''echo '{"decision":"deny","reason":"no shell in this repo"}' ''' +condition = { type = "tool_name", name = "exec_shell" } +``` ### Turn-end observer hooks