fix(hooks): treat bash, Bash and exec_shell as one tool in tool_name conditions

tool_category_for already classified the three spellings as the shell
tool, but tool_name_matches_condition compared names literally, so a
hook written with `exec_shell` (the documented example) never fired for
`bash` calls and vice versa. Both now share is_shell_tool_name.

HOOKS.md examples use `bash` with an alias note, and the configuration
comment now says 15 event names, matching the events table.

Checks: cargo test -p codewhale-tui --lib -- tool_name_ fleet::exact::
hooks::executor: 161 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TvKGZz4LrKfrQiAXip48JS
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 20:12:32 -07:00
1 parent 75cd6acb1c
commit 7e5793a29e
2 files changed
+40 -5

No files matched your search

+36 -1
View File
@@ -2072,6 +2072,13 @@ impl HookExecutor {
if tool_name == pattern {
return true;
}
// The shell tool is spelled `bash` / `Bash` on the model surface, and
// `exec_shell` is still stamped for the `shell_env` event and lives
// on in older hook configs. Treat the three as one tool, matching
// `tool_category_for`, so a condition written with any spelling fires.
if is_shell_tool_name(tool_name) && is_shell_tool_name(pattern) {
return true;
}
if let Some(rest) = pattern.strip_prefix("mcp_") {
let documented = rest.strip_prefix('_');
if documented.is_some() || pattern.contains('*') {
@@ -2542,6 +2549,11 @@ fn is_mcp_server_tool(name: &str) -> bool {
/// An unparseable or absent argument blob is treated as the tool's most
/// dangerous action, because a gate that cannot see the action must not
/// assume the harmless one.
/// The spellings of the one shell tool (see `tool_category_for`).
fn is_shell_tool_name(name: &str) -> bool {
matches!(name, "bash" | "Bash" | "exec_shell")
}
fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
let action = tool_args
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
@@ -2555,7 +2567,7 @@ fn tool_category_for(tool_name: &str, tool_args: Option<&str>) -> &'static str {
match tool_name {
// The shell surface. `exec_shell` is retired but kept here because
// `shell.rs` still stamps it for the `shell_env` hook event.
"bash" | "Bash" | "exec_shell" => "shell",
name if is_shell_tool_name(name) => "shell",
// The lowercase primitives ship without an action envelope.
"read" | "todo_write" => "safe",
"write" | "edit" => "file_write",
@@ -4397,6 +4409,29 @@ exit 7
));
}
#[test]
fn tool_name_shell_spellings_match_each_other_in_both_directions() {
let spellings = ["bash", "Bash", "exec_shell"];
for tool in spellings {
for pattern in spellings {
assert!(
HookExecutor::tool_name_matches_condition(tool, pattern),
"tool {tool} should match condition {pattern}"
);
}
}
// The alias is exact: it does not widen to other shell-ish tools.
assert!(!HookExecutor::tool_name_matches_condition(
"task_shell_start",
"bash"
));
assert!(!HookExecutor::tool_name_matches_condition(
"bash",
"read_file"
));
assert!(!HookExecutor::tool_name_matches_condition("BASH", "bash"));
}
#[test]
fn tool_name_glob_escapes_regex_metacharacters() {
// Without escaping, `.` would match any character.
+4 -4
View File
@@ -68,13 +68,13 @@ default_timeout_secs = 30 # see the timeout note below
working_dir = "/path/to/dir" # default: the session workspace
[[hooks.hooks]]
event = "tool_call_before" # required; one of the 11 names below
event = "tool_call_before" # required; one of the 15 names below
command = "~/.codewhale/hooks/gate.sh" # required; `sh -c` on Unix, `cmd /C` on Windows
name = "gate" # optional label for /hooks and log lines
timeout_secs = 30 # optional, default 30
background = false # optional; foreground inside the hook worker
continue_on_error = true # optional, default true
condition = { type = "tool_name", name = "exec_shell" } # optional
condition = { type = "tool_name", name = "bash" } # optional
```
`timeout_secs` note, stated as implemented: when `[hooks].default_timeout_secs`
@@ -173,7 +173,7 @@ configured instead (the backend owns its base environment, and your
| Condition | Matches | Supported on |
| --- | --- | --- |
| `{ type = "always" }` | every invocation (also the default when omitted) | every event |
| `{ type = "tool_name", name = "exec_shell" }` | exact tool name; `*` globs are supported, e.g. `mcp__*` | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` |
| `{ type = "tool_name", name = "bash" }` | exact tool name; `*` globs are supported, e.g. `mcp__*`. The shell tool's spellings `bash`, `Bash`, and `exec_shell` are aliases: a condition naming any one matches all three | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` |
| `{ type = "tool_category", category = "shell" }` | tool category | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` |
| `{ type = "mode", mode = "plan" }` | the context's mode string, case-insensitive | every event **except** `shell_env` |
| `{ type = "exit_code", code = 1 }` | the exit code the tool actually reported | `tool_call_after`, `on_error` |
@@ -184,7 +184,7 @@ Three rules keep conditions from lying:
- **`exit_code` needs a real exit code.** It matches only when the event
actually observed a process exit code — `tool_call_after`, or `on_error` for
a tool failure, in both cases for a process-backed tool such as `exec_shell`.
a tool failure, in both cases for a process-backed tool such as `bash`.
A tool that reports no exit code never matches an `exit_code` condition; the
condition is not satisfied by a default, a zero, or a success flag. The value
is a 64-bit integer, so a Windows crash code such as `3221225477`