Last active
February 21, 2026 15:21
-
-
Save BenderV/3c2e13f5e3c133b52139ab22d08f797b to your computer and use it in GitHub Desktop.
Claude Code AI Permission Hooks — Installer (curl | bash)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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 <pattern> [--command <original_cmd>] | |
| python3 learn.py --dangerous <pattern> [--command <original_cmd>] | |
| python3 learn.py --allow-once <command> | |
| python3 learn.py --remove <pattern> | |
| 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 <pattern>\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" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment