README.md

Hooks Package

A Git-like hooks system for Crush that allows users to intercept and modify behavior at key points in the application lifecycle.

Overview

The hooks package provides a flexible, shell-based system for customizing Crush behavior through executable scripts. Hooks can:

  • Add context to LLM requests
  • Control tool execution permissions
  • Modify prompts and tool parameters
  • Audit and log activity
  • Execute cleanup on shutdown

Cross-Platform Support

The hooks system works on Windows, macOS, and Linux:

  • Hook Files: All hooks must be .sh files (shell scripts)
  • Shell Execution: Uses Crush's internal POSIX shell emulator (mvdan.cc/sh) on all platforms
  • Hook Discovery:
    • Unix/macOS: .sh files must have execute permission (chmod +x hook.sh)
    • Windows: .sh files are automatically recognized (no permission needed)
  • Path Separators: Use forward slashes (/) in hook scripts for cross-platform compatibility

Example:

# Works on Windows, macOS, and Linux
.crush/hooks/pre-tool-use/01-check.sh

Quick Start

Creating a Hook

  1. Create an executable script in .crush/hooks/{hook-type}/:
#!/bin/bash
# .crush/hooks/pre-tool-use/01-block-dangerous.sh

if [ "$CRUSH_TOOL_NAME" = "bash" ]; then
  COMMAND=$(crush_get_tool_input command)
  if [[ "$COMMAND" =~ "rm -rf /" ]]; then
    crush_deny "Blocked dangerous command"
  fi
fi
  1. Make it executable:
chmod +x .crush/hooks/pre-tool-use/01-block-dangerous.sh
  1. The hook will automatically execute when the event occurs.

Hook Types

1. UserPromptSubmit

When: After user submits prompt, before sending to LLM
Use cases: Add context, modify prompts, validate input
Location: .crush/hooks/user-prompt-submit/

Available data (via stdin JSON):

  • prompt - User's prompt text
  • attachments - List of attached files
  • model - Model name
  • provider - Provider name (e.g., "anthropic", "openai")
  • is_first_message - Boolean indicating if this is the first message in the conversation

Example:

#!/bin/bash
# Add git context to every prompt, and README only for first message

BRANCH=$(git branch --show-current 2>/dev/null)
if [ -n "$BRANCH" ]; then
  crush_add_context "Current branch: $BRANCH"
fi

# Only add README context for the first message to avoid repetition
IS_FIRST=$(crush_get_input is_first_message)
if [ "$IS_FIRST" = "true" ] && [ -f "README.md" ]; then
  crush_add_context_file "README.md"
fi

2. PreToolUse

When: After LLM requests tool use, before permission check & execution
Use cases: Auto-approve, deny dangerous commands, audit requests
Location: .crush/hooks/pre-tool-use/

Available data (via stdin JSON):

  • tool_input - Tool parameters (object)

Environment variables:

  • $CRUSH_TOOL_NAME - Name of the tool being called
  • $CRUSH_TOOL_CALL_ID - Unique ID for this tool call

Example:

#!/bin/bash
# Auto-approve read-only tools and modify parameters

case "$CRUSH_TOOL_NAME" in
  view|ls|grep|glob)
    crush_approve "Auto-approved read-only tool"
    ;;
  bash)
    COMMAND=$(crush_get_tool_input command)
    if [[ "$COMMAND" =~ ^(ls|cat|grep) ]]; then
      crush_approve "Auto-approved safe bash command"
    fi
    ;;
  view)
    # Limit file reads to 1000 lines max for performance
    crush_modify_input "limit" "1000"
    ;;
esac

3. PostToolUse

When: After tool executes, before result sent to LLM
Use cases: Filter output, redact secrets, log results
Location: .crush/hooks/post-tool-use/

Available data (via stdin JSON):

  • tool_input - Tool parameters (object)
  • tool_output - Tool result (object with success, content)
  • execution_time_ms - How long the tool took

