#!/usr/bin/env bash set -euo pipefail # ───────────────────────────────────────────────────────── # Claude Code AI Permission Hooks — Installer # Usage: curl -fsSL https://claude-permissions.myriade.ai | bash # ───────────────────────────────────────────────────────── CLAUDE_DIR=".claude" HOOKS_DIR="$CLAUDE_DIR/hooks" SETTINGS_FILE="$CLAUDE_DIR/settings.json" POLICY_FILE="$CLAUDE_DIR/permission-policy.md" # ── Colors ────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' info() { printf "${BLUE}▸${NC} %s\n" "$1"; } ok() { printf "${GREEN}✓${NC} %s\n" "$1"; } warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } err() { printf "${RED}✗${NC} %s\n" "$1"; } # ── Header ────────────────────────────────────────────── printf "\n${BOLD} Claude Code — AI Permission Hooks${NC}\n" printf " ─────────────────────────────────\n" printf " Safe commands auto-approve, dangerous ones auto-block,\n" printf " everything else gets classified by Claude Sonnet.\n\n" # ── Preflight checks ─────────────────────────────────── if ! command -v python3 &>/dev/null; then err "python3 is required but not found. Install it first." exit 1 fi if ! command -v claude &>/dev/null; then warn "claude CLI not found. The AI hook won't work without it." warn "Install it: https://docs.anthropic.com/en/docs/claude-code" printf "\n" fi # ── Create directories ───────────────────────────────── info "Creating $HOOKS_DIR/" mkdir -p "$HOOKS_DIR" # ── Write rule-check.py ──────────────────────────────── info "Writing rule-based hook..." cat > "$HOOKS_DIR/rule-check.py" << 'HOOK_RULE' #!/usr/bin/env python3 """ Fast rule-based hook. Runs on PreToolUse for Bash commands. Auto-allows safe stuff, auto-blocks dangerous stuff, passes through the rest. """ import json import re import sys input_data = json.loads(sys.stdin.read()) tool_name = input_data.get("tool_name", "") tool_input = input_data.get("tool_input", {}) command = tool_input.get("command", "") # ---- ALWAYS BLOCK ---- DANGEROUS = [ r"rm\s+-rf\s+[/~]", r":()\{\s*:\|:&\s*\};:", r"mkfs\.", r"dd\s+if=.*of=/dev/", r"curl.*\|\s*bash", r"wget.*\|\s*bash", r">\s*/dev/sd", r"chmod\s+777\s+/", r"gh\s+repo\s+delete", r"gh\s+secret\s+set", r"git\s+push.*--force\s+.*main", r"git\s+push.*--force\s+.*master", r"DROP\s+(DATABASE|TABLE)", r"TRUNCATE\s+TABLE", ] # ---- ALWAYS ALLOW ---- SAFE = [ # Read-only / inspection r"^ls(\s|$)", r"^cat\s", r"^head\s", r"^tail\s", r"^grep\s", r"^rg\s", r"^find\s", r"^wc\s", r"^echo\s", r"^pwd$", r"^file\s", r"^stat\s", r"^du\s", r"^df\s", r"^tree\s", r"^which\s", r"^type\s", # Git read r"^git\s+(status|log|diff|branch|show|stash\s+list|remote|tag)", # Git write (non-destructive) r"^git\s+add\s", r"^git\s+commit\s", r"^git\s+checkout\s", r"^git\s+switch\s", r"^git\s+fetch", r"^git\s+pull(\s|$)", r"^git\s+stash(\s|$)", r"^git\s+stash\s+(push|pop|apply)", r"^git\s+merge\s", r"^git\s+rebase\s", r"^git\s+restore\s", r"^git\s+branch\s", # Git push (non-force) r"^git\s+push(\s+(?!.*--force).*|$)", # Python / uv r"^(uv\s+run\s+)?pytest", r"^(uv\s+run\s+)?python3?\s+-m\s+pytest", r"^uv\s+run\s+ruff\s+(check|format)", r"^uv\s+run\s+python", r"^uv\s+run\s+alembic", r"^uv\s+sync", r"^python3?\s+-m\s+py_compile", r"^python3?\s+-c\s", r"^uvx\s+pre-commit", r"^ruff\s+(check|format)", # Node / Yarn / npm / pnpm / bun r"^yarn\s+(dev|build|test|lint|format|type-check|install|run)", r"^npm\s+(test|run|install)", r"^npx\s", r"^pnpm\s+(dev|build|test|lint|format|install|run)", r"^bun\s+(dev|build|test|run|install)", r"^node\s", # Docker (read) r"^docker\s+(ps|images|logs|inspect|compose\s+(ps|logs))", # GitHub CLI (read) r"^gh\s+(pr|issue)\s+(view|list|status|checks|diff)", r"^gh\s+api\s", # Build tools r"^make(\s|$)", r"^cargo\s+(build|test|check|clippy|run)", r"^go\s+(build|test|run|vet|fmt)", # Shell utilities r"^mkdir\s", r"^touch\s", r"^cp\s", r"^mv\s", r"^chmod\s", r"^sort\s", r"^uniq\s", r"^jq\s", r"^tr\s", r"^cut\s", r"^awk\s", r"^sed\s", r"^diff\s", r"^sqlite3\s", r"^timeout\s", # Process / system info r"^lsof\s", r"^kill\s", r"^pkill\s", # Common project scripts r"^bash\s+start\.sh", r"^source\s+\.venv", r"^DOTENV_FILE=", r"^DATABASE_URL=", ] def strip_string_literals(cmd): """Remove content inside string literals so dangerous patterns in quoted text (e.g. 'rm -rf /' mentioned in a PR body) don't trigger false blocks. The raw command structure is preserved for DANGEROUS matching.""" # Heredoc blocks: <<'EOF' ... EOF or < "$HOOKS_DIR/ai-permission.py" << 'HOOK_AI' #!/usr/bin/env python3 """ AI-powered permission hook. Fires on PermissionRequest. Sends the command + policy to Claude Sonnet 4.6 for classification. """ import json import os import subprocess import sys input_data = json.loads(sys.stdin.read()) tool_name = input_data.get("tool_name", "") tool_input = input_data.get("tool_input", {}) cwd = input_data.get("cwd", os.getcwd()) if tool_name == "Bash": description = f"Bash command: {tool_input.get('command', '')}" elif tool_name in ("Edit", "Write"): description = ( f"{tool_name} file: " f"{tool_input.get('file_path', tool_input.get('path', 'unknown'))}" ) elif tool_name == "Read": description = f"Read file: {tool_input.get('file_path', 'unknown')}" else: description = f"Tool: {tool_name}, Input: {json.dumps(tool_input)[:500]}" policy_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "permission-policy.md" ) try: with open(policy_path, "r") as f: policy = f.read() except FileNotFoundError: policy = "Default policy: allow read-only operations, block destructive operations." prompt = f"""{policy} ## Current Request Working directory: {cwd} Request to analyze: {description} Classify this as GREEN (safe, auto-approve), YELLOW (needs human review), or RED (block). Respond with ONLY a JSON object like: {{"score": "GREEN", "reason": "brief explanation"}} """ try: result = subprocess.run( [ "claude", "--print", "--output-format", "text", "--model", "claude-sonnet-4-6", "--no-session-persistence", prompt, ], capture_output=True, text=True, timeout=25, ) response_text = result.stdout.strip() clean = response_text.replace("```json", "").replace("```", "").strip() classification = json.loads(clean) score = classification.get("score", "YELLOW").upper() reason = classification.get("reason", "No reason given") if score == "GREEN": output = { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow", "reason": f"[AI-GREEN] {reason}", }, } } json.dump(output, sys.stdout) elif score == "RED": output = { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "deny", "reason": f"[AI-RED] {reason}", }, } } json.dump(output, sys.stdout) except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError): pass HOOK_AI chmod +x "$HOOKS_DIR/ai-permission.py" # ── Write permission-policy.md ───────────────────────── info "Writing permission policy..." cat > "$POLICY_FILE" << 'POLICY' # Permission Policy You are a security classifier for a coding agent. Classify each request as GREEN, YELLOW, or RED. ## Bash Commands ### GREEN (auto-approve) - Read-only: ls, cat, head, tail, grep, rg, find, wc, file, stat, du, df, tree, which, type - Text processing: sed, awk, sort, uniq, jq, tr, cut, diff - Python/uv: uv run pytest, uv run ruff check/format, uv run python, uv run alembic, uv sync, python -m pytest, uvx pre-commit - Node/Yarn/npm: yarn dev/build/test/lint/format/type-check/install, npm test/run/install, npx, pnpm, bun - Git read: git status, log, diff, branch, show, stash list, remote, tag, fetch - Git write: git add, commit, checkout, switch, pull, stash, merge, rebase, restore, branch - Git push: non-force push to any branch - GitHub CLI read: gh pr/issue view/list/status/checks/diff, gh api - Docker read: docker ps, images, logs, inspect, compose ps/logs - Build tools: make, cargo build/test/check/clippy, go build/test/run - File operations: mkdir, touch, cp, mv, chmod (not 777 on /), rm (single project files) - Process management: lsof, kill, pkill, timeout - Database: sqlite3 (project databases) ### YELLOW (ask the human) - Package installs: pip install, uv pip install, yarn add, npm install (new packages) - Docker write: docker build, run, exec, stop, rm, compose up/down/restart - Network: curl, wget (without pipe to shell), nslookup, dig, whois, nmap - Git force push to non-main branches - GitHub CLI write: gh pr create/edit, gh release - Cloud CLI: gcloud, aws, az commands - Environment variable changes in shell - Migration creation (alembic revision, django makemigrations) - Release/deploy scripts ### RED (block) - Destructive: rm -rf on home/root, format disks, dd to devices - Pipe to shell: curl|bash, wget|sh - System modification: modifying /etc, /usr, systemd - Credential access: reading .ssh, AWS credentials directly - Force push to main/master - Repository deletion: gh repo delete - Secret management: gh secret set - SQL destructive: DROP DATABASE, DROP TABLE, TRUNCATE - Fork bombs, disk writes to /dev ## File Operations ### GREEN - Read/write project source files and tests - Create new files in project directory - Edit configuration files in project ### YELLOW - Shell scripts, CI/CD configs, Dockerfiles - Dependency lock files - Environment files (.env*) ### RED - System files outside project - SSH keys, credentials, dotfiles outside project - Binary executables ## Rules - For piped/chained commands, evaluate each part and use the MOST restrictive score - When in doubt, classify as YELLOW - Respond with ONLY: {"score": "GREEN|YELLOW|RED", "reason": "brief explanation"} POLICY # ── Update settings.json ─────────────────────────────── info "Updating $SETTINGS_FILE..." HOOKS_JSON='{ "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "python3 .claude/hooks/rule-check.py", "timeout": 5000 } ] } ], "PermissionRequest": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 .claude/hooks/ai-permission.py", "timeout": 30000 } ] } ] }' # Merge hooks into existing settings.json (or create new one) python3 - "$SETTINGS_FILE" "$HOOKS_JSON" << 'MERGE_SCRIPT' import json import sys settings_path = sys.argv[1] new_hooks = json.loads(sys.argv[2]) try: with open(settings_path, "r") as f: settings = json.load(f) except (FileNotFoundError, json.JSONDecodeError): settings = {} existing_hooks = settings.get("hooks", {}) # Merge: new hooks take priority, but preserve other hook events (e.g. Notification) for event, config in new_hooks.items(): existing_hooks[event] = config settings["hooks"] = existing_hooks with open(settings_path, "w") as f: json.dump(settings, f, indent=2) f.write("\n") MERGE_SCRIPT # ── Run tests ────────────────────────────────────────── info "Running quick tests..." PASS=0 FAIL=0 # Test 1: safe command result=$(echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | python3 "$HOOKS_DIR/rule-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='allow' else 1)" 2>/dev/null; then ok "ls -la → allow" PASS=$((PASS + 1)) else err "ls -la → expected allow" FAIL=$((FAIL + 1)) fi # Test 2: dangerous command result=$(echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | python3 "$HOOKS_DIR/rule-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='deny' else 1)" 2>/dev/null; then ok "rm -rf / → deny" PASS=$((PASS + 1)) else err "rm -rf / → expected deny" FAIL=$((FAIL + 1)) fi # Test 3: ambiguous command (should pass through = no output) result=$(echo '{"tool_name":"Bash","tool_input":{"command":"docker build ."}}' | python3 "$HOOKS_DIR/rule-check.py") if [ -z "$result" ]; then ok "docker build . → pass-through (to AI hook)" PASS=$((PASS + 1)) else err "docker build . → expected pass-through" FAIL=$((FAIL + 1)) fi # Test 4: git push non-force result=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' | python3 "$HOOKS_DIR/rule-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='allow' else 1)" 2>/dev/null; then ok "git push origin main → allow" PASS=$((PASS + 1)) else err "git push origin main → expected allow" FAIL=$((FAIL + 1)) fi # Test 5: git force push main result=$(echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | python3 "$HOOKS_DIR/rule-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='deny' else 1)" 2>/dev/null; then ok "git push --force origin main → deny" PASS=$((PASS + 1)) else err "git push --force origin main → expected deny" FAIL=$((FAIL + 1)) fi # Test 6: dangerous pattern inside string literal (should pass through, not block) result=$(printf '{"tool_name":"Bash","tool_input":{"command":"gh pr create --body \\"verify rm -rf / is blocked\\""}}' | python3 "$HOOKS_DIR/rule-check.py") if [ -z "$result" ]; then ok "gh pr create --body \"...rm -rf /...\" → pass-through (not falsely blocked)" PASS=$((PASS + 1)) else err "gh pr create --body \"...rm -rf /...\" → expected pass-through, got blocked" FAIL=$((FAIL + 1)) fi # ── Summary ──────────────────────────────────────────── printf "\n" if [ "$FAIL" -eq 0 ]; then printf " ${GREEN}${BOLD}All $PASS tests passed!${NC}\n" else printf " ${RED}${BOLD}$FAIL test(s) failed${NC}, $PASS passed\n" fi printf "\n" printf " ${BOLD}Installed to:${NC}\n" printf " $HOOKS_DIR/rule-check.py — fast regex rules\n" printf " $HOOKS_DIR/ai-permission.py — Sonnet 4.6 fallback\n" printf " $POLICY_FILE — editable policy\n" printf " $SETTINGS_FILE — hooks config\n" printf "\n" printf " ${BOLD}How it works:${NC}\n" printf " Command → rule-check (instant)\n" printf " ├─ SAFE → auto-approve ${GREEN}✓${NC}\n" printf " ├─ DANGEROUS → auto-block ${RED}✗${NC}\n" printf " └─ UNKNOWN → AI hook (Sonnet 4.6, ~5-15s)\n" printf " ├─ GREEN → auto-approve ${GREEN}✓${NC}\n" printf " ├─ RED → auto-block ${RED}✗${NC}\n" printf " └─ YELLOW → asks you ${YELLOW}?${NC}\n" printf "\n" printf " ${BOLD}Customize:${NC} edit ${BLUE}$POLICY_FILE${NC}\n" printf " ${BOLD}Restart Claude Code${NC} for changes to take effect.\n" printf "\n"