|
#!/bin/bash |
|
# Guard: Block destructive bash commands before execution |
|
# PreToolUse hook for Claude Code |
|
# exit 2 = BLOCK (command will not execute) |
|
# exit 0 + stderr = WARN (command executes with warning) |
|
# exit 0 = PASS |
|
# |
|
# Setup: Add as PreToolUse hook in ~/.claude/settings.json |
|
# { |
|
# "hooks": { |
|
# "PreToolUse": [ |
|
# { "matcher": "Bash", "command": "bash ~/.claude/hooks/guard-destructive.sh" } |
|
# ] |
|
# } |
|
# } |
|
|
|
DEBT_FILE="$HOME/.claude/coffee-debt" |
|
LOG_FILE="$HOME/.claude/coffee-log.jsonl" |
|
|
|
input=$(cat) |
|
command=$(echo "$input" | jq -r '.tool_input.command // empty') |
|
|
|
if [ -z "$command" ]; then |
|
exit 0 |
|
fi |
|
|
|
# --- SAFE PATTERNS (check first, short-circuit) --- |
|
|
|
# Safe rm targets: build artifacts, caches, temp dirs |
|
SAFE_RM_TARGETS="node_modules|dist|build|\.next|__pycache__|\.cache|coverage|\.turbo|\.venv|\.pytest_cache|\.ruff_cache|\.mypy_cache|\.tox|\.eggs|\.egg-info|tmp|temp|\.parcel-cache|\.nuxt|\.output|\.astro" |
|
|
|
is_safe_rm() { |
|
local cmd="$1" |
|
local targets="" |
|
local skip_rm=1 |
|
for word in $cmd; do |
|
if [ $skip_rm -eq 1 ]; then |
|
skip_rm=0 |
|
continue |
|
fi |
|
case "$word" in |
|
-*) continue ;; |
|
*) targets="$targets $word" ;; |
|
esac |
|
done |
|
[ -z "$targets" ] && return 1 |
|
for target in $targets; do |
|
local base=$(basename "$target") |
|
if ! echo "$base" | grep -qE "^(${SAFE_RM_TARGETS})$"; then |
|
return 1 |
|
fi |
|
done |
|
return 0 |
|
} |
|
|
|
# --- Check a single command segment --- |
|
|
|
check_single_command() { |
|
local cmd="$1" |
|
|
|
# === BLOCK PATTERNS === |
|
|
|
# Recursive deletion with dangerous paths |
|
if echo "$cmd" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|--recursive\s+)'; then |
|
if ! is_safe_rm "$cmd"; then |
|
if echo "$cmd" | grep -qE 'rm\s.*\s*(~/|/Users|/home|\$HOME|/\s|/\s*$|\.\.)'; then |
|
echo "BLOCK|rm_dangerous_path|file_deletion|BLOCKED: Recursive deletion targeting dangerous path." |
|
return |
|
fi |
|
echo "BLOCK|rm_recursive|file_deletion|BLOCKED: Recursive file deletion detected. Safe targets (node_modules, dist, build, etc.) are auto-allowed." |
|
return |
|
fi |
|
fi |
|
|
|
# Git reset --hard |
|
if echo "$cmd" | grep -qE 'git\s+reset\s+--hard'; then |
|
echo "BLOCK|git_reset_hard|git_destructive|BLOCKED: git reset --hard discards all uncommitted changes permanently." |
|
return |
|
fi |
|
|
|
# Git push --force |
|
if echo "$cmd" | grep -qE 'git\s+push\s+.*(-f|--force-with-lease|--force)'; then |
|
echo "BLOCK|git_push_force|git_destructive|BLOCKED: Force push rewrites remote history." |
|
return |
|
fi |
|
|
|
# Git clean -f |
|
if echo "$cmd" | grep -qE 'git\s+clean\s+.*-[a-zA-Z]*f'; then |
|
echo "BLOCK|git_clean|git_destructive|BLOCKED: git clean -f permanently deletes untracked files." |
|
return |
|
fi |
|
|
|
# Git branch -D (force delete) |
|
if echo "$cmd" | grep -qE 'git\s+branch\s+.*-D'; then |
|
echo "BLOCK|git_branch_force_delete|git_destructive|BLOCKED: git branch -D deletes branch even with unmerged changes." |
|
return |
|
fi |
|
|
|
# Git checkout/restore discard all |
|
if echo "$cmd" | grep -qE 'git\s+checkout\s+--\s*\.|git\s+restore\s+\.'; then |
|
echo "BLOCK|git_discard_all|git_destructive|BLOCKED: This discards ALL working directory changes." |
|
return |
|
fi |
|
|
|
# Database DROP/TRUNCATE |
|
if echo "$cmd" | grep -qiE 'DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE'; then |
|
echo "BLOCK|db_destructive|database_destructive|BLOCKED: DROP/TRUNCATE permanently destroys database objects." |
|
return |
|
fi |
|
|
|
# Cloud resource deletion (customize for your providers) |
|
if echo "$cmd" | grep -qE '(vercel|railway|fly|neon)\s+(rm|remove|delete|destroy)'; then |
|
echo "BLOCK|cloud_delete|cloud_destructive|BLOCKED: Cloud resource deletion detected." |
|
return |
|
fi |
|
|
|
# Docker force prune |
|
if echo "$cmd" | grep -qE 'docker\s+system\s+prune\s+.*-a'; then |
|
echo "BLOCK|docker_prune|docker_destructive|BLOCKED: docker system prune -a removes all unused images." |
|
return |
|
fi |
|
|
|
# chmod 777 |
|
if echo "$cmd" | grep -qE 'chmod\s+(-R\s+)?777'; then |
|
echo "BLOCK|chmod_777|security|BLOCKED: chmod 777 makes files world-accessible." |
|
return |
|
fi |
|
|
|
# === WARN PATTERNS === |
|
|
|
# DELETE without WHERE |
|
if echo "$cmd" | grep -qiE 'DELETE\s+FROM\s+\w+\s*;?\s*$'; then |
|
echo "WARN|delete_no_where|database_risky|WARNING: DELETE without WHERE clause deletes ALL rows." |
|
return |
|
fi |
|
|
|
# DB/backup file deletion |
|
if echo "$cmd" | grep -qE 'rm\s+.*\.(db|sqlite|sqlite3|sql|backup|bak|dump)'; then |
|
echo "WARN|rm_db_file|data_risky|WARNING: Deleting database/backup file." |
|
return |
|
fi |
|
|
|
# Env file deletion |
|
if echo "$cmd" | grep -qE 'rm\s+.*\.env'; then |
|
echo "WARN|rm_env_file|credential_risky|WARNING: Deleting .env file with potential credentials." |
|
return |
|
fi |
|
|
|
# git stash drop |
|
if echo "$cmd" | grep -qE 'git\s+stash\s+drop'; then |
|
echo "WARN|git_stash_drop|git_risky|WARNING: Dropped stashes cannot be recovered." |
|
return |
|
fi |
|
} |
|
|
|
# --- Main execution --- |
|
|
|
# Check for chained commands (&&, ||, ;) |
|
check_all() { |
|
local full_cmd="$1" |
|
# Check full command first |
|
local result=$(check_single_command "$full_cmd") |
|
if [ -n "$result" ]; then |
|
echo "$result" |
|
return |
|
fi |
|
# Split on chain operators and check segments |
|
echo "$full_cmd" | tr '&' '\n' | tr '|' '\n' | tr ';' '\n' | while IFS= read -r segment; do |
|
segment=$(echo "$segment" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') |
|
[ -z "$segment" ] && continue |
|
result=$(check_single_command "$segment") |
|
if [ -n "$result" ]; then |
|
echo "$result" |
|
return |
|
fi |
|
done |
|
} |
|
|
|
result=$(check_all "$command") |
|
|
|
# No match = safe |
|
if [ -z "$result" ]; then |
|
exit 0 |
|
fi |
|
|
|
# Parse result |
|
severity=$(echo "$result" | cut -d'|' -f1) |
|
reason=$(echo "$result" | cut -d'|' -f2) |
|
category=$(echo "$result" | cut -d'|' -f3) |
|
message=$(echo "$result" | cut -d'|' -f4-) |
|
|
|
echo -e "$message" >&2 |
|
|
|
# Log to coffee-log.jsonl (optional, remove if not using Coffee Debt) |
|
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") |
|
CMD_SNIPPET=$(echo "$command" | head -c 300 | jq -Rs '.' 2>/dev/null || echo '""') |
|
LOG_TYPE="blocked" |
|
[ "$severity" = "WARN" ] && LOG_TYPE="warned" |
|
|
|
if [ "$severity" = "BLOCK" ]; then |
|
if [ -f "$DEBT_FILE" ]; then |
|
TOTAL=$(cat "$DEBT_FILE" 2>/dev/null || echo "0") |
|
TOTAL=$((TOTAL + 1)) |
|
echo "$TOTAL" > "$DEBT_FILE" |
|
fi |
|
exit 2 |
|
fi |
|
|
|
exit 0 |