README.md

  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```