README.md

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