diff --git a/hooks/claude/dangerous-actions-blocker.sh b/hooks/claude/dangerous-actions-blocker.sh new file mode 100755 index 000000000..1ba53dc55 --- /dev/null +++ b/hooks/claude/dangerous-actions-blocker.sh @@ -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 rm /path` and `kubectl exec -- 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 diff --git a/hooks/claude/test-dangerous-actions-blocker.sh b/hooks/claude/test-dangerous-actions-blocker.sh new file mode 100755 index 000000000..a10e461a2 --- /dev/null +++ b/hooks/claude/test-dangerous-actions-blocker.sh @@ -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