README.md

  1# Hooks
  2
  3> [!NOTE] This document was designed for both humans and agents.
  4
  5Hooks are user-defined shell commands that fire at specific points during
  6Crush's execution, giving you deterministic control over an agent's wily
  7behavior.
  8
  9Hooks in Crush are shell-based, with a focus on simplicity. This allows hooks to
 10effectively be written in any language. In this document we'll primarily focus
 11on Bash for simplicity's sake, though we'll include some examples in other
 12languages at the end, too.
 13
 14### Hot Hook Facts
 15
 16- Hooks run before permission checks. If a hook denies a tool call, you'll
 17  never see a permission prompt for it. If a hook explicitly allows a tool
 18  call, you'll _also_ never see a prompt β€” Crush treats `"decision": "allow"`
 19  as affirmative pre-approval.
 20- Hooks only fire on the **top-level agent's** tool calls. Sub-agents (the
 21  `agent` task tool, `agentic_fetch`, etc.) run without hook interception so
 22  a single delegated turn doesn't trigger your hook N times. The outer
 23  sub-agent tool call itself _is_ hooked, so policy like "never let the
 24  agent spawn sub-agents" still works.
 25- Hooks are also compatible with hooks in Claude Code, however this document
 26  covers the Crush-specific API only. One intentional divergence: Crush treats
 27  `updated_input` as a shallow-merge patch rather than a full replacement β€” see
 28  [Output](#output) below.
 29- Crush currently supports just one hook, `PreToolUse`, with plans to support
 30  the full gamut. If there's a hook you'd like to see, let us know.
 31
 32## Configuration
 33
 34Hooks can be added to your `crush.json` at both the global and project-level,
 35with project level hooks taking precedence.
 36
 37```jsonc
 38{
 39  "hooks": {
 40    "PreToolUse": [
 41      {
 42        "matcher": "bash", // regex tested against the tool name
 43        "command": "./hooks/my-hot-hook.sh", // the path to the hook
 44        "timeout": 10, // in seconds; default 30
 45      },
 46    ],
 47  },
 48}
 49```
 50
 51Hooks are keyed by event name. Only `command` is required; omit `matcher` to
 52match all tools.
 53
 54## Events
 55
 56Here are the events you can hook into (spoiler: there's currently just one):
 57
 58### PreToolUse
 59
 60This hook fires before every tool call. Use it to block dangerous commands,
 61enforce policies, rewrite tool input, or inject context the model should see.
 62
 63**Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
 64`mcp_github_create_pull_request`).
 65
 66> [!NOTE] Event names are case insensitive and snake-caseable, so `PreToolUse`,
 67> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
 68
 69## Baby's First Hook
 70
 71Hooks are just shell scripts. Go crazy.
 72
 73```bash
 74#!/usr/bin/env bash
 75
 76# Log all bash tool calls to a file.
 77printf "%s: %s / %s" \
 78    "$(date -Iseconds)" \
 79    "$CRUSH_SESSION_ID" \
 80    "$CRUSH_TOOL_INPUT_COMMAND" >> ./bash.log
 81```
 82
 83That's basically it. For the full guide on how hooks work, however, read on.
 84
 85## Building Hooks
 86
 87When a hook fires, Crush:
 88
 891. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
 90   all).
 912. Deduplicates by `command` (identical commands run once).
 923. Runs all matching hooks **in parallel** as subprocesses.
 934. Waits for all to finish (or time out), then aggregates results **in config
 94   order**: deny wins over allow, allow wins over none; `updated_input` patches
 95   shallow-merge in order.
 96
 97Note that you can omit `matcher` and match in your shell script instead, however
 98you'll incur some additional overhead as Crush will `exec` each script.
 99
