#!/usr/bin/env bash set -euo pipefail # ───────────────────────────────────────────────────────── # Claude Code AI Permission Hooks — Installer (v2) # Usage: curl -fsSL https://claude-permissions.myriade.ai | bash # # Single self-learning hook: static rules + AI classification # with learned patterns that persist across sessions. # ───────────────────────────────────────────────────────── CLAUDE_DIR=".claude" HOOKS_DIR="$CLAUDE_DIR/hooks" SETTINGS_FILE="$CLAUDE_DIR/settings.json" POLICY_FILE="$CLAUDE_DIR/permission-policy.md" LEARNED_FILE="$CLAUDE_DIR/learned-patterns.json" # ── 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 (v2)${NC}\n" printf " ──────────────────────────────────────\n" printf " Single self-learning hook: static rules + AI + memory.\n" printf " Commands auto-learn from AI classification & user choices.\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" # ── Clean up old files ───────────────────────────────── if [ -f "$HOOKS_DIR/rule-check.py" ] || [ -f "$HOOKS_DIR/ai-permission.py" ]; then info "Removing old hook files (merged into permission-check.py)..." rm -f "$HOOKS_DIR/rule-check.py" "$HOOKS_DIR/ai-permission.py" fi # ── Write permission-check.py ────────────────────────── info "Writing unified permission hook..." cat > "$HOOKS_DIR/permission-check.py" << 'HOOK_MAIN' #!/usr/bin/env python3 """ Unified permission hook (PreToolUse) for Bash and Read commands. Combines fast rule-based checks with AI classification and learned patterns. Flow: 1. One-time allow list (temp file) → allow + remove entry 2. Learned SAFE patterns → allow 3. Learned DANGEROUS patterns → deny 4. Static SAFE patterns → allow 5. Static DANGEROUS patterns → deny 6. Unknown → call AI (Sonnet) - GREEN → allow + save to learned-safe - RED → deny + save to learned-dangerous - YELLOW → fall through to Claude Code's native permission prompt """ import json import os import re 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", {}) command = tool_input.get("command", "") # ---- Resolve paths ---- try: REPO_ROOT = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=5, ).stdout.strip() except Exception: REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LEARNED_PATTERNS_PATH = os.path.join(REPO_ROOT, ".claude", "learned-patterns.json") POLICY_PATH = os.path.join(REPO_ROOT, ".claude", "permission-policy.md") ALLOW_ONCE_PATH = f"/tmp/.claude-allow-once-{os.getuid()}.json" LEARN_SCRIPT = os.path.join(REPO_ROOT, ".claude", "hooks", "learn.py") # ---- Static patterns ---- DANGEROUS = [ r"rm\s+-rf\s+/(\s|$|\*)", r"rm\s+-rf\s+~(/?\s*$|/\*)", r":()\{\s*:\|:&\s*\};:", r"mkfs\.", r"dd\s+if=.*of=/dev/", 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", ] SAFE = [ # Navigation r"^cd(\s|$)", # 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|fetch|rev-parse)", # 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+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: allow non-force, and --force-with-lease (safe variant) r"^git\s+push\s+--force-with-lease(\s|$)", 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+(?!.*-[^\s]*R)(?!.*--recursive)", r"^rm\s+(?!.*-[^\s]*[rR])(?!.*--recursive)", 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", # Curl to local proxy r"^curl\s+.*127\.0\.0\.1", r"^curl\s+.*localhost", # Package management r"^sudo\s+apt(-get)?\s+(update|install)", # Process / system info r"^lsof(\s|$)", r"^kill\s", r"^pkill\s", # Common project scripts r"^bash\s+start\.sh", r"^source\s+\.venv", ] # ---- Helpers ---- def strip_string_literals(cmd): """Remove content inside string literals so dangerous patterns in quoted text don't trigger false blocks.""" result = re.sub(r"<<-?\s*'?(\w+)'?\n.*?\n\1\b", "", cmd, flags=re.DOTALL) result = re.sub(r"\$\(cat\s+<<.*?\)", "", result, flags=re.DOTALL) result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result, flags=re.DOTALL) result = re.sub(r"'(?:[^'\\]|\\.)*'", "''", result, flags=re.DOTALL) return result def strip_env_prefix(cmd): """Strip leading VAR=value assignments from a command.""" return re.sub(r"^(\s*\w+=[^\s]*\s+)+", "", cmd) def is_dangerous(cmd): cleaned = strip_string_literals(cmd) for pattern in DANGEROUS: if re.search(pattern, cleaned, re.IGNORECASE): return True return False def is_safe(cmd): for pattern in SAFE: if re.search(pattern, cmd): return True stripped = strip_env_prefix(cmd) if stripped != cmd: for pattern in SAFE: if re.search(pattern, stripped): return True return False def split_commands(cmd): cleaned = strip_string_literals(cmd) parts = [ p.strip() for p in re.split(r"\s*(?:&&|\|\||;|\||\n)\s*", cleaned) if p.strip() ] return parts # ---- Learned patterns & one-time allows ---- def load_learned_patterns(): try: with open(LEARNED_PATTERNS_PATH, "r") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {"safe": [], "dangerous": []} def save_learned_pattern(category, pattern, original_command): """Save a learned pattern to the JSON file.""" from datetime import date data = load_learned_patterns() entry = { "pattern": pattern, "command": original_command, "added": date.today().isoformat(), } # Avoid duplicates existing_patterns = [e["pattern"] for e in data.get(category, [])] if pattern not in existing_patterns: data.setdefault(category, []).append(entry) os.makedirs(os.path.dirname(LEARNED_PATTERNS_PATH), exist_ok=True) with open(LEARNED_PATTERNS_PATH, "w") as f: json.dump(data, f, indent=2) def check_learned_patterns(cmd): """Check command against learned patterns. Returns 'allow', 'deny', or None.""" data = load_learned_patterns() for entry in data.get("safe", []): try: if re.search(entry["pattern"], cmd): return "allow" except re.error: continue for entry in data.get("dangerous", []): try: if re.search(entry["pattern"], cmd): return "deny" except re.error: continue return None def check_one_time_allow(cmd): """Check if this command has a one-time allow. Returns True and removes entry if found.""" try: with open(ALLOW_ONCE_PATH, "r") as f: allows = json.load(f) except (FileNotFoundError, json.JSONDecodeError): return False if cmd in allows: allows.remove(cmd) with open(ALLOW_ONCE_PATH, "w") as f: json.dump(allows, f) return True return False # ---- AI classification ---- def classify_with_ai(cmd, cwd): """Call Claude Sonnet to classify the command. Returns (score, reason, pattern) or None.""" 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} Bash command: {cmd} Classify this as GREEN (safe, auto-approve), YELLOW (needs human review), or RED (block). Also provide a generalized regex pattern that would match this type of command for future auto-classification. Respond with ONLY a JSON object like: {{"score": "GREEN", "reason": "brief explanation", "pattern": "^docker\\\\s+build(\\\\s|$)"}} """ try: result = subprocess.run( [ "claude", "--print", "--output-format", "text", "--model", "claude-sonnet-4-6", "--no-session-persistence", prompt, ], capture_output=True, text=True, timeout=12, ) 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") pattern = classification.get("pattern", "") return score, reason, pattern except FileNotFoundError: print( "[permission-check] claude CLI not found — falling through", file=sys.stderr, ) return None except subprocess.TimeoutExpired: print( "[permission-check] claude CLI timed out — falling through", file=sys.stderr, ) return None except json.JSONDecodeError: print( "[permission-check] could not parse AI response — falling through", file=sys.stderr, ) return None except Exception as e: print( f"[permission-check] unexpected error: {e} — falling through", file=sys.stderr, ) return None # ---- Output helpers ---- def output_allow(reason): json.dump( { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": reason, } }, sys.stdout, ) def output_deny(reason, additional_context=None): result = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, } } if additional_context: result["hookSpecificOutput"]["additionalContext"] = additional_context json.dump(result, sys.stdout) # ---- Main decision logic ---- def decide(): # Auto-approve Read tool — always safe if tool_name == "Read": output_allow("Auto-approved: Read tool is always safe") return if tool_name != "Bash" or not command: return cwd = input_data.get("cwd", os.getcwd()) # 1. Check one-time allow list if check_one_time_allow(command): output_allow("One-time allow (user approved)") return # 2. Check learned SAFE patterns learned = check_learned_patterns(command) if learned == "allow": output_allow("Auto-approved: matches learned safe pattern") return # 3. Check learned DANGEROUS patterns if learned == "deny": output_deny("Blocked: matches learned dangerous pattern") return # 4. Check static DANGEROUS patterns (no anchor, searches anywhere) if is_dangerous(command): output_deny("Blocked by rule: matches dangerous pattern") return # 5. Check static SAFE patterns (anchored to start of each sub-command) parts = split_commands(command) if parts and all(is_safe(part) for part in parts): output_allow("Auto-approved: matches safe pattern") return # 6. Unknown — call AI ai_result = classify_with_ai(command, cwd) if ai_result is None: # AI unavailable — fall through to user prompt return score, reason, pattern = ai_result if score == "GREEN": # Save learned pattern and allow if pattern: save_learned_pattern("safe", pattern, command) output_allow(f"[AI-GREEN] {reason}") return if score == "RED": # Save learned pattern and deny if pattern: save_learned_pattern("dangerous", pattern, command) output_deny(f"[AI-RED] {reason}") return # YELLOW — fall through to Claude Code's native permission prompt return decide() HOOK_MAIN chmod +x "$HOOKS_DIR/permission-check.py" # ── Write learn.py ───────────────────────────────────── info "Writing learn.py helper..." cat > "$HOOKS_DIR/learn.py" << 'HOOK_LEARN' #!/usr/bin/env python3 """ CLI tool for managing learned permission patterns. Usage: python3 learn.py --safe [--command ] python3 learn.py --dangerous [--command ] python3 learn.py --allow-once python3 learn.py --remove python3 learn.py --list """ import argparse import json import os import subprocess import sys from datetime import date def get_repo_root(): try: return subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=5, ).stdout.strip() except Exception: return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) REPO_ROOT = get_repo_root() LEARNED_PATTERNS_PATH = os.path.join(REPO_ROOT, ".claude", "learned-patterns.json") ALLOW_ONCE_PATH = f"/tmp/.claude-allow-once-{os.getuid()}.json" def load_patterns(): try: with open(LEARNED_PATTERNS_PATH, "r") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {"safe": [], "dangerous": []} def save_patterns(data): os.makedirs(os.path.dirname(LEARNED_PATTERNS_PATH), exist_ok=True) with open(LEARNED_PATTERNS_PATH, "w") as f: json.dump(data, f, indent=2) def add_pattern(category, pattern, original_command=None): data = load_patterns() existing = [e["pattern"] for e in data.get(category, [])] if pattern in existing: print(f"Pattern already exists in {category}: {pattern}") return entry = { "pattern": pattern, "command": original_command or "", "added": date.today().isoformat(), } data.setdefault(category, []).append(entry) save_patterns(data) print(f"Added to {category}: {pattern}") def remove_pattern(pattern): data = load_patterns() found = False for category in ("safe", "dangerous"): before = len(data.get(category, [])) data[category] = [e for e in data.get(category, []) if e["pattern"] != pattern] if len(data[category]) < before: found = True print(f"Removed from {category}: {pattern}") if found: save_patterns(data) else: print(f"Pattern not found: {pattern}") sys.exit(1) def allow_once(command): try: with open(ALLOW_ONCE_PATH, "r") as f: allows = json.load(f) except (FileNotFoundError, json.JSONDecodeError): allows = [] if command not in allows: allows.append(command) with open(ALLOW_ONCE_PATH, "w") as f: json.dump(allows, f) print(f"One-time allow added for: {command}") def list_patterns(): data = load_patterns() print("=== Learned Safe Patterns ===") for entry in data.get("safe", []): cmd = entry.get("command", "") cmd_info = f" (from: {cmd})" if cmd else "" print(f" {entry['pattern']}{cmd_info} [{entry.get('added', '?')}]") if not data.get("safe"): print(" (none)") print() print("=== Learned Dangerous Patterns ===") for entry in data.get("dangerous", []): cmd = entry.get("command", "") cmd_info = f" (from: {cmd})" if cmd else "" print(f" {entry['pattern']}{cmd_info} [{entry.get('added', '?')}]") if not data.get("dangerous"): print(" (none)") # Show one-time allows if any try: with open(ALLOW_ONCE_PATH, "r") as f: allows = json.load(f) if allows: print() print("=== Pending One-Time Allows ===") for cmd in allows: print(f" {cmd}") except (FileNotFoundError, json.JSONDecodeError): pass def main(): parser = argparse.ArgumentParser(description="Manage learned permission patterns") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--safe", metavar="PATTERN", help="Add a safe pattern") group.add_argument("--dangerous", metavar="PATTERN", help="Add a dangerous pattern") group.add_argument("--allow-once", metavar="COMMAND", help="Add a one-time allow") group.add_argument("--remove", metavar="PATTERN", help="Remove a learned pattern") group.add_argument("--list", action="store_true", help="List all learned patterns") parser.add_argument( "--command", metavar="CMD", help="Original command (for reference)" ) args = parser.parse_args() if args.safe: add_pattern("safe", args.safe, args.command) elif args.dangerous: add_pattern("dangerous", args.dangerous, args.command) elif args.allow_once: allow_once(args.allow_once) elif args.remove: remove_pattern(args.remove) elif args.list: list_patterns() if __name__ == "__main__": main() HOOK_LEARN chmod +x "$HOOKS_DIR/learn.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, nslookup, dig, whois, nmap - Piping to shell: curl|bash, wget|sh (evaluate context) - 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 - 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 # ── Initialize learned-patterns.json ─────────────────── if [ ! -f "$LEARNED_FILE" ]; then info "Creating learned patterns file..." cat > "$LEARNED_FILE" << 'LEARNED' { "safe": [], "dangerous": [] } LEARNED else ok "Learned patterns file already exists (preserved)" fi # ── Update settings.json ─────────────────────────────── info "Updating $SETTINGS_FILE..." HOOKS_JSON='{ "PreToolUse": [ { "matcher": "Bash|Read", "hooks": [ { "type": "command", "command": "python3 \"$(git rev-parse --show-toplevel)/.claude/hooks/permission-check.py\"", "timeout": 15000 } ] } ] }' # Merge hooks into existing settings.json (or create new one) # Also removes old PermissionRequest hook if present 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", {}) # Remove old PermissionRequest hook (merged into PreToolUse) existing_hooks.pop("PermissionRequest", None) # 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/permission-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/permission-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 fall through to AI) result=$(echo '{"tool_name":"Bash","tool_input":{"command":"docker build ."}}' | python3 "$HOOKS_DIR/permission-check.py" 2>/dev/null) if echo "$result" | python3 -c " import sys,json try: d=json.load(sys.stdin) exit(0) except: exit(0) " 2>/dev/null; then ok "docker build . → falls through to AI or user" PASS=$((PASS + 1)) else err "docker build . → unexpected result" 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/permission-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/permission-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/permission-check.py" 2>/dev/null) if [ -z "$result" ] || echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']!='deny' or 'dangerous pattern' not in d['hookSpecificOutput']['permissionDecisionReason'] else 1)" 2>/dev/null; then ok "gh pr create --body \"...rm -rf /...\" → not falsely blocked" PASS=$((PASS + 1)) else err "gh pr create --body \"...rm -rf /...\" → falsely blocked" FAIL=$((FAIL + 1)) fi # Test 7: Read tool auto-approve result=$(echo '{"tool_name":"Read","tool_input":{"file_path":"/etc/passwd"}}' | python3 "$HOOKS_DIR/permission-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 "Read /etc/passwd → allow (Read always safe)" PASS=$((PASS + 1)) else err "Read /etc/passwd → expected allow" FAIL=$((FAIL + 1)) fi # Test 8: env prefix stripping result=$(echo '{"tool_name":"Bash","tool_input":{"command":"PYTHONPATH=. pytest tests/"}}' | python3 "$HOOKS_DIR/permission-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 "PYTHONPATH=. pytest tests/ → allow (env prefix stripped)" PASS=$((PASS + 1)) else err "PYTHONPATH=. pytest tests/ → expected allow" FAIL=$((FAIL + 1)) fi # Test 9: learn.py --safe + learned pattern check python3 "$HOOKS_DIR/learn.py" --safe "^test-pattern-xyz" --command "test-pattern-xyz" > /dev/null 2>&1 result=$(echo '{"tool_name":"Bash","tool_input":{"command":"test-pattern-xyz --flag"}}' | python3 "$HOOKS_DIR/permission-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='allow' and 'learned' in d['hookSpecificOutput']['permissionDecisionReason'] else 1)" 2>/dev/null; then ok "learned safe pattern → allow" PASS=$((PASS + 1)) else err "learned safe pattern → expected allow with 'learned' reason" FAIL=$((FAIL + 1)) fi python3 "$HOOKS_DIR/learn.py" --remove "^test-pattern-xyz" > /dev/null 2>&1 # Test 10: learn.py --allow-once + one-time allow check python3 "$HOOKS_DIR/learn.py" --allow-once "one-time-test-cmd" > /dev/null 2>&1 result=$(echo '{"tool_name":"Bash","tool_input":{"command":"one-time-test-cmd"}}' | python3 "$HOOKS_DIR/permission-check.py") if echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d['hookSpecificOutput']['permissionDecision']=='allow' and 'One-time' in d['hookSpecificOutput']['permissionDecisionReason'] else 1)" 2>/dev/null; then ok "one-time allow → allow (consumed)" PASS=$((PASS + 1)) else err "one-time allow → expected allow with 'One-time' reason" FAIL=$((FAIL + 1)) fi # Verify consumed result2=$(echo '{"tool_name":"Bash","tool_input":{"command":"one-time-test-cmd"}}' | python3 "$HOOKS_DIR/permission-check.py" 2>/dev/null) if [ -z "$result2" ] || echo "$result2" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if 'One-time' not in d['hookSpecificOutput'].get('permissionDecisionReason','') else 1)" 2>/dev/null; then ok "one-time allow consumed → not auto-allowed again" PASS=$((PASS + 1)) else err "one-time allow → should have been consumed" 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/permission-check.py — unified hook (rules + AI + memory)\n" printf " $HOOKS_DIR/learn.py — manage learned patterns\n" printf " $LEARNED_FILE — persisted learned patterns\n" printf " $POLICY_FILE — editable policy\n" printf " $SETTINGS_FILE — hooks config\n" printf "\n" printf " ${BOLD}How it works:${NC}\n" printf " Command → permission-check.py\n" printf " ├─ One-time allow? → allow (consumed) ${GREEN}✓${NC}\n" printf " ├─ Learned SAFE? → auto-approve ${GREEN}✓${NC}\n" printf " ├─ Learned DANGER? → auto-block ${RED}✗${NC}\n" printf " ├─ Static SAFE? → auto-approve ${GREEN}✓${NC}\n" printf " ├─ Static DANGER? → auto-block ${RED}✗${NC}\n" printf " └─ Unknown → AI (Sonnet, ~5-10s)\n" printf " ├─ GREEN → allow + remember ${GREEN}✓${NC}\n" printf " ├─ RED → block + remember ${RED}✗${NC}\n" printf " └─ YELLOW → native permission prompt ${YELLOW}?${NC}\n" printf "\n" printf " ${BOLD}Manage patterns:${NC}\n" printf " python3 $HOOKS_DIR/learn.py --list\n" printf " python3 $HOOKS_DIR/learn.py --remove \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"