Environment variables:

  • $CRUSH_TOOL_NAME - Name of the tool
  • $CRUSH_TOOL_CALL_ID - Unique ID for this tool call

Example:

#!/bin/bash
# Redact sensitive information from tool output

# Get tool output using helper (stdin is automatically available)
OUTPUT_CONTENT=$(crush_get_input tool_output | jq -r '.content // empty')

# Check if output contains sensitive patterns
if echo "$OUTPUT_CONTENT" | grep -qE '(password|api[_-]?key|secret|token)'; then
  # Redact sensitive data
  REDACTED=$(echo "$OUTPUT_CONTENT" | sed -E 's/(password|api[_-]?key|secret|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]+/\1=\[REDACTED\]/gi')
  crush_modify_output "content" "$REDACTED"
  crush_log "Redacted sensitive information from $CRUSH_TOOL_NAME output"
fi

4. Stop

When: When agent conversation loop stops or is cancelled
Use cases: Save conversation state, cleanup session resources, archive logs
Location: .crush/hooks/stop/

Available data (via stdin JSON):

  • reason - Why the loop stopped (e.g., "completed", "cancelled", "error")

Environment variables:

  • $CRUSH_SESSION_ID - The session ID that stopped

Example:

#!/bin/bash
# Save conversation summary when agent loop stops

REASON=$(crush_get_input reason)

# Archive session logs
if [ -f ".crush/session-$CRUSH_SESSION_ID.log" ]; then
  ARCHIVE="logs/session-$CRUSH_SESSION_ID-$(date +%Y%m%d-%H%M%S).log"
  mkdir -p logs
  mv ".crush/session-$CRUSH_SESSION_ID.log" "$ARCHIVE"
  gzip "$ARCHIVE"
  crush_log "Archived session logs: $ARCHIVE.gz (reason: $REASON)"
fi

Catch-All Hooks

