Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions hooks/claude/dangerous-actions-blocker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/bash
# dangerous-actions-blocker.sh - Block dangerous CLI operations
# Hook: PreToolUse (Bash)
# Blocks: rm -rf, force-push, secret exposure, destructive git ops

set -euo pipefail

# Read the tool input from stdin
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if [ -z "$COMMAND" ]; then
exit 0
fi

# === DESTRUCTIVE FILE OPERATIONS ===
# Skip host-path checks when `rm` runs inside a container.
# `docker exec <ctr> rm /path` and `kubectl exec <pod> -- rm /path` operate on the
# container's filesystem, not the host — blocking them is a false positive.
_in_container_ctx=false
if echo "$COMMAND" | grep -qE '^(docker|kubectl)\s+exec\s+'; then
_in_container_ctx=true
fi

if [ "$_in_container_ctx" = "false" ] && echo "$COMMAND" | grep -qE 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*(\/|~|\$HOME|\.\.)'; then
echo '{"decision":"block","reason":"BLOCKED: rm -rf on root/home/parent directory. Use a safer path."}'
exit 0
fi

if [ "$_in_container_ctx" = "false" ] && echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*r'; then
# Allow rm -rf on safe paths (node_modules, dist, build, tmp, cache, .next, __pycache__)
if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+(node_modules|dist|build|\.next|__pycache__|\.cache|tmp|\.tmp|coverage|\.nyc_output)'; then
exit 0
fi
echo '{"decision":"ask","reason":"rm -rf detected. Please confirm this is intentional."}'
exit 0
fi

# === DESTRUCTIVE GIT OPERATIONS ===
# Block --force but allow --force-with-lease
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force'; then
if ! echo "$COMMAND" | grep -qE 'force-with-lease'; then
echo '{"decision":"block","reason":"BLOCKED: git push --force. Use --force-with-lease instead."}'
exit 0
fi
fi

if echo "$COMMAND" | grep -qE 'git\s+push\s+.*\s-f(\s|$)'; then
echo '{"decision":"block","reason":"BLOCKED: git push -f. Use --force-with-lease instead."}'
exit 0
fi

if echo "$COMMAND" | grep -qE 'git\s+reset\s+--hard'; then
echo '{"decision":"ask","reason":"git reset --hard will discard uncommitted changes. Confirm?"}'
exit 0
fi

if echo "$COMMAND" | grep -qE 'git\s+clean\s+-[a-zA-Z]*f'; then
echo '{"decision":"ask","reason":"git clean -f will permanently delete untracked files. Confirm?"}'
exit 0
fi

if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--\s+\.'; then
echo '{"decision":"ask","reason":"git checkout -- . will discard all unstaged changes. Confirm?"}'
exit 0
fi

if echo "$COMMAND" | grep -qE 'git\s+branch\s+-D'; then
echo '{"decision":"ask","reason":"git branch -D force-deletes a branch even if not merged. Confirm?"}'
exit 0
fi

# === SECRETS EXPOSURE ===
if echo "$COMMAND" | grep -qE '(cat|echo|printf|head|tail|less|more)\s+.*\.(env|pem|key|secret|credentials|token)'; then
echo '{"decision":"block","reason":"BLOCKED: Reading a potential secrets file. Use environment variables instead."}'
exit 0
fi

if echo "$COMMAND" | grep -qiE '(ANTHROPIC_API_KEY|AWS_SECRET|OPENAI_API_KEY|DATABASE_URL|PRIVATE_KEY|TOKEN|PASSWORD)='; then
echo '{"decision":"block","reason":"BLOCKED: Secret/credential detected in command. Use env vars or .env files."}'
exit 0
fi

# === DATABASE DESTRUCTIVE OPS ===
if echo "$COMMAND" | grep -qiE '(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;)'; then
echo '{"decision":"block","reason":"BLOCKED: Destructive database operation (DROP/TRUNCATE/DELETE ALL). Do this manually."}'
exit 0
fi

# === DOCKER DESTRUCTIVE OPS ===
if echo "$COMMAND" | grep -qE 'docker\s+system\s+prune\s+(-a|--all)'; then
echo '{"decision":"ask","reason":"docker system prune -a will remove ALL unused images, containers, networks. Confirm?"}'
exit 0
fi

if echo "$COMMAND" | grep -qE 'docker\s+(rm|rmi)\s+-f\s+\$\(docker'; then
echo '{"decision":"ask","reason":"Mass docker removal detected. Confirm?"}'
exit 0
fi

# All clear
exit 0
115 changes: 115 additions & 0 deletions hooks/claude/test-dangerous-actions-blocker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/bin/bash
# Test suite for dangerous-actions-blocker.sh
# Usage: bash hooks/claude/test-dangerous-actions-blocker.sh

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
HOOK="$SCRIPT_DIR/dangerous-actions-blocker.sh"
PASSED=0
FAILED=0

