SKILL.md

  1---
  2name: crush-hooks
  3description: Use when the user wants to add, write, debug, or configure a Crush hook — gating or blocking tool calls, approving or rewriting tool input before execution, injecting context into tool results, or troubleshooting hook behavior in crush.json.
  4---
  5
  6# Crush Hooks
  7
  8Hooks are user-defined commands in `crush.json` that fire at specific points
  9during execution, giving deterministic control over tool behavior. They run
 10**before** permission checks and **only on the top-level agent's** tool calls —
 11sub-agent calls (task tool, agentic_fetch, etc.) are not intercepted, though
 12the sub-agent tool call itself is.
 13
 14For the full reference, see `docs/hooks/README.md`. This skill covers what you
 15need to author correct hooks.
 16
 17## Supported Events
 18
 19Only `PreToolUse` is currently supported. Event names are case-insensitive and
 20accept snake_case (`PreToolUse`, `pretooluse`, `pre_tool_use` all work).
 21
 22## Configuration
 23
 24```jsonc
 25{
 26  "hooks": {
 27    "PreToolUse": [
 28      {
 29        "matcher": "^bash$",              // regex against tool name (optional; omit to match all)
 30        "command": "./hooks/my-hook.sh",   // required: shell command to run
 31        "timeout": 10                     // optional: seconds, default 30
 32      }
 33    ]
 34  }
 35}
 36```
 37
 38Project-level hooks take precedence over global. Matching hooks are deduped by
 39`command`, run in parallel, and aggregated in **config order** (not finish order).
 40
 41## Language
 42
 43`command` is a shell command, so hooks can be written in any language by
 44invoking the interpreter: `node ./hooks/h.js`, `python3 ./hooks/h.py`,
 45`./hooks/h.sh`, inline `echo '…'`, etc. The rest of this skill shows bash, but
 46the input/output contract is identical regardless of language.
 47
 48## Input
 49
 50**Environment variables:**
 51
 52| Variable                     | Description                              |
 53| ---------------------------- | ---------------------------------------- |
 54| `CRUSH_EVENT`                | Event name (e.g. `PreToolUse`)           |
 55| `CRUSH_TOOL_NAME`            | Tool being called (e.g. `bash`)          |
 56| `CRUSH_SESSION_ID`           | Current session ID                       |
 57| `CRUSH_CWD`                  | Working directory                        |
 58| `CRUSH_PROJECT_DIR`          | Project root directory                   |
 59| `CRUSH_TOOL_INPUT_COMMAND`   | For `bash` calls: the shell command      |
 60| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path     |
 61
 62**JSON on stdin:**
 63
 64```json
 65{
 66  "event": "PreToolUse",
 67  "session_id": "313909e",
 68  "cwd": "/home/user/project",
 69  "tool_name": "bash",
 70  "tool_input": {"command": "rm -rf /"}
 71}
 72```
 73
 74## Output
 75
 76Communicate back via exit code (+ stderr) or JSON on stdout.
 77
 78| Exit Code | Meaning                                                       |
 79| --------- | ------------------------------------------------------------- |
 80| 0         | Success. Stdout is parsed as the JSON envelope below.         |
 81| 2         | Block this tool call. Stderr becomes the deny reason.         |
 82| 49        | Halt the whole turn. Stderr becomes the halt reason.          |
 83| Other     | Non-blocking error. Logged and ignored; tool call proceeds.   |
 84
 85Exit 2 blocks one tool call (agent sees the reason and can try again); exit 49
 86ends the whole turn (user takes over). Default to deny — reach for halt only
 87when letting the agent retry is itself the problem (e.g. secrets detected,
 88policy violation).
 89
 90**JSON envelope (exit 0):**
 91
 92```json
 93{
 94  "version": 1,
 95  "decision": "allow",
 96  "halt": false,
 97  "reason": "...",
 98  "context": "Extra info for the model",
 99  "updated_input": {"command": "rewritten"}
100}
101```
102
103- **`decision`**: `"allow"`, `"deny"`, or omit. `"allow"` is **affirmative
104  pre-approval** — it bypasses the permission prompt entirely. Omit it
105  (or `null`) when you only want to inject context or rewrite input without
106  also auto-approving the call.
107- **`halt: true`**: ends the turn (same as exit 49).
108- **`reason`**: shown to the model on deny; to model and user on halt.
109- **`context`**: string **or array of strings**. Appended to what the model
110  sees. Empty entries are dropped.
111- **`updated_input`**: **shallow-merge patch** against `tool_input`, not a
112  replacement. Keys you include overwrite; keys you don't are preserved.
113  Nested objects are replaced wholesale, not deep-merged. Ignored on deny/halt.
114
115## Aggregation (Multiple Hooks)
116
117Composed in **config order**:
118
119- `deny` > `allow` > no opinion. First deny decides; subsequent allows don't override.
120- `halt` is sticky: any hook halting ends the turn.
121- `reason` and `context` concatenate in config order (newline-joined).
122- `updated_input` patches shallow-merge sequentially; later patches win on colliding keys.
123
124## Canonical Examples
125
126### Block destructive commands
127
128```bash
129#!/usr/bin/env bash
130set -euo pipefail
131
132if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
133  echo "Refusing to run rm -rf against root" >&2
134  exit 2
135fi
136```
137
138Config: `{"matcher": "^bash$", "command": "./hooks/no-rm-rf.sh"}`
139
140### Auto-approve read-only tools (inline, no script)
141
142```jsonc
143{"matcher": "^(view|ls|grep|glob)$", "command": "echo '{\"decision\":\"allow\"}'"}
144```
145
146Every `view`/`ls`/`grep`/`glob` call now runs without prompting.
147
148### Inject context without auto-approving
149
150Emit only `context` — omit `decision` so the normal permission flow still runs.
151
152```bash
153#!/usr/bin/env bash
154set -euo pipefail
155
156if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
157  echo '{"context": "Remember: run gofumpt after editing Go files."}'
158else
159  echo '{}'
160fi
161```
162
163Config: `{"matcher": "^(edit|write|multiedit)$", "command": "./hooks/go-context.sh"}`
164
165### Rewrite tool input (shallow merge)
166
167```bash
168#!/usr/bin/env bash
169set -euo pipefail
170
171read -r input
172rewritten=$(echo "$input" | jq -r '.tool_input.command' | some-rewriter)
173
174cat <<EOF
175{
176  "context": "Rewrote command",
177  "updated_input": {"command": "$rewritten"}
178}
179EOF
180```
181
182If the original call was `{"command": "npm test", "timeout": 60000}`, the
183tool runs with `{"command": "<rewritten>", "timeout": 60000}``timeout` is
184preserved.
185
186## Authoring Checklist
187
1881. Add `#!/usr/bin/env bash` and `set -euo pipefail` (for shell scripts).
1892. `chmod +x` the script.
1903. Add the entry under `hooks.PreToolUse` in `crush.json` with the right matcher.
1914. Decide intent: inject context (omit `decision`), auto-approve (`"allow"`),
192   block (`exit 2`), or halt (`exit 49`).
1935. If rewriting input, remember `updated_input` is a shallow merge — only
194   include the keys you want to change.
195
196## Debugging
197
198- Timeouts kill the hook silently and the tool call proceeds. Bump `timeout` if needed.
199- Non-zero exit codes other than 2/49 are logged but don't block — check Crush logs.
200- Use `echo "debug info" >&2` for logging without corrupting stdout JSON.
201- `matcher` is a regex against the tool name. Use `^bash$` (not `bash`) if you
202  don't also want to match `mcp_something_bash`.
203
204## Claude Code Compatibility
205
206Crush also accepts Claude Code's `hookSpecificOutput` envelope. One intentional
207divergence: Crush treats `updated_input` as shallow-merge, Claude Code replaces.
208Existing Claude Code hooks work without modification for the matcher/decision
209parts; revisit any that relied on `updatedInput` fully replacing tool input.