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