1# Hooks Package
2
3A Git-like hooks system for Crush that allows users to intercept and modify behavior at key points in the application lifecycle.
4
5## Overview
6
7The hooks package provides a flexible, shell-based system for customizing Crush behavior through executable scripts. Hooks can:
8
9- Add context to LLM requests
10- Control tool execution permissions
11- Modify prompts and tool parameters
12- Audit and log activity
13- Execute cleanup on shutdown
14
15### Cross-Platform Support
16
17The hooks system works on **Windows, macOS, and Linux**:
18
19- **Hook Files**: All hooks must be `.sh` files (shell scripts)
20- **Shell Execution**: Uses Crush's internal POSIX shell emulator (`mvdan.cc/sh`) on all platforms
21- **Hook Discovery**:
22 - **Unix/macOS**: `.sh` files must have execute permission (`chmod +x hook.sh`)
23 - **Windows**: `.sh` files are automatically recognized (no permission needed)
24- **Path Separators**: Use forward slashes (`/`) in hook scripts for cross-platform compatibility
25
26**Example**:
27```bash
28# Works on Windows, macOS, and Linux
29.crush/hooks/pre-tool-use/01-check.sh
30```
31
32## Quick Start
33
34### Creating a Hook
35
361. Create an executable script in `.crush/hooks/{hook-type}/`:
37
38```bash
39#!/bin/bash
40# .crush/hooks/pre-tool-use/01-block-dangerous.sh
41
42if [ "$CRUSH_TOOL_NAME" = "bash" ]; then
43 COMMAND=$(crush_get_tool_input command)
44 if [[ "$COMMAND" =~ "rm -rf /" ]]; then
45 crush_deny "Blocked dangerous command"
46 fi
47fi
48```
49
502. Make it executable:
51
52```bash
53chmod +x .crush/hooks/pre-tool-use/01-block-dangerous.sh
54```
55
563. The hook will automatically execute when the event occurs.
57
58## Hook Types
59
60### 1. UserPromptSubmit
61
62**When**: After user submits prompt, before sending to LLM
63**Use cases**: Add context, modify prompts, validate input
64**Location**: `.crush/hooks/user-prompt-submit/`
65
66**Available data** (via stdin JSON):
67- `prompt` - User's prompt text
68- `attachments` - List of attached files
69- `model` - Model name
70- `is_first_message` - Boolean indicating if this is the first message in the conversation
71
72**Example**:
73```bash
74#!/bin/bash
75# Add git context to every prompt, and README only for first message
76
77BRANCH=$(git branch --show-current 2>/dev/null)
78if [ -n "$BRANCH" ]; then
79 crush_add_context "Current branch: $BRANCH"
80fi
81
82# Only add README context for the first message to avoid repetition
83IS_FIRST=$(crush_get_input is_first_message)
84if [ "$IS_FIRST" = "true" ] && [ -f "README.md" ]; then
85 crush_add_context_file "README.md"
86fi
87```
88
89### 2. PreToolUse
90
91**When**: After LLM requests tool use, before permission check & execution
92**Use cases**: Auto-approve, deny dangerous commands, audit requests
93**Location**: `.crush/hooks/pre-tool-use/`
94
95**Available data** (via stdin JSON):
96- `tool_input` - Tool parameters (object)
97
98**Environment variables**:
99- `$CRUSH_TOOL_NAME` - Name of the tool being called
100- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
101
102**Example**:
103```bash
104#!/bin/bash
105# Auto-approve read-only tools and modify parameters
106
107case "$CRUSH_TOOL_NAME" in
108 view|ls|grep|glob)
109 crush_approve "Auto-approved read-only tool"
110 ;;
111 bash)
112 COMMAND=$(crush_get_tool_input command)
113 if [[ "$COMMAND" =~ ^(ls|cat|grep) ]]; then
114 crush_approve "Auto-approved safe bash command"
115 fi
116 ;;
117 view)
118 # Limit file reads to 1000 lines max for performance
119 crush_modify_input "limit" "1000"
120 ;;
121esac
122```
123
124### 3. PostToolUse
125
126**When**: After tool executes, before result sent to LLM
127**Use cases**: Filter output, redact secrets, log results
128**Location**: `.crush/hooks/post-tool-use/`
129
130**Available data** (via stdin JSON):
131- `tool_input` - Tool parameters (object)
132- `tool_output` - Tool result (object with `success`, `content`)
133- `execution_time_ms` - How long the tool took
134
135**Environment variables**:
136- `$CRUSH_TOOL_NAME` - Name of the tool
137- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
138
139**Example**:
140```bash
141#!/bin/bash
142# Redact sensitive information from tool output
143
144# Get tool output using helper (stdin is automatically available)
145OUTPUT_CONTENT=$(crush_get_input tool_output | jq -r '.content // empty')
146
147# Check if output contains sensitive patterns
148if echo "$OUTPUT_CONTENT" | grep -qE '(password|api[_-]?key|secret|token)'; then
149 # Redact sensitive data
150 REDACTED=$(echo "$OUTPUT_CONTENT" | sed -E 's/(password|api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]+/\1=\[REDACTED\]/gi')
151 crush_modify_output "content" "$REDACTED"
152 crush_log "Redacted sensitive information from $CRUSH_TOOL_NAME output"
153fi
154```
155
156### 4. Stop
157
158**When**: When agent conversation loop stops or is cancelled
159**Use cases**: Save conversation state, cleanup session resources, archive logs
160**Location**: `.crush/hooks/stop/`
161
162**Available data** (via stdin JSON):
163- `reason` - Why the loop stopped (e.g., "completed", "cancelled", "error")
164- `session_id` - The session ID that stopped
165
166**Example**:
167```bash
168#!/bin/bash
169# Save conversation summary when agent loop stops
170
171REASON=$(crush_get_input reason)
172SESSION_ID=$(crush_get_input session_id)
173
174# Archive session logs
175if [ -f ".crush/session-$SESSION_ID.log" ]; then
176 ARCHIVE="logs/session-$SESSION_ID-$(date +%Y%m%d-%H%M%S).log"
177 mkdir -p logs
178 mv ".crush/session-$SESSION_ID.log" "$ARCHIVE"
179 gzip "$ARCHIVE"
180 crush_log "Archived session logs: $ARCHIVE.gz (reason: $REASON)"
181fi
182```
183
184## Catch-All Hooks
185
186Place hooks at the **root level** (`.crush/hooks/*.sh`) to run for **ALL hook types**:
187
188```bash
189#!/bin/bash
190# .crush/hooks/00-global-log.sh
191# This runs for every hook type
192
193echo "[$CRUSH_HOOK_TYPE] Session: $CRUSH_SESSION_ID" >> global.log
194```
195
196**Execution order**:
1971. Catch-all hooks (alphabetically sorted)
1982. Type-specific hooks (alphabetically sorted)
199
200Use `$CRUSH_HOOK_TYPE` to determine which event triggered the hook.
201
202## Helper Functions
203
204All hooks have access to these built-in functions (no sourcing required):
205
206### Permission Helpers
207
208#### `crush_approve [message]`
209Approve the current tool call (PreToolUse only).
210
211```bash
212crush_approve "Auto-approved read-only command"
213```
214
215#### `crush_deny [message]`
216Deny the current tool call and stop execution (PreToolUse only).
217
218```bash
219crush_deny "Blocked dangerous operation"
220# Script exits immediately with code 2
221```
222
223#### `crush_ask [message]`
224Ask user for permission (default behavior).
225
226```bash
227crush_ask "This command modifies files, please review"
228```
229
230### Context Helpers
231
232#### `crush_add_context "content"`
233Add raw text content to LLM context.
234
235```bash
236crush_add_context "Project uses React 18 with TypeScript"
237```
238
239#### `crush_add_context_file "path"`
240Load a file and add its content to LLM context.
241
242```bash
243crush_add_context_file "docs/ARCHITECTURE.md"
244crush_add_context_file "package.json"
245```
246
247### Modification Helpers
248
249#### `crush_modify_prompt "new_prompt"`
250Replace the user's prompt (UserPromptSubmit only).
251
252```bash
253PROMPT=$(crush_get_prompt)
254MODIFIED="$PROMPT\n\nNote: Always use TypeScript."
255crush_modify_prompt "$MODIFIED"
256```
257
258#### `crush_modify_input "param_name" "value"`
259Modify tool input parameters (PreToolUse only).
260
261Values are parsed as JSON when valid, supporting all JSON types (strings, numbers, booleans, arrays, objects).
262
263```bash
264# Strings (no quotes needed for simple strings)
265crush_modify_input "command" "ls -la"
266crush_modify_input "working_dir" "/tmp"
267
268# Numbers (parsed as JSON)
269crush_modify_input "offset" "100"
270crush_modify_input "limit" "50"
271
272# Booleans (parsed as JSON)
273crush_modify_input "run_in_background" "true"
274crush_modify_input "replace_all" "false"
275
276# Arrays (JSON format)
277crush_modify_input "ignore" '["*.log","*.tmp"]'
278
279# Quoted strings (for strings with spaces or special chars)
280crush_modify_input "message" '"hello world"'
281```
282
283#### `crush_modify_output "field_name" "value"`
284Modify tool output before sending to LLM (PostToolUse only).
285
286```bash
287# Redact sensitive information from tool output content
288crush_modify_output "content" "[REDACTED - sensitive data removed]"
289
290# Can also modify other fields in the tool_output object
291crush_modify_output "success" "false"
292```
293
294#### `crush_stop [message]`
295Stop execution immediately.
296
297```bash
298if [ "$(date +%H)" -lt 9 ]; then
299 crush_stop "Crush is only available during business hours"
300fi
301```
302
303### Input Parsing Helpers
304
305Hooks receive JSON context via stdin, which is automatically saved and available to all helper functions. You can call multiple helpers without manually reading stdin first.
306
307#### `crush_get_input "field_name"`
308Get a top-level field from the hook context.
309
310```bash
311# Can call multiple times without saving stdin
312PROMPT=$(crush_get_input prompt)
313MODEL=$(crush_get_input model)
314```
315
316#### `crush_get_tool_input "parameter"`
317Get a tool parameter (PreToolUse/PostToolUse only).
318
319```bash
320# Can call multiple times without saving stdin
321COMMAND=$(crush_get_tool_input command)
322FILE_PATH=$(crush_get_tool_input file_path)
323```
324
325#### `crush_get_prompt`
326Get the user's prompt (UserPromptSubmit only).
327
328```bash
329PROMPT=$(crush_get_prompt)
330if [[ "$PROMPT" =~ "password" ]]; then
331 crush_stop "Never include passwords in prompts"
332fi
333```
334
335### Logging Helper
336
337#### `crush_log "message"`
338Write to Crush's log (stderr).
339
340```bash
341crush_log "Processing hook for tool: $CRUSH_TOOL_NAME"
342```
343
344## Environment Variables
345
346All hooks have access to these environment variables:
347
348### Always Available
349- `$CRUSH_HOOK_TYPE` - Type of hook: `user-prompt-submit`, `pre-tool-use`, `post-tool-use`, `stop`
350- `$CRUSH_SESSION_ID` - Current session ID
351- `$CRUSH_WORKING_DIR` - Working directory
352
353### Tool Hooks (PreToolUse, PostToolUse)
354- `$CRUSH_TOOL_NAME` - Name of the tool being called
355- `$CRUSH_TOOL_CALL_ID` - Unique ID for this tool call
356
357## Result Communication
358
359Hooks communicate results back to Crush in two ways:
360
361### 1. Environment Variables (Simple)
362
363Export variables to set hook results:
364
365```bash
366export CRUSH_PERMISSION=approve
367export CRUSH_MESSAGE="Auto-approved"
368export CRUSH_CONTINUE=false
369export CRUSH_CONTEXT_CONTENT="Additional context"
370export CRUSH_CONTEXT_FILES="/path/to/file1.md:/path/to/file2.md"
371```
372
373**Available variables**:
374- `CRUSH_PERMISSION` - `approve`, `ask`, or `deny`
375- `CRUSH_MESSAGE` - User-facing message
376- `CRUSH_CONTINUE` - `true` or `false` (stop execution)
377- `CRUSH_MODIFIED_PROMPT` - New prompt text
378- `CRUSH_MODIFIED_INPUT` - Modified tool input (format: `key=value:key2=value2`, values parsed as JSON)
379- `CRUSH_MODIFIED_OUTPUT` - Modified tool output (format: `key=value:key2=value2`, values parsed as JSON)
380- `CRUSH_CONTEXT_CONTENT` - Text to add to LLM context
381- `CRUSH_CONTEXT_FILES` - Colon-separated file paths
382
383**Note**: `CRUSH_MODIFIED_INPUT` and `CRUSH_MODIFIED_OUTPUT` use `:` as delimiter between pairs. For complex values with multiple fields or nested structures, use JSON output instead (see below).
384
385### 2. JSON Output (Complex)
386
387Echo JSON to stdout for complex modifications:
388
389```bash
390echo '{
391 "permission": "approve",
392 "message": "Modified command",
393 "modified_input": {
394 "command": "ls -la --color=auto"
395 },
396 "context_content": "Added context"
397}'
398```
399
400**JSON fields**:
401- `continue` (bool) - Continue execution
402- `permission` (string) - `approve`, `ask`, `deny`
403- `message` (string) - User-facing message
404- `modified_prompt` (string) - New prompt
405- `modified_input` (object) - Modified tool parameters
406- `modified_output` (object) - Modified tool results
407- `context_content` (string) - Context to add
408- `context_files` (array) - File paths to load
409
410**Note**: Environment variables and JSON output are merged automatically.
411
412## Exit Codes
413
414- **0** - Success, continue execution
415- **1** - Error (PreToolUse: denies permission, others: logs and continues)
416- **2** - Deny/stop execution (sets `Continue=false`)
417
418```bash
419# Example: Check rate limit
420COUNT=$(grep -c "$(date +%Y-%m-%d)" usage.log)
421if [ "$COUNT" -gt 100 ]; then
422 echo "Rate limit exceeded" >&2
423 exit 2 # Stops execution
424fi
425```
426
427## Hook Ordering
428
429Hooks execute **sequentially** in alphabetical order. Use numeric prefixes to control order:
430
431```
432.crush/hooks/
433 00-global-log.sh # Catch-all: runs first for all types
434 pre-tool-use/
435 01-rate-limit.sh # Runs first
436 02-auto-approve.sh # Runs second
437 99-audit.sh # Runs last
438```
439
440## Result Merging
441
442When multiple hooks execute, their results are merged:
443
444### Permission (Most Restrictive Wins)
445- `deny` > `ask` > `approve`
446- If any hook denies, the final result is deny
447
448### Continue (AND Logic)
449- All hooks must set `Continue=true` (or not set it)
450- If any hook sets `Continue=false`, execution stops
451
452### Context (Append)
453- Context content from all hooks is concatenated
454- Context files from all hooks are combined
455
456### Messages (Append)
457- Messages are joined with `; ` separator
458
459### Modified Fields (Last Wins)
460- Modified prompt: last hook's value wins
461- Modified input/output: maps are merged, last value wins for conflicts
462
463## Configuration
464
465Configure hooks in `crush.json`:
466
467```json
468{
469 "hooks": {
470 "enabled": true,
471 "timeout_seconds": 30,
472 "directories": [
473 "/path/to/custom/hooks",
474 ".crush/hooks"
475 ],
476 "disabled": [
477 "pre-tool-use/slow-check.sh",
478 "user-prompt-submit/verbose.sh"
479 ],
480 "environment": {
481 "CUSTOM_VAR": "value"
482 },
483 "inline": {
484 "pre-tool-use": [{
485 "name": "rate-limit",
486 "script": "#!/bin/bash\n# Inline hook script here..."
487 }]
488 }
489 }
490}
491```
492
493### Configuration Options
494
495- **enabled** (bool) - Enable/disable the entire hooks system (default: `true`)
496- **timeout_seconds** (int) - Maximum execution time per hook (default: `30`)
497- **directories** ([]string) - Additional directories to search for hooks
498- **disabled** ([]string) - List of hook paths to skip (relative to hooks directory)
499- **environment** (map) - Environment variables to pass to all hooks
500- **inline** (map) - Hooks defined directly in config (by hook type)
501
502## Best Practices
503
504### 1. Keep Hooks Fast
505Hooks run synchronously. Keep them under 1 second to avoid slowing down the UI.
506
507```bash
508# Bad: Slow network call
509curl -X POST https://api.example.com/log
510
511# Good: Log locally, sync in background
512echo "$LOG_ENTRY" >> audit.log
513```
514
515### 2. Handle Errors Gracefully
516Don't let hooks crash. Use error handling:
517
518```bash
519BRANCH=$(git branch --show-current 2>/dev/null)
520if [ -n "$BRANCH" ]; then
521 crush_add_context "Branch: $BRANCH"
522fi
523```
524
525### 3. Use Descriptive Names
526Use numeric prefixes and descriptive names:
527
528```bash
52901-security-check.sh # Good
53099-audit-log.sh # Good
531hook.sh # Bad
532```
533
534### 4. Test Hooks Independently
535Run hooks manually to test:
536
537```bash
538export CRUSH_HOOK_TYPE=pre-tool-use
539export CRUSH_TOOL_NAME=bash
540echo '{"tool_input":{"command":"rm -rf /"}}' | .crush/hooks/pre-tool-use/01-block-dangerous.sh
541echo "Exit code: $?"
542```
543
544### 5. Log for Debugging
545Use `crush_log` to debug hook execution:
546
547```bash
548crush_log "Checking command: $COMMAND"
549if [[ "$COMMAND" =~ "dangerous" ]]; then
550 crush_log "Blocking dangerous command"
551 crush_deny "Command blocked"
552fi
553```
554
555### 6. Don't Block on I/O
556Avoid blocking operations:
557
558```bash
559# Bad: Waits for user input
560read -p "Continue? " answer
561
562# Bad: Long-running process
563./expensive-analysis.sh
564
565# Good: Quick checks
566[ -f ".allowed" ] && crush_approve
567```