Place hooks at the root level (.crush/hooks/*.sh) to run for ALL hook types:

#!/bin/bash
# .crush/hooks/00-global-log.sh
# This runs for every hook type

echo "[$CRUSH_HOOK_TYPE] Session: $CRUSH_SESSION_ID" >> global.log

Execution order:

  1. Catch-all hooks (alphabetically sorted)
  2. Type-specific hooks (alphabetically sorted)

Use $CRUSH_HOOK_TYPE to determine which event triggered the hook.

Helper Functions

All hooks have access to these built-in functions (no sourcing required):

Permission Helpers

crush_approve [message]

Approve the current tool call (PreToolUse only).

crush_approve "Auto-approved read-only command"

crush_deny [message]

Deny the current tool call and stop execution (PreToolUse only).

crush_deny "Blocked dangerous operation"
# Script exits immediately with code 2

Context Helpers

crush_add_context "content"

Add raw text content to LLM context.

crush_add_context "Project uses React 18 with TypeScript"

crush_add_context_file "path"

Load a file and add its content to LLM context.

crush_add_context_file "docs/ARCHITECTURE.md"
crush_add_context_file "package.json"

Modification Helpers

crush_modify_prompt "new_prompt"

Replace the user's prompt (UserPromptSubmit only).

PROMPT=$(crush_get_prompt)
MODIFIED="$PROMPT\n\nNote: Always use TypeScript."
crush_modify_prompt "$MODIFIED"

crush_modify_input "param_name" "value"

Modify tool input parameters (PreToolUse only).

Values are parsed as JSON when valid, supporting all JSON types (strings, numbers, booleans, arrays, objects).

# Strings (no quotes needed for simple strings)
crush_modify_input "command" "ls -la"
crush_modify_input "working_dir" "/tmp"

# Numbers (parsed as JSON)
crush_modify_input "offset" "100"
crush_modify_input "limit" "50"

# Booleans (parsed as JSON)
crush_modify_input "run_in_background" "true"
crush_modify_input "replace_all" "false"

# Arrays (JSON format)
crush_modify_input "ignore" '["*.log","*.tmp"]'

# Quoted strings (for strings with spaces or special chars)
crush_modify_input "message" '"hello world"'

crush_modify_output "field_name" "value"

Modify tool output before sending to LLM (PostToolUse only).

# Redact sensitive information from tool output content
crush_modify_output "content" "[REDACTED - sensitive data removed]"

# Can also modify other fields in the tool_output object
crush_modify_output "success" "false"

crush_stop [message]

Stop execution immediately.

if [ "$(date +%H)" -lt 9 ]; then
  crush_stop "Crush is only available during business hours"
fi

Input Parsing Helpers

Hooks 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.

crush_get_input "field_name"

Get a top-level field from the hook context.

# Can call multiple times without saving stdin
PROMPT=$(crush_get_input prompt)
MODEL=$(crush_get_input model)

crush_get_tool_input "parameter"

Get a tool parameter (PreToolUse/PostToolUse only).

# Can call multiple times without saving stdin
COMMAND=$(crush_get_tool_input command)
FILE_PATH=$(crush_get_tool_input file_path)

crush_get_prompt

Get the user's prompt (UserPromptSubmit only).

PROMPT=$(crush_get_prompt)
if [[ "$PROMPT" =~ "password" ]]; then
  crush_stop "Never include passwords in prompts"
fi

Logging Helper

crush_log "message"

Write to Crush's log (stderr).

crush_log "Processing hook for tool: $CRUSH_TOOL_NAME"

Environment Variables

All hooks have access to these environment variables:

Always Available

  • $CRUSH_HOOK_TYPE - Type of hook: user-prompt-submit, pre-tool-use, post-tool-use, stop
  • $CRUSH_SESSION_ID - Current session ID
  • $CRUSH_WORKING_DIR - Working directory

Tool Hooks (PreToolUse, PostToolUse)

  • $CRUSH_TOOL_NAME - Name of the tool being called
  • $CRUSH_TOOL_CALL_ID - Unique ID for this tool call

Result Communication

Hooks communicate results back to Crush in two ways:

1. Environment Variables (Simple)

Export variables to set hook results:

export CRUSH_PERMISSION=approve
export CRUSH_MESSAGE="Auto-approved"
export CRUSH_CONTINUE=false
export CRUSH_CONTEXT_CONTENT="Additional context"
export CRUSH_CONTEXT_FILES="/path/to/file1.md:/path/to/file2.md"

Available variables:

  • CRUSH_PERMISSION - approve or deny
  • CRUSH_MESSAGE - User-facing message
  • CRUSH_CONTINUE - true or false (stop execution)
  • CRUSH_MODIFIED_PROMPT - New prompt text
  • CRUSH_MODIFIED_INPUT - Modified tool input (format: key=value:key2=value2, values parsed as JSON)
  • CRUSH_MODIFIED_OUTPUT - Modified tool output (format: key=value:key2=value2, values parsed as JSON)
  • CRUSH_CONTEXT_CONTENT - Text to add to LLM context
  • CRUSH_CONTEXT_FILES - Colon-separated file paths

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).

2. JSON Output (Complex)

Echo JSON to stdout for complex modifications:

echo '{
  "permission": "approve",
  "message": "Modified command",
  "modified_input": {
    "command": "ls -la --color=auto"
  },
  "context_content": "Added context"
}'

JSON fields:

  • continue (bool) - Continue execution
  • permission (string) - approve or deny
  • message (string) - User-facing message
  • modified_prompt (string) - New prompt
  • modified_input (object) - Modified tool parameters
  • modified_output (object) - Modified tool results
  • context_content (string) - Context to add
  • context_files (array) - File paths to load

Note: Environment variables and JSON output are merged automatically.

Exit Codes

  • 0 - Success, continue execution
  • 1 - Error (PreToolUse: denies permission, others: logs and continues)
  • 2 - Deny/stop execution (sets Continue=false)
# Example: Check rate limit
COUNT=$(grep -c "$(date +%Y-%m-%d)" usage.log)
if [ "$COUNT" -gt 100 ]; then
  echo "Rate limit exceeded" >&2
  exit 2  # Stops execution
fi

Hook Ordering

Hooks execute sequentially in alphabetical order. Use numeric prefixes to control order:

.crush/hooks/
  00-global-log.sh          # Catch-all: runs first for all types
  pre-tool-use/
    01-rate-limit.sh        # Runs first
    02-auto-approve.sh      # Runs second
    99-audit.sh             # Runs last

Result Merging

When multiple hooks execute, their results are merged:

Permission (Most Restrictive Wins)

  • deny > approve
  • If any hook denies, the final result is deny
  • If any hook approves and no denials, the result is approve
  • If no hooks set permission, normal permission flow applies

Continue (AND Logic)

  • All hooks must set Continue=true (or not set it)
  • If any hook sets Continue=false, execution stops

Context (Append)

  • Context content from all hooks is concatenated
  • Context files from all hooks are combined

Messages (Append)

  • Messages are joined with ; separator

Modified Fields (Last Wins)

  • Modified prompt: last hook's value wins
  • Modified input/output: maps are merged, last value wins for conflicts

Configuration

Configure hooks in crush.json:

{
  "hooks": {
    "disabled": false,
    "timeout_seconds": 30,
    "directories": [
      "/path/to/custom/hooks",
      ".crush/hooks"
    ],
    "disable_hooks": [
      "pre-tool-use/slow-check.sh",
      "user-prompt-submit/verbose.sh"
    ],
    "environment": {
      "CUSTOM_VAR": "value"
    },
    "inline": {
      "pre-tool-use": [{
        "name": "rate-limit",
        "script": "#!/bin/bash\n# Inline hook script here..."
      }]
    }
  }
}

Configuration Options

  • disabled (bool) - Disable hook execution entirely (default: false)
  • timeout_seconds (int) - Maximum execution time per hook (default: 30)
  • directories ([]string) - Directories to search for hook scripts (defaults to [".crush/hooks"])
  • disable_hooks ([]string) - List of hook paths to skip (relative to hooks directory)
  • environment (map) - Environment variables to pass to all hooks
  • inline (map) - Hooks defined directly in config (by hook type)

Best Practices

1. Keep Hooks Fast

Hooks run synchronously. Keep them under 1 second to avoid slowing down the UI.

# Bad: Slow network call
curl -X POST https://api.example.com/log

# Good: Log locally, sync in background
echo "$LOG_ENTRY" >> audit.log

2. Handle Errors Gracefully

Don't let hooks crash. Use error handling:

BRANCH=$(git branch --show-current 2>/dev/null)
if [ -n "$BRANCH" ]; then
  crush_add_context "Branch: $BRANCH"
fi

3. Use Descriptive Names

Use numeric prefixes and descriptive names:

01-security-check.sh      # Good
99-audit-log.sh           # Good
hook.sh                   # Bad

4. Test Hooks Independently

Run hooks manually to test:

export CRUSH_HOOK_TYPE=pre-tool-use
export CRUSH_TOOL_NAME=bash
echo '{"tool_input":{"command":"rm -rf /"}}' | .crush/hooks/pre-tool-use/01-block-dangerous.sh
echo "Exit code: $?"

5. Log for Debugging

Use crush_log to debug hook execution:

crush_log "Checking command: $COMMAND"
if [[ "$COMMAND" =~ "dangerous" ]]; then
  crush_log "Blocking dangerous command"
  crush_deny "Command blocked"
fi

6. Don't Block on I/O

Avoid blocking operations:

# Bad: Waits for user input
read -p "Continue? " answer

# Bad: Long-running process
./expensive-analysis.sh

# Good: Quick checks
[ -f ".allowed" ] && crush_approve