feat(hooks): wire the #3026 decision contract into the engine — JSON deny/ask/updatedInput now steer tool calls (deny>ask>allow, last-writer updatedInput), additionalContext piped into tool results, project .codewhale/hooks.toml loaded at both HookExecutor sites; parser hardening + fold/glob/project tests; docs
Co-Authored-By: Claude <noreply@anthropic.com> https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5
This commit is contained in:
@@ -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<String> =
|
||||
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<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
let mut plans: Vec<ToolExecutionPlan> = 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<ToolError> = None;
|
||||
let mut guard_result: Option<ToolResult> = 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<String>,
|
||||
/// 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<serde_json::Value>,
|
||||
/// Concatenated `additionalContext` strings from all hooks.
|
||||
additional_context: Option<String>,
|
||||
}
|
||||
|
||||
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<i32>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+243
-28
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+88
-1
@@ -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 `<workspace>/.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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user