100### Input
101
102Each hook receives data two ways: environment variables and stdin (as JSON).
103Environment variables are typically easier to work with, with JSON being
104available when input is more complex.
105
106#### Environment Variables
107
108The available environment variables are:
109
110| Variable                     | Description                                    |
111| ---------------------------- | ---------------------------------------------- |
112| `CRUSH_EVENT`                | The hook event name (e.g. `PreToolUse`).       |
113| `CRUSH_TOOL_NAME`            | The tool being called (e.g. `bash`).           |
114| `CRUSH_SESSION_ID`           | Current session ID.                            |
115| `CRUSH_CWD`                  | Working directory.                             |
116| `CRUSH_PROJECT_DIR`          | Project root directory.                        |
117| `CRUSH_TOOL_INPUT_COMMAND`   | For `bash` calls: the shell command being run. |
118| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path.          |
119
120#### JSON
121
122Standard input provides the full context as JSON:
123
124```jsonc
125{
126  "event": "PreToolUse", // Hook event name
127  "session_id": "313909e", // Current session ID
128  "cwd": "/home/user/project", // Working directory
129  "tool_name": "bash", // The tool being called
130  "tool_input": { "command": "rm -rf /" }, // The tool's input
131}
132```
133
134Note that `tool_input` field contains the raw JSON the model sent to the tool.
135
136To parse the stdin JSON in your hook script, read from stdin and use a tool like
137`jq`:
138
139```bash
140#!/usr/bin/env bash
141read -r input
142tool_name=$(echo "$input" | jq -r '.tool_name')
143command=$(echo "$input" | jq -r '.tool_input.command // empty')
144```
145
146You can also use tools like Python:
147
148```python
149#!/usr/bin/env python3
150import json, sys
151
152data = json.load(sys.stdin)
153tool_name = data.get("tool_name", "")
154command = data.get("tool_input", {}).get("command", "")
155```
156
157### Output
158
159Hooks communicate back to Crush via **exit code** and `stdout`/`stderr`. The
160simplest way to do this is to return an error code and print additional context
161to stderr. For example:
162
163```bash
164# Here, error code 2 blocks the tool, using stderr as the reason:
165if some_bad_condition; then
166  echo "Blocked: reason here" >&2
167  exit 2
168fi
169```
170
171| Exit Code | Meaning                                                          |
172| --------- | ---------------------------------------------------------------- |
173| 0         | Success. Stdout is parsed as JSON (see fields below).            |
174| 2         | **Block the tool.** Stderr is used as the deny reason (no JSON). |
175| 49        | **Halt the turn.** Stderr is used as the halt reason (no JSON).  |
176| Other     | Non-blocking error. Logged and ignored β€” the tool call proceeds. |
177
178The difference between exit 2 and exit 49:
179
180- **Exit 2** blocks the current tool call. The agent sees the error and can try
181  something else.
182- **Exit 49** halts the whole turn. The agent doesn't get to respond further;
183  the user takes over. Use this when something is wrong enough that the agent
184  shouldn't keep trying. 49 sits in an empty slice of the exit-code space β€”
185  between the generic-error range (1-30), the BSD `sysexits.h` range (64-78),
186  and the killed-by-signal range (128+) β€” so it can't be hit by accident.
187
188That said, if you need more control, or if you need to rewrite input, you can
189use JSON on stdout. Exit 0 and print a JSON object to provide context, update
190the input, or still deny/halt with a reason:
191
192```jsonc
193{
194  "version": 1, // Output envelope version. Optional; defaults to 1.
195  "decision": "allow", // "allow", "deny", or null. Omit for no opinion.
196  "halt": false, // If true, halts the turn entirely.
197  "reason": "LGTM", // Shown when denying or halting.
198  "context": "Scrubbed secrets", // String or array of strings. Appended to what the model sees.
199  "updated_input": { "command": "…" }, // Shallow-merged into the tool's input before execution.
200}
201```
202
203`version` is an optional integer at the top of the envelope. It defaults to `1`
204if omitted. Unknown higher versions are still parsed; the field exists so the
205envelope can evolve without a compatibility shim.
206
207`decision: "allow"` is **affirmative**: it pre-approves the tool call and
208bypasses the permission prompt entirely. Silence (no `decision`, or
209`decision: null`) means "no opinion" β€” the tool still goes through the
210normal permission flow. Use `"allow"` when you want to auto-approve; omit it
211when you only want to inject context or rewrite input without also vouching
212for the call.
213
214`updated_input` is a shallow-merge patch. Keys you include overwrite matching
215keys in `tool_input`; keys you don't include are preserved. If the model called
216`bash` with `{"command": "npm test", "timeout": 60000}` and your hook returns
217`{"updated_input": {"command": "bun test"}}`, the tool runs with
218`{"command": "bun test", "timeout": 60000}` β€” the timeout isn't dropped. The
219merge is shallow: nested objects are replaced wholesale, not deep-merged.
220
221`halt: true` stops the turn entirely. The agent doesn't get to respond further;
222the user takes over. The exit-code shorthand is `exit 49` with stderr as the
223reason.
224
225`context` accepts either a string or an array of strings. Use the string form
226for a single observation; use the array form when a hook produces multiple
227distinct notes and you'd rather not concatenate them by hand. Empty strings and
228empty array entries are dropped.
229
230Here's a full shell script that produces this JSON:
231
232```bash
233#!/usr/bin/env bash
234# Example: rewrite a bash command using RTK
235
236read -r input
237original_cmd=$(echo "$input" | jq -r '.tool_input.command')
238rewritten=$(secret-scrubber rewrite "$original_cmd")
239
240cat <<EOF
241{
242  "decision": "allow",
243  "context": "Scrubbed secrets",
244  "updated_input": {"command": "$rewritten"}
245}
246EOF
247```
248
249### Multiple Hooks
250
251Hooks run in parallel, but their results compose in config order. Whichever hook
252finishes first doesn't get to "win" by virtue of timing; composition is
253deterministic based on the order hooks appear in `crush.json`.
254
255When multiple hooks match the same tool call:
256
257- If **any** hook denies, the tool call is blocked. `reason` values are
258  concatenated in config order (newline-separated).
259- If **any** hook halts, the turn ends after the tool call is blocked.
260- If no hook denies or halts but at least one allows, the tool call proceeds
261  **and the permission prompt is skipped**.
262- `context` values are concatenated in config order. Strings and arrays compose
263  uniformly β€” each string becomes one entry, and array entries are flattened in.
264- `updated_input` patches shallow-merge in config order against the original
265  tool input. Later hooks override earlier ones on colliding keys. If denied or
266  halted, `updated_input` patches are ignored.
267
268### Timeouts
269
270If a hook exceeds its timeout, the process is killed and treated as a
271non-blocking error and the tool call proceeds. The default timeout is 30
272seconds.
273
274## Examples
275
276### Block destructive commands
277
278Prevent the agent from running `rm -rf` in bash:
279
280```json
281{
282  "hooks": {
283    "PreToolUse": [
284      {
285        "matcher": "^bash$",
286        "command": "./hooks/no-rm-rf.sh"
287      }
288    ]
289  }
290}
291```
292
293`hooks/no-rm-rf.sh`:
294
295```bash
296#!/usr/bin/env bash
297# Block rm -rf commands in the bash tool. Otherwise stay silent so the
298# normal permission flow runs.
299
300if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
301  echo "Refusing to run rm -rf against root" >&2
302  exit 2
303fi
304
305exit 0
306```
307
308### Auto-approve read-only tools
309
310Skip the permission prompt for tools that can't change anything. The hook
311returns `decision: "allow"`, which tells Crush to pre-approve the call:
312
313```jsonc
314{
315  "hooks": {
316    "PreToolUse": [
317      {
318        "matcher": "^(view|ls|grep|glob)$",
319        "command": "echo '{\"decision\":\"allow\"}'",
320      },
321    ],
322  },
323}
324```
325
326No script file needed β€” the command is inline. Every `view`/`ls`/`grep`/`glob`
327call now runs without prompting. Add the `bash` tool to this list at your own
328risk; consider a more targeted allowlist instead:
329
330```bash
331#!/usr/bin/env bash
332# hooks/safe-bash.sh β€” auto-approve read-only bash commands.
333
334case "$CRUSH_TOOL_INPUT_COMMAND" in
335  ls*|cat*|grep*|rg*|echo*|pwd*)
336    echo '{"decision":"allow"}'
337    ;;
338  *)
339    # Silent β€” fall through to the normal permission prompt.
340    exit 0
341    ;;
342esac
343```
344
345### Inject context into file writes
346
347Add a reminder to the model whenever it writes a Go file:
348
349```json
350{
351  "hooks": {
352    "PreToolUse": [
353      {
354        "matcher": "^(edit|write|multiedit)$",
355        "command": "./hooks/go-context.sh"
356      }
357    ]
358  }
359}
360```
361
362`hooks/go-context.sh`:
363
364```bash
365#!/usr/bin/env bash
366# Remind the model about Go formatting when editing .go files.
367# Emit context only; stay silent on `decision` so the normal permission
368# prompt still runs for edits/writes.
369
370if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
371  echo '{"context": "Remember: run gofumpt after editing Go files."}'
372else
373  echo '{}'
374fi
375```
376
377### Block all MCP tools
378
379The `command` can be inline. This one-liner matches all MCP tools and blocks
380them:
381
382```jsonc
383{ "matcher": "^mcp_", "command": "echo 'MCP tools are disabled' >&2; exit 2" }
384```
385
386### Log every tool call
387
388With no `matcher` this fires for every tool. It exits 0 with no stdout so the
389tool call always proceeds.
390
391```jsonc
392{ "command": "echo \"$(date -Iseconds) $CRUSH_TOOL_NAME\" >> ./tools.log" }
393```
394
395### A real-world Example:
396
397For a more practical example, see [`rtk-rewrite.sh`](./examples/rtk-rewrite.sh),
398which demonstrates how to rewrite tool input using
399[RTK](https://github.com/rtk-ai/rtk) to save tokens.
400
401### Using other languages
402
403Hooks aren't limited to shell scripts: any executable works. Here's the same
404"block rm -rf" example in some other languages.
405
406#### Lua
407
408`{"matcher": "^bash$", "command": "lua ./hooks/no-rm-rf.lua"}`
409
410```lua
411local input = io.read("*a")
412local tool_input = input:match('"command":"(.-)"') or ""
413
414if tool_input:match("rm%s+%-[rf][rf]%s+/") then
415  io.stderr:write("Refusing to run rm -rf against root\n")
416  os.exit(2)
417end
418```
419
420#### JavaScript
421
422`{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}`
423
424```js
425let input = "";
426process.stdin.on("data", (chunk) => (input += chunk));
427process.stdin.on("end", () => {
428  const { tool_input: toolInput } = JSON.parse(input);
429
430  if (/rm\s+-[rf]{2}\s+\//.test(toolInput.command)) {
431    process.stderr.write("Refusing to run rm -rf against root\n");
432    process.exit(2);
433  }
434});
435```
436
437---
438
439## Reference
440
441This is the official reference of the narrative above. If prose and this section
442disagree, the prose should be presumed canonical for intent, while this section
443is canonical for shape.
444
445Both the stdin payload and the output envelope have **common fields** that apply
446to every event and **per-event fields** that only some events recognize. When an
447event doesn't understand a field, it's ignored.
448
449### Hook config
450
451Each entry under a `hooks.<EventName>` array:
452
453```jsonc
454{
455  // string. Optional. Regex tested against the tool name. Omit to match all.
456  "matcher": "^bash$",
457
458  // string. Required. Shell command to run.
459  "command": "./hooks/my-hook.sh",
460
461  // number. Optional. Seconds before the hook is killed. Defaults to 30.
462  "timeout": 10,
463}
464```
465
466### Stdin payload (common)
467
468Present in every hook event:
469
470```jsonc
471{
472  // string. Hook event name.
473  "event": "PreToolUse",
474
475  // string. Current session ID.
476  "session_id": "313909e",
477
478  // string. Working directory when invoked.
479  "cwd": "/home/user/project",
480}
481```
482
483### Stdin payload β€” PreToolUse
484
485Extends the common payload:
486
487```jsonc
488{
489  // ...common fields...
490
491  // string. The tool being called.
492  "tool_name": "bash",
493
494  // object. Raw JSON input the model sent to the tool. Shape is per-tool.
495  "tool_input": {
496    "command": "npm test",
497  },
498}
499```
500
501### Output envelope (common)
502
503Fields a hook may print to stdout on exit 0. All are optional and apply to every
504event:
505
506```jsonc
507{
508  // number. Defaults to 1. Unknown higher values still parse; exists for
509  // forward-compat.
510  "version": 1,
511
512  // boolean. If true, ends the turn entirely. User takes over.
513  "halt": false,
514
515  // string. Shown when denying (to the model) or halting (to the model and
516  // user).
517  "reason": "not allowed",
518
519  // string | string[]. Appended to what the model sees. Empty entries are
520  // dropped.
521  "context": "Rewrote with RTK",
522}
523```
524
525### Output envelope β€” PreToolUse
526
527Extends the common envelope:
528
529```jsonc
530{
531  // ...common fields...
532
533  // "allow" | "deny" | null. null/omitted = no opinion, the tool still goes
534  // through the normal permission prompt. "allow" is affirmative: pre-approves
535  // the tool call and bypasses the prompt. "deny" blocks the call; the model
536  // sees the error and may try something else.
537  "decision": "allow",
538
539  // object. Shallow-merge patch against tool_input. Nested objects are
540  // replaced wholesale, not deep-merged.
541  "updated_input": {
542    "command": "bun test",
543  },
544}
545```
546
547### Exit codes
548
549| Code  | Meaning                                                                  |
550| ----- | ------------------------------------------------------------------------ |
551| `0`   | Success. Stdout is parsed as the output envelope.                        |
552| `2`   | Block this tool call. Stderr becomes the deny reason. Stdout is ignored. |
553| `49`  | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored.  |
554| other | Non-blocking error. Logged and ignored; the tool call proceeds.          |
555
556Exit `2` only applies to events that can block something. On events where
557there's nothing to block, it's treated as a non-blocking error.
558
559### Aggregation
560
561When multiple hooks match the same event, results compose in **config order**.
562
563Universal rules:
564
5651. `halt` is sticky: if any hook halts, the turn ends.
5662. `reason` values concatenate with `\n` in config order. Halt-only hooks
567   without a deny still contribute their reason.
5683. `context` values concatenate with `\n` in config order. String entries and
569   array entries flatten uniformly.
570
571PreToolUse-specific rules:
572
5734. `decision` precedence: `deny` > `allow` > `null`. First deny determines the
574   outcome; subsequent allows don't override. If the final aggregated decision
575   is `allow`, Crush pre-approves the tool call and skips the permission
576   prompt. If it's `null` (no hook allowed), the tool goes through the normal
577   permission flow.
5785. `updated_input` patches shallow-merge sequentially against the original
579   `tool_input`. Later patches override earlier ones on colliding keys. Patches
580   are **ignored** if the final decision is deny or halt.
581
582### Environment variables
583
584See [Environment Variables](#environment-variables) above for the full list.
585
586---
587
588## Whatcha think?
589
590We'd love to hear your thoughts on this project. Need help? We gotchu. You can
591find us on:
592
593- [Twitter](https://twitter.com/charmcli)
594- [Slack](https://charm.land/slack)
595- [Discord](https://charm.land/discord)
596- [The Fediverse](https://mastodon.social/@charmcli)
597- [Bluesky](https://bsky.app/profile/charm.land)
598
599---
600
601Part of [Charm](https://charm.land).
602
603<a href="https://charm.land/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-softy.jpg" /></a>
604
605<!--prettier-ignore-->
606Charmηƒ­ηˆ±εΌ€ζΊ β€’ Charm loves open source