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