How Hooks Run
Execution model
Hooks run through Crush's embedded POSIX shell (mvdan.cc/sh) — the same interpreter the bash tool uses. Inline commands and shebang-less scripts execute in-process; scripts with a #! shebang dispatch to the named interpreter via os/exec. This contract is identical on macOS, Linux, and Windows.
What this means in practice:
- Windows without Unix tooling: inline shell (
echo, pipelines,jq,grep), shebang-less.shscripts, inline PowerShell (powershell -Command …), and.exeinvocations all work out of the box with no WSL, Git Bash, Cygwin, or MSYS required. - PowerShell scripts (
.ps1) are not auto-dispatched by extension. Invoke them explicitly:powershell -File ./audit.ps1(orpwsh -File ./audit.ps1). - Shebang'd scripts require the named interpreter on
PATH. Git for Windows shipsbash.exe, which makes#!/bin/bashand#!/usr/bin/env bashscripts work on Windows the same way they do on Unix. CRLF line endings in the shebang line are tolerated. - Permissive shebang fallback: if the absolute path in a shebang doesn't exist (e.g.
#!/bin/bashon Windows), Crush falls back to aPATHlookup of the base name (bash) before giving up. A debug-level log records the fallback. If the interpreter isn't onPATHeither, the hook fails cleanly as a non-blocking warning and the agent proceeds as "no opinion". - Environment: every hook sees
CRUSH=1,AGENT=crush, andAI_AGENT=crushon top of theCRUSH_*hook-specific variables. These three markers are guaranteed and match what thebashtool sets, so scripts that detect "am I being run by an AI agent?" behave the same in both contexts. - Timeout behavior: when a hook exceeds its timeout, Crush cancels the context and waits a short grace period (~1s) for the interpreter to yield. If the hook still hasn't returned, Crush abandons it, logs a warning, and treats the result as "no opinion" so the agent can proceed. Long-running work should honor context cancellation or run in a subprocess via a shebang.
Configuration
Hooks can be added to your crush.json (or .crush.json) at both the global and project-level, with project level hooks taking precedence.
{
"hooks": {
"PreToolUse": [
{
"name": "no-rm-rf", // friendly name shown in the TUI
"matcher": "bash", // regex tested against the tool name
"command": "./hooks/my-hot-hook.sh", // the path to the hook
"timeout": 10, // in seconds; default 30
},
],
},
}IMPORTANT
The command is resolved relative to your current working directory — not relative to the config file. Relative paths like ./hooks/whatever.sh work fine in project-level crush.json because the project root is also your working directory. For global config (~/.config/crush/), however, you must use either an absolute path or an inline command:
// Global ~/.config/crush/crush.json
{
"hooks": {
"PreToolUse": [
{
"command": "/home/you/.config/crush/hooks/no-haskell.sh",
// or use an inline command:
// "command": "echo '{\"decision\":\"allow\"}'"
},
],
},
}You can also manage hooks from your crushrc with hook add / hook remove:
Usage:
hook add <event> --command <command> [flags]
Flags:
--command string shell command to run (required)
--name string name used for later removal
--matcher string regex tested against the tool name
--timeout int timeout in seconds (default 30)hook add PreToolUse --matcher "^bash$" \
--command "./hooks/no-haskell.sh" --name no-haskellEvents
Here are the events you can hook into (spoiler: there's currently just one):
PreToolUse
This hook fires before every tool call. Use it to block dangerous commands, enforce policies, rewrite tool input, inject context the model should see, log stuff, and so on.
Matched against: the tool name (e.g. bash, edit, write, mcp_github_create_pull_request).
NOTE
Event names are case insensitive and snake-caseable, so PreToolUse, pretooluse, PRETOOLUSE, pre_tool_use, and PRE_TOOL_USE all work.
Scope: PreToolUse only fires on the top-level agent's tool calls. Sub-agents (the agent task tool, agentic_fetch, etc.) run without hook interception so a single delegated turn doesn't trigger your hook N times. The outer sub-agent tool call itself is hooked, so policy like "never let the agent spawn sub-agents" still works.
Hooks are keyed by event name. Only command is required, and you can omit matcher to match all tools.
Building Hooks
When a hook fires, Crush:
- Filters hooks whose
matcherregex matches the tool name (no matcher = match all). - Deduplicates by
command(identical commands run once). - Runs all matching hooks in parallel through Crush's embedded POSIX shell (see Execution model).
- Waits for all to finish (or time out), then aggregates results in config order: deny wins over allow, allow wins over none;
updated_inputpatches shallow-merge in order. - Applies the result before permission checks. If the aggregated decision is
deny, the tool call is blocked and you never see a permission prompt for it. If it'sallow, Crush treats that as affirmative pre-approval and also skips the prompt. Silence (no decision) falls through to the normal permission flow.
Note that you can omit matcher and match in your shell script instead, however you'll incur some additional overhead as Crush will still parse and run each hook.
Multiple Hooks
Hooks run in parallel, but their results compose in config order. Whichever hook finishes first doesn't get to "win" by virtue of timing; composition is deterministic based on the order hooks appear in crush.json.
When multiple hooks match the same tool call:
- If any hook denies, the tool call is blocked.
reasonvalues are concatenated in config order (newline-separated). - If any hook halts, the turn ends after the tool call is blocked.
- If no hook denies or halts but at least one allows, the tool call proceeds and the permission prompt is skipped.
contextvalues are concatenated in config order. Strings and arrays compose uniformly — each string becomes one entry, and array entries are flattened in.updated_inputpatches shallow-merge in config order against the original tool input. Later hooks override earlier ones on colliding keys. If denied or halted,updated_inputpatches are ignored.
Timeouts
If a hook exceeds its timeout, Crush cancels its context and treats the result as a non-blocking error so the tool call proceeds. The default timeout is 30 seconds. Shebang-dispatched subprocesses are killed through exec.CommandContext; in-process hooks get a short grace period to yield and are then abandoned (the agent moves on regardless). Long-running work should honor context cancellation or run out-of-process via a shebang.
