on 8ebab21 help sign in
crush/docs/hooks/README.md
679 lines 21 kB history raw
1# Hooks
3> [!NOTE]
4> This document was designed for both humans and agents.
6Hooks are user-defined shell scripts that run when various events happen during
7the agent lifecycle, allowing you to both build on top of Crush, customize
8its behavior, and exert deterministic control over an agent's wily behavior.
10Hooks are just shell commands, and were designed to be both simple and future
11forward.
13### Hot Hook Facts
15- Hooks just shell commands
16- Hooks can be written in any language because they’re just executables: Bash, Python, Node, Rust, Haskell, whatever
17- Hooks are Claude Code-compatible
18- Crush ships with a builtin `crush-hook` skill write, edit, and configure
19 hooks; just tell Crush how to configure Crush
20- Crush currently supports just one hook, `PreToolUse`, with plans to support
21 the full gamut; please let us know which hooks you'd like to see next
22- Hooks run in parallel for speed, but their results compose in config order
23 for determinism
25### Some things you can do with hooks:
27- Block "dangerous" commands: no more `git push -f` or `cabal init`
28- Rewrite tool input: turn `node` calls info `deno`, scrub secrets from
29 commands, rewrite all mentions of "Haskell" into "Haskell, The Best
30 Language", and so on
31- Inject context: add notes to the model's context whenever certain tools are
32 called. For example: "remember to run gofumpt after editing Go files"
33- Auto-approve tools: skip the permission prompt for bash commands that
34 you know are safe
35- Log certain tool calls
37…And lots more. Show us what you're building!
39## Baby's First Hook
41Let's just dive into it and make a simple hook. This particular hook will
42disallow the use of Haskell (but we love you, Simon Peyton Jones).
44### Config
46The first thing we need to do is hook up our hook. Let's add the following to
47our local `crush.json`. You can, of course, do this globally, too.
49```jsonc
51 // As expected, hooks go in a "hooks" object.
52 "hooks": {
53 // PreToolUse is an event that fires before a tool is used.
54 "PreToolUse": [
55 {
56 // What tool do we want to hook into? In this case, Bash, because it
57 // runs the stuff we wanna block.
58 "matcher": "^bash$",
60 // The path to our actual hook script.
61 "command": "./no-haskell.sh",
62 },
63 ],
64 },
66```
68Now, let's make our `no-haskell.sh` hook script.
70```bash
71#!/usr/bin/env bash
73# Disallow ghc, cabal, and stack. Pipe the bash command output
74# ($CRUSH_TOOL_INPUT_COMMAND) to grep and match on a regexp.
75if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE '(^| )((ghc|cabal|stack)(\.exe)?)( |$)'; then
77 # Someone is trying to use Haskell. Let's send a message back to the model
78 # and user explaining why we're blocking this. Note that we send all feedback
79 # like this to stderr.
80 echo "No Haskell allowed, kiddo." >&2
82 # Now, block the tool call by exiting with code 2.
83 exit 2
84fi
85```
87That's basically it. For the full guide on how hooks work, however, read on.
89---
91## Configuration
93Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
94and project-level, with project level hooks taking precedence.
96```jsonc
98 "hooks": {
99 "PreToolUse": [
100 {
101 "matcher": "bash", // regex tested against the tool name
102 "command": "./hooks/my-hot-hook.sh", // the path to the hook
103 "timeout": 10, // in seconds; default 30
104 },
105 ],
106 },
108```
110Remember, hooks will run in parallel but resolve in config order. Last hook
111wins when rewriting input, but first deny wins when blocking.
113## Events
115Here are the events you can hook into (spoiler: there's currently just one):
117### PreToolUse
119This hook fires before every tool call. Use it to block dangerous commands,
120enforce policies, rewrite tool input, inject context the model should see, log
121stuff, and so on.
123**Matched against**: the tool name (e.g. `bash`, `edit`, `write`,
124`mcp_github_create_pull_request`).
126> [!NOTE]
127> Event names are case insensitive and snake-caseable, so `PreToolUse`,
128> `pretooluse`, `PRETOOLUSE`, `pre_tool_use`, and `PRE_TOOL_USE` all work.
130**Scope**: `PreToolUse` only fires on the **top-level agent's** tool calls.
131Sub-agents (the `agent` task tool, `agentic_fetch`, etc.) run without hook
132interception so a single delegated turn doesn't trigger your hook N times. The
133outer sub-agent tool call itself _is_ hooked, so policy like "never let the
134agent spawn sub-agents" still works.
136Hooks are keyed by event name. Only `command` is required, and you can omit
137`matcher` to match all tools.
139## Building Hooks
141When a hook fires, Crush:
1431. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
144 all).
1452. Deduplicates by `command` (identical commands run once).
1463. Runs all matching hooks **in parallel** as subprocesses.
1474. Waits for all to finish (or time out), then aggregates results **in config
148 order**: deny wins over allow, allow wins over none; `updated_input` patches
149 shallow-merge in order.
1505. Applies the result **before** permission checks. If the aggregated decision
151 is `deny`, the tool call is blocked and you never see a permission prompt
152 for it. If it's `allow`, Crush treats that as affirmative pre-approval and
153 also skips the prompt. Silence (no decision) falls through to the normal
154 permission flow.
156Note that you can omit `matcher` and match in your shell script instead, however
157you'll incur some additional overhead as Crush will `exec` each script.
159### Input
161Each hook receives data two ways: environment variables and stdin (as JSON).
162Environment variables are typically easier to work with, with JSON being
163available when input is more complex.
165#### Environment Variables
167The available environment variables are:
169| Variable | Description |
170| ---------------------------- | ---------------------------------------------- |
171| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
172| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
173| `CRUSH_SESSION_ID` | Current session ID. |
174| `CRUSH_CWD` | Working directory. |
175| `CRUSH_PROJECT_DIR` | Project root directory. |
176| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
177| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
179#### JSON
181Standard input provides the full context as JSON:
183```jsonc
185 "event": "PreToolUse", // Hook event name
186 "session_id": "313909e", // Current session ID
187 "cwd": "/home/user/project", // Working directory
188 "tool_name": "bash", // The tool being called
189 "tool_input": { "command": "rm -rf /" }, // The tool's input
191```
193Note that `tool_input` field contains the raw JSON the model sent to the tool.
195To parse the stdin JSON in your hook script, read from stdin and use a tool like
196`jq`:
198```bash
199#!/usr/bin/env bash
200read -r input
201tool_name=$(echo "$input" | jq -r '.tool_name')
202command=$(echo "$input" | jq -r '.tool_input.command // empty')
203```
205You can also use tools like Python:
207```python
208#!/usr/bin/env python3
209import json, sys
211data = json.load(sys.stdin)
212tool_name = data.get("tool_name", "")
213command = data.get("tool_input", {}).get("command", "")
214```
216### Output
218Hooks communicate back to Crush via **exit code** and `stdout`/`stderr`. The
219simplest way to do this is to return an error code and print additional context
220to stderr. For example:
222```bash
223# Here, error code 2 blocks the tool, using stderr as the reason:
224if some_bad_condition; then
225 echo "Blocked: reason here" >&2
226 exit 2
227fi
228```
230| Exit Code | Meaning |
231| --------- | ---------------------------------------------------------------- |
232| 0 | Success. Stdout is parsed as JSON (see fields below). |
233| 2 | **Block the tool.** Stderr is used as the deny reason (no JSON). |
234| 49 | **Halt the turn.** Stderr is used as the halt reason (no JSON). |
235| Other | Non-blocking error. Logged and ignored — the tool call proceeds. |
237The difference between exit 2 and exit 49:
239- **Exit 2** blocks the current tool call. The agent sees the error and can try
240 something else.
241- **Exit 49** halts the whole turn. The agent doesn't get to respond further;
242 the user takes over. Use this when something is wrong enough that the agent
243 shouldn't keep trying. 49 sits in an empty slice of the exit-code space —
244 between the generic-error range (1-30), the BSD `sysexits.h` range (64-78),
245 and the killed-by-signal range (128+) — so it can't be hit by accident.
247That said, if you need more control, or if you need to rewrite input, you can
248use JSON on stdout. Exit 0 and print a JSON object to provide context, update
249the input, or still deny/halt with a reason:
251```jsonc
253 "version": 1, // Output envelope version. Optional; defaults to 1.
254 "decision": "allow", // "allow", "deny", or null. Omit for no opinion.
255 "halt": false, // If true, halts the turn entirely.
256 "reason": "LGTM", // Shown when denying or halting.
257 "context": "Scrubbed secrets", // String or array of strings. Appended to what the model sees.
258 "updated_input": { "command": "…" }, // Shallow-merged into the tool's input before execution.
260```
262`version` is an optional integer at the top of the envelope. It defaults to `1`
263if omitted. Unknown higher versions are still parsed; the field exists so the
264envelope can evolve without a compatibility shim.
266`decision: "allow"` is **affirmative**: it pre-approves the tool call and
267bypasses the permission prompt entirely. Silence (no `decision`, or
268`decision: null`) means "no opinion" — the tool still goes through the
269normal permission flow. Use `"allow"` when you want to auto-approve; omit it
270when you only want to inject context or rewrite input without also vouching
271for the call.
273`updated_input` is a shallow-merge patch. Keys you include overwrite matching
274keys in `tool_input`; keys you don't include are preserved. If the model called
275`bash` with `{"command": "npm test", "timeout": 60000}` and your hook returns
276`{"updated_input": {"command": "bun test"}}`, the tool runs with
277`{"command": "bun test", "timeout": 60000}` — the timeout isn't dropped. The
278merge is shallow: nested objects are replaced wholesale, not deep-merged.
280`halt: true` stops the turn entirely. The agent doesn't get to respond further;
281the user takes over. The exit-code shorthand is `exit 49` with stderr as the
282reason.
284`context` accepts either a string or an array of strings. Use the string form
285for a single observation; use the array form when a hook produces multiple
286distinct notes and you'd rather not concatenate them by hand. Empty strings and
287empty array entries are dropped.
289Here's a full shell script that produces this JSON:
291```bash
292#!/usr/bin/env bash
293# Example: rewrite a bash command using RTK
295read -r input
296original_cmd=$(echo "$input" | jq -r '.tool_input.command')
297rewritten=$(secret-scrubber rewrite "$original_cmd")
299cat <<EOF
301 "decision": "allow",
302 "context": "Scrubbed secrets",
303 "updated_input": {"command": "$rewritten"}
305EOF
306```
308### Multiple Hooks
310Hooks run in parallel, but their results compose in config order. Whichever hook
311finishes first doesn't get to "win" by virtue of timing; composition is
312deterministic based on the order hooks appear in `crush.json`.
314When multiple hooks match the same tool call:
316- If **any** hook denies, the tool call is blocked. `reason` values are
317 concatenated in config order (newline-separated).
318- If **any** hook halts, the turn ends after the tool call is blocked.
319- If no hook denies or halts but at least one allows, the tool call proceeds
320 **and the permission prompt is skipped**.
321- `context` values are concatenated in config order. Strings and arrays compose
322 uniformly — each string becomes one entry, and array entries are flattened in.
323- `updated_input` patches shallow-merge in config order against the original
324 tool input. Later hooks override earlier ones on colliding keys. If denied or
325 halted, `updated_input` patches are ignored.
327### Timeouts
329If a hook exceeds its timeout, the process is killed and treated as a
330non-blocking error and the tool call proceeds. The default timeout is 30
331seconds.
333## Examples
335### Block destructive commands
337Prevent the agent from running `rm -rf` in bash:
339```json
341 "hooks": {
342 "PreToolUse": [
343 {
344 "matcher": "^bash$",
345 "command": "./hooks/no-rm-rf.sh"
346 }
347 ]
348 }
350```
352`hooks/no-rm-rf.sh`:
354```bash
355#!/usr/bin/env bash
356# Block rm -rf commands in the bash tool. Otherwise stay silent so the
357# normal permission flow runs.
359if echo "$CRUSH_TOOL_INPUT_COMMAND" | grep -qE 'rm\s+-(rf|fr)\s+/'; then
360 echo "Refusing to run rm -rf against root" >&2
361 exit 2
362fi
364exit 0
365```
367### Auto-approve read-only tools
369Skip the permission prompt for tools that can't change anything. The hook
370returns `decision: "allow"`, which tells Crush to pre-approve the call:
372```jsonc
374 "hooks": {
375 "PreToolUse": [
376 {
377 "matcher": "^(view|ls|grep|glob)$",
378 "command": "echo '{\"decision\":\"allow\"}'",
379 },
380 ],
381 },
383```
385No script file needed — the command is inline. Every `view`/`ls`/`grep`/`glob`
386call now runs without prompting. Add the `bash` tool to this list at your own
387risk; consider a more targeted allowlist instead:
389```bash
390#!/usr/bin/env bash
391# hooks/safe-bash.sh — auto-approve read-only bash commands.
393case "$CRUSH_TOOL_INPUT_COMMAND" in
394 ls*|cat*|grep*|rg*|echo*|pwd*)
395 echo '{"decision":"allow"}'
396 ;;
397 *)
398 # Silent — fall through to the normal permission prompt.
399 exit 0
400 ;;
401esac
402```
404### Inject context into file writes
406Add a reminder to the model whenever it writes a Go file:
408```json
410 "hooks": {
411 "PreToolUse": [
412 {
413 "matcher": "^(edit|write|multiedit)$",
414 "command": "./hooks/go-context.sh"
415 }
416 ]
417 }
419```
421`hooks/go-context.sh`:
423```bash
424#!/usr/bin/env bash
425# Remind the model about Go formatting when editing .go files.
426# Emit context only; stay silent on `decision` so the normal permission
427# prompt still runs for edits/writes.
429if [[ "$CRUSH_TOOL_INPUT_FILE_PATH" == *.go ]]; then
430 echo '{"context": "Remember: run gofumpt after editing Go files."}'
431else
432 echo '{}'
433fi
434```
436### Block all MCP tools
438The `command` can be inline. This one-liner matches all MCP tools and blocks
439them:
441```jsonc
442{ "matcher": "^mcp_", "command": "echo 'MCP tools are disabled' >&2; exit 2" }
443```
445### Log every tool call
447With no `matcher` this fires for every tool. It exits 0 with no stdout so the
448tool call always proceeds.
450```jsonc
451{ "command": "echo \"$(date -Iseconds) $CRUSH_TOOL_NAME\" >> ./tools.log" }
452```
454### A real-world Example:
456For a more practical example, see [`rtk-rewrite.sh`](./examples/rtk-rewrite.sh),
457which demonstrates how to rewrite tool input using
458[RTK](https://github.com/rtk-ai/rtk) to save tokens.
460### Using other languages
462Hooks aren't limited to shell scripts: any executable works. Here's the same
463"block rm -rf" example in some other languages.
465#### Lua
467`{"matcher": "^bash$", "command": "lua ./hooks/no-rm-rf.lua"}`
469```lua
470local input = io.read("*a")
471local tool_input = input:match('"command":"(.-)"') or ""
473if tool_input:match("rm%s+%-[rf][rf]%s+/") then
474 io.stderr:write("Refusing to run rm -rf against root\n")
475 os.exit(2)
476end
477```
479#### JavaScript
481`{"matcher": "^bash$", "command": "node ./hooks/no-rm-rf.js"}`
483```js
484let input = "";
485process.stdin.on("data", (chunk) => (input += chunk));
486process.stdin.on("end", () => {
487 const { tool_input: toolInput } = JSON.parse(input);
489 if (/rm\s+-[rf]{2}\s+\//.test(toolInput.command)) {
490 process.stderr.write("Refusing to run rm -rf against root\n");
491 process.exit(2);
492 }
493});
494```
496---
498## Claude Code compatibility
500Crush hooks are broadly compatible with [Claude Code
501hooks](https://docs.claude.com/en/docs/claude-code/hooks): the config shape,
502stdin payload, output envelope, and exit codes line up so most Claude Code
503hooks run under Crush unchanged. This document covers the Crush-specific API
504only — anything not documented here isn't guaranteed to work.
506One intentional divergence: Crush treats `updated_input` as a shallow-merge
507patch against the original `tool_input` rather than a full replacement. Keys
508you omit are preserved. See [Output](#output) for details.
510---
512## Reference
514This is the official reference of the narrative above. If prose and this section
515disagree, the prose should be presumed canonical for intent, while this section
516is canonical for shape.
518Both the stdin payload and the output envelope have **common fields** that apply
519to every event and **per-event fields** that only some events recognize. When an
520event doesn't understand a field, it's ignored.
522### Hook config
524Each entry under a `hooks.<EventName>` array:
526```jsonc
528 // string. Optional. Regex tested against the tool name. Omit to match all.
529 "matcher": "^bash$",
531 // string. Required. Shell command to run.
532 "command": "./hooks/my-hook.sh",
534 // number. Optional. Seconds before the hook is killed. Defaults to 30.
535 "timeout": 10,
537```
539### Stdin payload (common)
541Present in every hook event:
543```jsonc
545 // string. Hook event name.
546 "event": "PreToolUse",
548 // string. Current session ID.
549 "session_id": "313909e",
551 // string. Working directory when invoked.
552 "cwd": "/home/user/project",
554```
556### Stdin payload — PreToolUse
558Extends the common payload:
560```jsonc
562 // ...common fields...
564 // string. The tool being called.
565 "tool_name": "bash",
567 // object. Raw JSON input the model sent to the tool. Shape is per-tool.
568 "tool_input": {
569 "command": "npm test",
570 },
572```
574### Output envelope (common)
576Fields a hook may print to stdout on exit 0. All are optional and apply to every
577event:
579```jsonc
581 // number. Defaults to 1. Unknown higher values still parse; exists for
582 // forward-compat.
583 "version": 1,
585 // boolean. If true, ends the turn entirely. User takes over.
586 "halt": false,
588 // string. Shown when denying (to the model) or halting (to the model and
589 // user).
590 "reason": "not allowed",
592 // string | string[]. Appended to what the model sees. Empty entries are
593 // dropped.
594 "context": "Rewrote with RTK",
596```
598### Output envelope — PreToolUse
600Extends the common envelope:
602```jsonc
604 // ...common fields...
606 // "allow" | "deny" | null. null/omitted = no opinion, the tool still goes
607 // through the normal permission prompt. "allow" is affirmative: pre-approves
608 // the tool call and bypasses the prompt. "deny" blocks the call; the model
609 // sees the error and may try something else.
610 "decision": "allow",
612 // object. Shallow-merge patch against tool_input. Nested objects are
613 // replaced wholesale, not deep-merged.
614 "updated_input": {
615 "command": "bun test",
616 },
618```
620### Exit codes
622| Code | Meaning |
623| ----- | ------------------------------------------------------------------------ |
624| `0` | Success. Stdout is parsed as the output envelope. |
625| `2` | Block this tool call. Stderr becomes the deny reason. Stdout is ignored. |
626| `49` | Halt the whole turn. Stderr becomes the halt reason. Stdout is ignored. |
627| other | Non-blocking error. Logged and ignored; the tool call proceeds. |
629Exit `2` only applies to events that can block something. On events where
630there's nothing to block, it's treated as a non-blocking error.
632### Aggregation
634When multiple hooks match the same event, results compose in **config order**.
636Universal rules:
6381. `halt` is sticky: if any hook halts, the turn ends.
6392. `reason` values concatenate with `\n` in config order. Halt-only hooks
640 without a deny still contribute their reason.
6413. `context` values concatenate with `\n` in config order. String entries and
642 array entries flatten uniformly.
644PreToolUse-specific rules:
6464. `decision` precedence: `deny` > `allow` > `null`. First deny determines the
647 outcome; subsequent allows don't override. If the final aggregated decision
648 is `allow`, Crush pre-approves the tool call and skips the permission
649 prompt. If it's `null` (no hook allowed), the tool goes through the normal
650 permission flow.
6515. `updated_input` patches shallow-merge sequentially against the original
652 `tool_input`. Later patches override earlier ones on colliding keys. Patches
653 are **ignored** if the final decision is deny or halt.
655### Environment variables
657See [Environment Variables](#environment-variables) above for the full list.
659---
661## Whatcha think?
663We'd love to hear your thoughts on this project. Need help? We gotchu. You can
664find us on:
666- [Twitter](https://twitter.com/charmcli)
667- [Slack](https://charm.land/slack)
668- [Discord](https://charm.land/discord)
669- [The Fediverse](https://mastodon.social/@charmcli)
670- [Bluesky](https://bsky.app/profile/charm.land)
672---
674Part of [Charm](https://charm.land).
676<a href="https://charm.land/"><img alt="The Charm logo" width="400" src="https://stuff.charm.sh/charm-banner-softy.jpg" /></a>
678<!--prettier-ignore-->
679Charm热爱开源 • Charm loves open source