Loading...
Loading...
Expert guidance for writing secure, reliable, and performant Claude Code hooks - validates design decisions, enforces best practices, and prevents common pitfalls
npx skill4agent add pr-pm/prpm claude-hook-writerPreToolUsePostToolUseUserPromptSubmitSessionStartSessionEndNotificationStopSubagentStopPreCompactmatcher: "*""Write""Edit|Write""Bash""mcp__github__*""*"# PreToolUse / PostToolUse
{
"input": {
"file_path": "/path/to/file.ts", // Read, Write, Edit
"command": "npm test", // Bash
"old_string": "...", // Edit
"new_string": "..." // Edit
}
}FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
if [[ -z "$FILE" ]]; then
echo "No file path provided" >&2
exit 1
fitype: "command"type: "prompt"exit 0exit 2exit 1#!/bin/bash
set -euo pipefail # Exit on errors, undefined vars
INPUT=$(cat)
# Validate JSON parse
if ! FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty' 2>&1); then
echo "JSON parse failed: $FILE" >&2
exit 1
fi
# Validate field exists
if [[ -z "$FILE" ]]; then
echo "No file path in input" >&2
exit 1
fi# Validate file is in project
if [[ "$FILE" != "$CLAUDE_PROJECT_DIR"* ]]; then
echo "File outside project: $FILE" >&2
exit 2 # Block operation
fi
# Validate no directory traversal
if [[ "$FILE" == *".."* ]]; then
echo "Path traversal detected: $FILE" >&2
exit 2
fi# Block list (extend as needed)
BLOCKED_PATTERNS=(
".env"
".env.*"
"*.pem"
"*.key"
"*credentials*"
".git/*"
".ssh/*"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if [[ "$FILE" == $pattern ]]; then
echo "Blocked: $FILE matches sensitive pattern $pattern" >&2
exit 2
fi
done# WRONG
cat $FILE # Breaks on "my file.txt"
prettier --write $FILE # Fails with spaces
# RIGHT
cat "$FILE" # Handles spaces
prettier --write "$FILE" # Safe# WRONG - relative path might not resolve
./my-script.sh
# RIGHT - explicit path
"${CLAUDE_PLUGIN_ROOT}/scripts/my-script.sh"
# ALSO RIGHT - use full path
/Users/username/.claude/scripts/my-script.sh# Check tool exists
if ! command -v prettier &> /dev/null; then
echo "prettier not installed, skipping" >&2
exit 0 # Success exit (just skip)
fi
# Check file exists
if [[ ! -f "$FILE" ]]; then
echo "File not found: $FILE" >&2
exit 1
fi{
"hooks": [{
"type": "command",
"command": "./slow-operation.sh",
"timeout": 10000 // 10 seconds
}]
}# Don't block Claude
(heavy_operation "$FILE" &)
exit 0LOG_FILE=~/.claude-hooks/my-hook.log
# Log to stderr (shown in transcript)
echo "Hook failed: some reason" >&2
# Or log to file (for debugging)
echo "[$(date)] Error: some reason" >> "$LOG_FILE""file with spaces.txt""文件.txt""src/deep/nested/path/file.tsx""/absolute/path.txt""../../../etc/passwd"null// BAD - runs on everything
{"matcher": "*", ...}
// GOOD - only file writes
{"matcher": "Write", ...}
// BETTER - only TypeScript writes
// (check file extension in hook)
{"matcher": "Write", ...}LOCK_FILE="/tmp/claude-hook-${SESSION_ID}-${HOOK_NAME}.lock"
if [[ -f "$LOCK_FILE" ]]; then
exit 0 # Already running
fi
touch "$LOCK_FILE"
trap "rm -f '$LOCK_FILE'" EXIT # Clean up on exit
# Do work here
expensive_operation#!/bin/bash
set -euo pipefail
# Parse input
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
# Validate
[[ -n "$FILE" ]] || exit 0
[[ -f "$FILE" ]] || exit 0
[[ "$FILE" == "$CLAUDE_PROJECT_DIR"* ]] || exit 0
# Check formatter installed
if ! command -v prettier &> /dev/null; then
exit 0
fi
# Format by extension
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
prettier --write "$FILE" 2>/dev/null || exit 0
;;
*.py)
black "$FILE" 2>/dev/null || exit 0
;;
*.go)
gofmt -w "$FILE" 2>/dev/null || exit 0
;;
esac{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "/path/to/format-on-save.sh",
"timeout": 5000
}]
}]
}
}#!/bin/bash
set -euo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
[[ -n "$FILE" ]] || exit 0
# Sensitive patterns
BLOCKED=(
".env"
".env.*"
"*.pem"
"*.key"
"*secret*"
"*credential*"
".git/*"
)
for pattern in "${BLOCKED[@]}"; do
# Use case for glob matching
case "$FILE" in
$pattern)
echo "🚫 Blocked: $FILE is a sensitive file" >&2
echo " Pattern: $pattern" >&2
exit 2 # Block operation
;;
esac
done
exit 0 # Allow{
"hooks": {
"PreToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "/path/to/block-sensitive.sh"
}]
}]
}
}#!/bin/bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.input.command // empty')
[[ -n "$COMMAND" ]] || exit 0
LOG_FILE=~/claude-commands.log
mkdir -p "$(dirname "$LOG_FILE")"
# Log with timestamp and context
{
echo "---"
echo "Time: $(date '+%Y-%m-%d %H:%M:%S')"
echo "Directory: $CLAUDE_CURRENT_DIR"
echo "Command: $COMMAND"
} >> "$LOG_FILE"
exit 0{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/path/to/command-logger.sh"
}]
}]
}
}{
"hooks": {
"PreToolUse": [{
"matcher": "Write",
"hooks": [{
"type": "prompt",
"prompt": "Analyze the file content being written to ${input.file_path}. Check if it contains: hardcoded API keys, AWS credentials, private keys, passwords, or secrets. Return {\"decision\": \"block\", \"reason\": \"<specific issue>\"} if found, otherwise {\"decision\": \"allow\"}.",
"schema": {
"type": "object",
"properties": {
"decision": {"enum": ["allow", "block"]},
"reason": {"type": "string"}
},
"required": ["decision"]
}
}]
}]
}
}# Test with sample JSON
echo '{
"session_id": "test",
"input": {
"file_path": "/tmp/test.ts"
}
}' | ./my-hook.sh
# Check exit code
echo $? # 0 = success, 2 = blocked, 1 = error#!/bin/bash
# test-hook.sh
HOOK=./my-hook.sh
test_case() {
local description="$1"
local input="$2"
local expected_exit="$3"
echo "Testing: $description"
echo "$input" | $HOOK
actual_exit=$?
if [[ $actual_exit -eq $expected_exit ]]; then
echo " ✓ PASS"
else
echo " ✗ FAIL (expected exit $expected_exit, got $actual_exit)"
return 1
fi
}
# Test cases
test_case "Normal file" \
'{"input":{"file_path":"/tmp/test.ts"}}' \
0
test_case "Sensitive .env file" \
'{"input":{"file_path":".env"}}' \
2
test_case "File with spaces" \
'{"input":{"file_path":"/tmp/my file.ts"}}' \
0
test_case "Missing file_path" \
'{"input":{}}' \
1
test_case "Malformed JSON" \
'not json' \
1
echo "All tests passed"my-hook/
├── prpm.json # Package manifest
├── hook.json # Hook configuration
├── scripts/
│ └── my-hook.sh # Hook script
└── README.md # Documentation{
"name": "@yourname/my-hook",
"version": "1.0.0",
"description": "Brief description of what hook does (shown in search)",
"author": "Your Name",
"format": "claude",
"subtype": "hook",
"tags": [
"formatting",
"security",
"automation"
],
"main": "hook.json",
"scripts": {
"test": "./test-hook.sh"
}
}{
"hooks": {
"PostToolUse": [{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/my-hook.sh",
"timeout": 5000
}]
}]
}
}${CLAUDE_PLUGIN_ROOT}{
"hooks": {
"PreToolUse": [{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "./my-hook.sh",
"timeout": 5000,
"continue": true, // Whether Claude continues after hook (default: true)
"stopReason": "string", // Message shown when continue is false
"suppressOutput": false, // Hide stdout from transcript (default: false)
"systemMessage": "string" // Warning message shown to user
}]
}]
}
}continuefalse{
"type": "command",
"command": "./validate-security.sh",
"continue": false,
"stopReason": "Security validation failed. Please review the detected issues before proceeding."
}continuecontinuestopReasoncontinue: false{
"continue": false,
"stopReason": "Pre-commit checks failed. Fix linting errors and try again."
}suppressOutputtrue{
"type": "command",
"command": "./sync-to-cloud.sh",
"suppressOutput": true // Don't show sync progress in transcript
}systemMessage{
"type": "command",
"command": "./check-dependencies.sh",
"systemMessage": "⚠️ Some dependencies are outdated. Consider running 'npm update'."
}stopReasonsystemMessagestopReasoncontinue: false# My Hook
Brief description.
## What It Does
- Clear, specific bullet points
- Mention which events it triggers on
- Mention which tools it matches
## Installation
```bash
prpm install @yourname/my-hooknpm install -g prettierbrew install jq
### Publishing
```bash
# Test locally first
prpm test
# Publish
prpm publish
# Version bumps
prpm publish patch # 1.0.0 -> 1.0.1
prpm publish minor # 1.0.0 -> 1.1.0
prpm publish major # 1.0.0 -> 2.0.0# BREAKS on spaces
prettier --write $FILE
# SAFE
prettier --write "$FILE"# DANGEROUS - no validation
FILE=$(jq -r '.input.file_path')
rm "$FILE"
# SAFE - validate first
FILE=$(jq -r '.input.file_path // empty')
[[ "$FILE" == "$CLAUDE_PROJECT_DIR"* ]] || exit 2
[[ "$FILE" != *".env"* ]] || exit 2
rm "$FILE"# BLOCKS Claude for 30 seconds
npm test
# RUN IN BACKGROUND
(npm test &)
exit 0# PreToolUse hook that should block
if [[ $FILE == ".env" ]]; then
echo "Don't edit .env" >&2
exit 1 # WRONG - doesn't block, just logs error
fi
# RIGHT
if [[ $FILE == ".env" ]]; then
echo "Blocked: .env is protected" >&2
exit 2 # Blocks operation
fi# WRONG - appears in transcript
echo "Hook running..."
# RIGHT - stderr or file
echo "Hook running..." >&2
# or
echo "Hook running..." >> ~/.claude-hooks/debug.log# BREAKS if prettier not installed
prettier --write "$FILE"
# SAFE
if command -v prettier &>/dev/null; then
prettier --write "$FILE"
fi#!/bin/bash
set -x # Print commands as they executePreToolUse hook: ./my-hook.sh
stdout: Formatted file.ts
stderr:
exit: 0
duration: 47ms# Debug what jq extracts
INPUT=$(cat)
echo "$INPUT" | jq '.' >&2 # Show full JSON
echo "$INPUT" | jq -r '.input.file_path' >&2 # Show fieldecho "PROJECT_DIR: $CLAUDE_PROJECT_DIR" >&2
echo "CURRENT_DIR: $CLAUDE_CURRENT_DIR" >&2
echo "SESSION_ID: $SESSION_ID" >&2
echo "PLUGIN_ROOT: $CLAUDE_PLUGIN_ROOT" >&2021typecommandprompttimeoutcontinuestopReasonsuppressOutputsystemMessage$CLAUDE_PROJECT_DIR$CLAUDE_CURRENT_DIR$SESSION_ID$CLAUDE_PLUGIN_ROOT$CLAUDE_ENV_FILE{
"session_id": "...",
"transcript_path": "...",
"current_dir": "...",
"input": {
// Tool-specific fields
}
}# Extract with default
$(jq -r '.input.file_path // empty')
# Extract array
$(jq -r '.input.files[]')
# Check field exists
if jq -e '.input.file_path' >/dev/null; then
# Parse entire object
INPUT_OBJ=$(jq '.input')