11 KiB
11 KiB
Security Best Practices
Comprehensive security guidance for Claude Code hooks.
Input Validation
Validate All Input
Always validate and sanitize hook input before use:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Validate input exists
if [[ -z "$FILE_PATH" ]]; then
echo "Error: file_path missing" >&2
exit 1
fi
# Validate format
if [[ ! "$FILE_PATH" =~ ^[a-zA-Z0-9_./-]+$ ]]; then
echo "Error: invalid characters in file path" >&2
exit 2
fi
Check for Path Traversal
Block directory traversal attacks:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Block path traversal
if echo "$FILE_PATH" | grep -qE '\.\./'; then
cat << EOF >&2
Path traversal detected: $FILE_PATH
Paths containing '..' are not allowed.
EOF
exit 2
fi
# Block absolute paths outside project
if [[ "$FILE_PATH" == /* ]] && [[ ! "$FILE_PATH" == "$CLAUDE_PROJECT_DIR"* ]]; then
echo "Access outside project directory blocked: $FILE_PATH" >&2
exit 2
fi
Block Sensitive System Paths
Prevent access to system files:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Blocked system paths
BLOCKED_PATHS=(
'^/etc/'
'^/root/'
'^/home/[^/]+/\.ssh/'
'^/var/log/'
'^/sys/'
'^/proc/'
'^/boot/'
'^/usr/bin/'
'^/usr/sbin/'
)
for pattern in "${BLOCKED_PATHS[@]}"; do
if echo "$FILE_PATH" | grep -qE "$pattern"; then
cat << EOF >&2
Access to sensitive system path blocked: $FILE_PATH
This path is restricted for security reasons.
EOF
exit 2
fi
done
Detect Sensitive Files
Warn or block access to sensitive project files:
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Sensitive file patterns
SENSITIVE_PATTERNS=(
'\.env$'
'\.env\.'
'id_rsa'
'id_ed25519'
'\.pem$'
'\.key$'
'\.p12$'
'credentials'
'password'
'token'
'secret'
'\.git/config$'
'\.npmrc$'
'\.pypirc$'
)
for pattern in "${SENSITIVE_PATTERNS[@]}"; do
if echo "$FILE_PATH" | grep -qiE "$pattern"; then
cat << EOF >&2
Warning: Accessing sensitive file: $FILE_PATH
This file may contain sensitive information.
EOF
# Could exit 2 to block, or continue with warning
fi
done
Command Injection Prevention
Always Quote Variables
# WRONG - vulnerable to injection
rm $FILE_PATH
cd $DIRECTORY
echo $CONTENT
# CORRECT - properly quoted
rm "$FILE_PATH"
cd "$DIRECTORY"
echo "$CONTENT"
Avoid eval
# WRONG - dangerous
eval "$USER_COMMAND"
# CORRECT - use specific commands
if [[ "$USER_COMMAND" == "format" ]]; then
black "$FILE_PATH"
fi
Validate Command Patterns
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Block dangerous command patterns
DANGEROUS_PATTERNS=(
'\brm\s+-rf\s+/' # rm -rf /
'\brm\s+--no-preserve-root' # rm --no-preserve-root
'\bmkfs\b' # filesystem format
'\bdd\s+if=' # disk destruction
'\bformat\s+[cC]:' # Windows format
'>\s*/dev/sd[a-z]' # overwrite disk
':()\{\s*:\|\:&\s*\};:' # Fork bomb
'\bchmod\s+777\s+/' # Dangerous permissions
'\bchown\s+.*\s+/' # System ownership change
'\bcurl\s+.*\|\s*bash' # Pipe to bash
'\bwget\s+.*\|\s*bash' # Pipe to bash
'\bsudo\s+rm' # Sudo rm
'\bgit\s+push\s+--force\s+origin\s+main' # Force push main
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qE "$pattern"; then
cat << EOF >&2
Dangerous command blocked: $COMMAND
Pattern matched: $pattern
EOF
exit 2
fi
done
Path Security
Use Absolute Paths
Always construct paths from known roots:
#!/usr/bin/env bash
# Use CLAUDE_PROJECT_DIR for project paths
SCRIPT_PATH="$CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh"
# Use CLAUDE_PLUGIN_ROOT for plugin paths
PLUGIN_SCRIPT="${CLAUDE_PLUGIN_ROOT}/scripts/validate.sh"
# Never rely on relative paths
# BAD: ./scripts/validate.sh
# GOOD: "$CLAUDE_PROJECT_DIR/.claude/scripts/validate.sh"
Validate Script Existence
#!/usr/bin/env bash
SCRIPT_PATH="$CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh"
# Check exists
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "Error: script not found: $SCRIPT_PATH" >&2
exit 1
fi
# Check executable
if [[ ! -x "$SCRIPT_PATH" ]]; then
echo "Error: script not executable: $SCRIPT_PATH" >&2
exit 1
fi
# Execute safely
"$SCRIPT_PATH" "$@"
Resolve Symlinks
#!/usr/bin/env bash
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
# Resolve symlinks to check actual destination
REAL_PATH=$(realpath "$FILE_PATH" 2>/dev/null || echo "$FILE_PATH")
# Check the real path is within project
if [[ ! "$REAL_PATH" == "$CLAUDE_PROJECT_DIR"* ]]; then
echo "Symlink points outside project: $FILE_PATH -> $REAL_PATH" >&2
exit 2
fi
Sensitive Data Protection
Never Log Sensitive Data
#!/usr/bin/env bash
INPUT=$(cat)
# WRONG - logs everything including secrets
echo "Input: $INPUT" >> /tmp/debug.log
# CORRECT - log only safe fields
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
echo "Tool: $TOOL_NAME" >> /tmp/debug.log
# CORRECT - filter sensitive fields before logging
echo "$INPUT" | jq 'del(.tool_input.password, .tool_input.api_key, .tool_input.token)' >> /tmp/debug.log
Sanitize Output
#!/usr/bin/env bash
INPUT=$(cat)
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
# Check for secrets in content
if echo "$CONTENT" | grep -qiE '(password|api_key|secret|token)\s*[=:]\s*\S+'; then
echo "Warning: Potential secret detected in content" >&2
# Could block or just warn
fi
Protect Environment Variables
#!/usr/bin/env bash
# Don't expose sensitive env vars
# WRONG
echo "API_KEY=$API_KEY"
env | grep -i secret
# CORRECT - never print secrets
echo "API key configured: $([ -n "$API_KEY" ] && echo "yes" || echo "no")"
Timeout Protection
Set Appropriate Timeouts
{
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/validate.sh",
"timeout": 5
}]
}],
"PostToolUse": [{
"matcher": "Write|Edit",
"hooks": [{
"type": "command",
"command": "./.claude/hooks/format.sh",
"timeout": 30
}]
}]
}
}
Guidelines:
- Quick validation: 3-5 seconds
- Formatting: 10-30 seconds
- Network operations: 30-60 seconds
- Default: 60 seconds for command, 30 seconds for prompt
Handle Timeouts Gracefully
#!/usr/bin/env bash
set -euo pipefail
# Set internal timeout for network operations
timeout 10 curl -s https://api.example.com/validate || {
echo "API validation skipped (timeout)" >&2
exit 0 # Don't block on timeout
}
Error Handling
Use Strict Mode
#!/usr/bin/env bash
set -euo pipefail # Exit on error, undefined vars, pipe failures
# Also consider:
set -E # Inherit ERR trap in functions
trap 'echo "Error on line $LINENO" >&2' ERR
Validate Dependencies
#!/usr/bin/env bash
set -euo pipefail
# Check required tools exist
for cmd in jq git curl; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: $cmd not installed" >&2
exit 1
fi
done
Handle JSON Parsing Errors
#!/usr/bin/env bash
set -euo pipefail
# Read input with error handling
INPUT=$(cat) || {
echo "Error: failed to read stdin" >&2
exit 1
}
# Parse with validation
if ! echo "$INPUT" | jq empty 2>/dev/null; then
echo "Error: invalid JSON input" >&2
exit 1
fi
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
if [[ -z "$TOOL_NAME" ]]; then
echo "Error: tool_name missing" >&2
exit 1
fi
Permission Control
PreToolUse Permission Decisions
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Auto-approve reads of non-sensitive files
if [[ "$TOOL_NAME" == "Read" ]] && [[ ! "$FILE_PATH" =~ \.(env|key|pem)$ ]]; then
cat << EOF
{
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}
EOF
exit 0
fi
# Ask for writes to core files
if [[ "$FILE_PATH" =~ src/core/ ]]; then
cat << EOF
{
"continue": true,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Write to core module requires confirmation"
}
}
EOF
exit 0
fi
# Default: allow
exit 0
PermissionRequest Hook
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
# Auto-deny certain operations
if [[ "$TOOL_NAME" =~ (delete|destroy|remove) ]]; then
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive operations require manual approval"
}
}
EOF
exit 0
fi
Audit Trail
Log All Operations
#!/usr/bin/env bash
set -euo pipefail
AUDIT_FILE="$CLAUDE_PROJECT_DIR/.claude/audit.log"
INPUT=$(cat)
TIMESTAMP=$(date -Iseconds)
HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "N/A"')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // "N/A"')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
# Create audit entry (filter sensitive data)
AUDIT_ENTRY=$(jq -n \
--arg ts "$TIMESTAMP" \
--arg event "$HOOK_EVENT" \
--arg tool "$TOOL_NAME" \
--arg file "$FILE_PATH" \
--arg session "$SESSION_ID" \
--arg user "$USER" \
'{
timestamp: $ts,
event: $event,
tool: $tool,
file: $file,
session: $session,
user: $user
}')
echo "$AUDIT_ENTRY" >> "$AUDIT_FILE"
# Rotate: keep only last 10000 entries
tail -n 10000 "$AUDIT_FILE" > "$AUDIT_FILE.tmp" && mv "$AUDIT_FILE.tmp" "$AUDIT_FILE"
exit 0
Security Checklist
Before Deploying Hooks
- All input validated and sanitized
- Path traversal attacks blocked
- Sensitive system paths protected
- All shell variables quoted
- No eval or command injection vectors
- Sensitive data not logged
- Appropriate timeouts set
- Dependencies validated
- Error handling robust
- Audit trail enabled
Regular Security Review
- Review hook scripts for vulnerabilities
- Check for hardcoded secrets
- Verify timeout values are appropriate
- Audit logged data for sensitive info leaks
- Update blocked patterns for new threats
- Test hooks with malicious input