# Helper: run hook with a command, return stdout
run_hook() {
local cmd="$1"
echo "{\"tool_input\":{\"command\":\"$cmd\"}}" | bash "$HOOK" 2>/dev/null || true
}

# Helper: assert output contains expected decision
assert_decision() {
local description="$1"
local cmd="$2"
local expected_decision="$3"
local output
output=$(run_hook "$cmd")

if [ -z "$expected_decision" ]; then
# Expect no output (allowed)
if [ -z "$output" ]; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
echo "FAIL: $description"
echo " cmd: $cmd"
echo " expected: (allow - no output)"
echo " got: $output"
fi
else
if echo "$output" | grep -q "\"decision\":\"$expected_decision\""; then
PASSED=$((PASSED + 1))
else
FAILED=$((FAILED + 1))
echo "FAIL: $description"
echo " cmd: $cmd"
echo " expected: $expected_decision"
echo " got: ${output:-(empty)}"
fi
fi
}

echo "=== Dangerous Actions Blocker — Test Suite ==="
echo ""

# --- DESTRUCTIVE FILE OPERATIONS ---
echo "--- File operations ---"
assert_decision "rm -rf / is blocked" "rm -rf /" "block"
assert_decision "rm -rf ~ is blocked" "rm -rf ~" "block"
assert_decision "rm -rf .. is blocked" "rm -rf .." "block"
assert_decision "rm -rf /etc is blocked" "rm -rf /etc" "block"
assert_decision "rm -rf random dir asks" "rm -rf myproject" "ask"
assert_decision "rm -fr also asks" "rm -fr myproject" "ask"
assert_decision "rm -rf node_modules is allowed" "rm -rf node_modules" ""
assert_decision "rm -rf dist is allowed" "rm -rf dist" ""
assert_decision "rm -rf .next is allowed" "rm -rf .next" ""
assert_decision "rm -rf __pycache__ is allowed" "rm -rf __pycache__" ""
assert_decision "rm single file is allowed" "rm myfile.txt" ""
assert_decision "docker exec rm is allowed" "docker exec mycontainer rm -rf /app/tmp" ""
assert_decision "kubectl exec rm is allowed" "kubectl exec mypod -- rm -rf /app/cache" ""

# --- GIT OPERATIONS ---
echo "--- Git operations ---"
assert_decision "git push --force is blocked" "git push --force" "block"
assert_decision "git push -f is blocked" "git push origin main -f" "block"
assert_decision "git push --force-with-lease ok" "git push --force-with-lease" ""
assert_decision "git reset --hard asks" "git reset --hard HEAD~1" "ask"
assert_decision "git clean -f asks" "git clean -f" "ask"
assert_decision "git clean -fd asks" "git clean -fd" "ask"
assert_decision "git checkout -- . asks" "git checkout -- ." "ask"
assert_decision "git branch -D asks" "git branch -D my-branch" "ask"
assert_decision "normal git push is allowed" "git push origin main" ""
assert_decision "git status is allowed" "git status" ""
assert_decision "git commit is allowed" "git commit -m fix" ""

# --- SECRETS ---
echo "--- Secrets exposure ---"
assert_decision "cat .env is blocked" "cat .env" "block"
assert_decision "cat .pem is blocked" "cat server.pem" "block"
assert_decision "cat .key is blocked" "cat private.key" "block"
assert_decision "head .credentials is blocked" "head .credentials" "block"
assert_decision "API key in command is blocked" "ANTHROPIC_API_KEY=sk-123 curl" "block"
assert_decision "cat normal file is allowed" "cat README.md" ""

# --- DATABASE ---
echo "--- Database operations ---"
assert_decision "DROP TABLE is blocked" "psql -c DROP TABLE users;" "block"
assert_decision "TRUNCATE TABLE is blocked" "mysql -e TRUNCATE TABLE logs;" "block"
assert_decision "SELECT is allowed" "psql -c SELECT * FROM users;" ""

# --- DOCKER ---
echo "--- Docker operations ---"
assert_decision "docker system prune -a asks" "docker system prune -a" "ask"
assert_decision "docker system prune --all asks" "docker system prune --all" "ask"
assert_decision "docker ps is allowed" "docker ps" ""

# --- EDGE CASES ---
echo "--- Edge cases ---"
assert_decision "empty input is allowed" "" ""
assert_decision "safe command is allowed" "ls -la" ""
assert_decision "cargo build is allowed" "cargo build --release" ""

echo ""
echo "=== Results: $PASSED passed, $FAILED failed ==="

if [ "$FAILED" -gt 0 ]; then
exit 1
fi
Loading