Skip to content

Instantly share code, notes, and snippets.

@BenderV
Last active February 21, 2026 15:21
Show Gist options
  • Select an option

  • Save BenderV/3c2e13f5e3c133b52139ab22d08f797b to your computer and use it in GitHub Desktop.

Select an option

Save BenderV/3c2e13f5e3c133b52139ab22d08f797b to your computer and use it in GitHub Desktop.
Claude Code AI Permission Hooks — Installer (curl | bash)
#!/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