Created
January 19, 2026 15:21
-
-
Save mrocklin/729ca37c4c278f647e9b62580b4afaf8 to your computer and use it in GitHub Desktop.
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 python3 | |
| """ | |
| Claude Code PreToolUse Hook: Compositional Bash Command Approval | |
| PROBLEM | |
| ------- | |
| Claude Code's static permission system uses prefix matching: | |
| "Bash(git diff:*)" matches "git diff --staged" but NOT "git -C /path diff" | |
| "Bash(timeout 30 pytest:*)" matches that exact timeout, not "timeout 20 pytest" | |
| This leads to frequent permission prompts for safe command variations. | |
| SOLUTION: COMPOSITIONAL APPROVAL | |
| -------------------------------- | |
| Instead of listing every command variant, we decompose commands into: | |
| WRAPPERS + CORE COMMAND | |
| Wrappers are prefixes that modify HOW a command runs (timeout, env vars, etc.) | |
| Core commands are the actual executables (git, pytest, cargo, etc.) | |
| Example: "timeout 60 RUST_BACKTRACE=1 cargo test" is approved as: | |
| wrapper(timeout) + wrapper(env vars) + safe_command(cargo) | |
| Example: "PYTHONPATH=. .venv/bin/pytest tests/ | head" is approved as: | |
| wrapper(env vars) + wrapper(.venv) + safe_command(pytest) | safe_command(head) | |
| ALGORITHM | |
| --------- | |
| 1. Reject commands with $(...) or backticks (command substitution is too risky) | |
| 2. Split command on &&, ||, ;, |, & into segments | |
| 3. For each segment: | |
| a. Strip wrapper prefixes iteratively (timeout, env vars, .venv/bin/, etc.) | |
| b. Check if remaining core command matches a safe pattern | |
| 4. If ALL segments are safe, approve with combined reason; otherwise reject | |
| CHAINED COMMANDS | |
| ---------------- | |
| Commands are split on shell operators and ALL segments must be safe: | |
| "ls && pwd" -> approved (both safe) | |
| "ls && rm -rf /" -> rejected (rm not safe) | |
| "git diff | head" -> approved (both safe) | |
| SECURITY: WHAT WE REJECT | |
| ------------------------ | |
| - Command substitution: $(...) and backticks - could hide anything | |
| - Unlisted commands: rm, mv, chmod, chown, etc. - require explicit permission | |
| - Heredocs: cat > file << 'EOF' - file writes need permission | |
| - Redirections that write: > and >> are in the command, not stripped | |
| CONFIGURATION | |
| ------------- | |
| Registered in ~/.claude/settings.json: | |
| "hooks": { | |
| "PreToolUse": [{ | |
| "matcher": "Bash", | |
| "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/approve-variants.py"}] | |
| }] | |
| } | |
| EXTENDING | |
| --------- | |
| To add new safe wrappers: Add (regex, name) to WRAPPER_PATTERNS | |
| - Wrappers MODIFY how a command runs but don't change WHAT it does | |
| - Pattern must match prefix INCLUDING trailing whitespace/separator | |
| - Examples: timeout, env vars, nice, .venv/bin/ | |
| To add new safe commands: Add (regex, name) to SAFE_COMMANDS | |
| - Commands are the actual executable being run | |
| - Pattern matches after all wrappers are stripped | |
| - Use \b for word boundaries to avoid partial matches | |
| DEBUG / TEST | |
| ------------ | |
| Test a command manually: | |
| echo '{"tool_name": "Bash", "tool_input": {"command": "YOUR_CMD"}}' | python3 ~/.claude/hooks/approve-variants.py | |
| If approved: outputs JSON with permissionDecision: "allow" | |
| If rejected: no output (falls through to normal permission flow) | |
| """ | |
| import json | |
| import sys | |
| import re | |
| # ============================================================================= | |
| # HOOK ENTRY POINT | |
| # ============================================================================= | |
| try: | |
| data = json.load(sys.stdin) | |
| except Exception: | |
| sys.exit(0) | |
| tool_name = data.get("tool_name") | |
| tool_input = data.get("tool_input", {}) | |
| if tool_name != "Bash": | |
| sys.exit(0) | |
| def approve(reason): | |
| """Output approval JSON and exit.""" | |
| print(json.dumps({ | |
| "hookSpecificOutput": { | |
| "hookEventName": "PreToolUse", | |
| "permissionDecision": "allow", | |
| "permissionDecisionReason": reason | |
| } | |
| })) | |
| sys.exit(0) | |
| cmd = tool_input.get("command", "") | |
| # ============================================================================= | |
| # SECURITY: Reject command substitution (too dangerous to parse) | |
| # ============================================================================= | |
| if re.search(r"\$\(|`", cmd): | |
| sys.exit(0) | |
| # ============================================================================= | |
| # COMMAND SPLITTING | |
| # ============================================================================= | |
| def split_command_chain(cmd): | |
| """Split command into segments on &&, ||, ;, |, &. | |
| Handles: | |
| - Backslash continuations: cmd \\\n --flag -> cmd --flag | |
| - Quoted strings: grep "a|b" doesn't split on | | |
| - Redirections: 2>&1 doesn't split on & | |
| - Newlines: only split on \n if no quotes (to handle multiline strings) | |
| """ | |
| # Collapse backslash-newline continuations | |
| cmd = re.sub(r"\\\n\s*", " ", cmd) | |
| # Protect quoted strings from splitting (replace with placeholders) | |
| quoted_strings = [] | |
| def save_quoted(m): | |
| quoted_strings.append(m.group(0)) | |
| return f"__QUOTED_{len(quoted_strings)-1}__" | |
| cmd = re.sub(r'"[^"]*"', save_quoted, cmd) | |
| cmd = re.sub(r"'[^']*'", save_quoted, cmd) | |
| # Protect redirections: 2>&1, &> (contain & but aren't command separators) | |
| cmd = re.sub(r"(\d*)>&(\d*)", r"__REDIR_\1_\2__", cmd) | |
| cmd = re.sub(r"&>", "__REDIR_AMPGT__", cmd) | |
| # Split on command separators | |
| # With quotes: don't split on newlines (multiline python -c "..." etc.) | |
| # Without quotes: also split on newlines | |
| if quoted_strings: | |
| segments = re.split(r"\s*(?:&&|\|\||;|\||&)\s*", cmd) | |
| else: | |
| segments = re.split(r"\s*(?:&&|\|\||;|\||&)\s*|\n", cmd) | |
| # Restore protected content | |
| def restore(s): | |
| s = re.sub(r"__REDIR_(\d*)_(\d*)__", r"\1>&\2", s) | |
| s = s.replace("__REDIR_AMPGT__", "&>") | |
| for i, qs in enumerate(quoted_strings): | |
| s = s.replace(f"__QUOTED_{i}__", qs) | |
| return s | |
| return [restore(s).strip() for s in segments if s.strip()] | |
| # ============================================================================= | |
| # WRAPPER PATTERNS | |
| # These modify HOW a command runs, stripped before checking core command | |
| # ============================================================================= | |
| WRAPPER_PATTERNS = [ | |
| # Execution modifiers | |
| (r"^timeout\s+\d+\s+", "timeout"), | |
| (r"^time\s+", "time"), | |
| (r"^nice\s+(-n\s*\d+\s+)?", "nice"), | |
| (r"^env\s+", "env"), | |
| # Environment variables: VAR=value VAR2=value2 command | |
| (r"^([A-Z_][A-Z0-9_]*=[^\s]*\s+)+", "env vars"), | |
| # Virtual environment paths (matches .venv/bin/, venv/bin/, ../.venv/bin/, /abs/.venv/bin/) | |
| (r"^(\.\./)*\.?venv/bin/", ".venv"), | |
| (r"^/[^\s]+/\.?venv/bin/", ".venv"), | |
| # Shell control flow prefixes (when combined with commands on same segment) | |
| (r"^do\s+", "do"), # for ...; do CMD | |
| (r"^then\s+", "then"), # if ...; then CMD | |
| (r"^else\s+", "else"), # else CMD | |
| # Negation and comments | |
| (r"^!\s*", "!"), # if ! CMD | |
| (r"^#[^\n]*\n\s*", "comment"), # # comment\nCMD | |
| ] | |
| # ============================================================================= | |
| # SAFE COMMAND PATTERNS | |
| # The actual executables, checked after wrappers are stripped | |
| # ============================================================================= | |
| SAFE_COMMANDS = [ | |
| # --- Version Control --- | |
| (r"^git\s+(-C\s+\S+\s+)?(diff|log|status|show|branch|stash\s+list|bisect|worktree\s+list|fetch|ls-files)\b", | |
| "git read op"), | |
| (r"^git\s+(-C\s+\S+\s+)?(add|checkout|merge|rebase|stash)\b", | |
| "git write op"), | |
| # --- Python Ecosystem --- | |
| (r"^python[23]?\b", "python"), | |
| (r"^pytest\b", "pytest"), | |
| (r"^ruff\b", "ruff"), | |
| (r"^pip\s+(search|show|list|freeze|check)\b", "pip read-only"), | |
| (r"^uv\s+(pip|run|sync|venv|add|remove|lock)\b", "uv"), | |
| (r"^uvx\b", "uvx"), | |
| # --- JavaScript/Node --- | |
| (r"^npm\s+(install|run|test|build|ci)\b", "npm"), | |
| (r"^npx\b", "npx"), | |
| # --- Rust --- | |
| (r"^cargo\s+(\+\S+\s+)?(build|test|run|check|clippy|fmt|clean|search|add)\b", "cargo"), | |
| (r"^rustup\b", "rustup"), | |
| (r"^maturin\s+(develop|build)\b", "maturin"), | |
| # --- Build Tools --- | |
| (r"^make\b", "make"), | |
| # --- Unix Utilities (read-only / pipeline) --- | |
| (r"^(ls|cat|head|tail|wc|find|grep|rg|file|which|pwd|du|df|curl|sort|uniq|cut|tr|awk|sed|xargs|tee|open|strings|bat)\b", | |
| "read-only"), | |
| # --- File/Directory Operations (low-risk) --- | |
| (r"^touch\b", "touch"), | |
| (r"^mkdir\b", "mkdir"), | |
| # --- Process Management --- | |
| (r"^(pkill|kill)\b", "process mgmt"), | |
| (r"^(true|false|exit(\s+\d+)?)$", "shell builtin"), | |
| # --- Shell Constructs --- | |
| (r"^echo\b", "echo"), | |
| (r"^cd\s", "cd"), | |
| (r"^sleep\s", "sleep"), | |
| (r"^(source|\.) [^\s]*venv/bin/activate", "venv activate"), | |
| (r"^[A-Z_][A-Z0-9_]*=\S*$", "var assignment"), | |
| # --- Control Flow (standalone keywords) --- | |
| (r"^for\s+\w+\s+in\s", "for loop"), | |
| (r"^while\s", "while loop"), | |
| (r"^if\s", "if"), | |
| (r"^elif\s", "elif"), | |
| (r"^do$", "do"), | |
| (r"^done$", "done"), | |
| (r"^fi$", "fi"), | |
| # --- Project-Specific (add your own CLIs here) --- | |
| (r"^frisky\b", "frisky"), | |
| (r"^bd\b", "bd"), | |
| ] | |
| # ============================================================================= | |
| # CORE LOGIC | |
| # ============================================================================= | |
| def strip_wrappers(cmd): | |
| """Strip wrapper prefixes iteratively, return (core_cmd, wrapper_names).""" | |
| wrappers = [] | |
| changed = True | |
| while changed: | |
| changed = False | |
| for pattern, name in WRAPPER_PATTERNS: | |
| m = re.match(pattern, cmd) | |
| if m: | |
| wrappers.append(name) | |
| cmd = cmd[m.end():] | |
| changed = True | |
| break | |
| return cmd.strip(), wrappers | |
| def check_safe(cmd): | |
| """Check if command matches a safe pattern. Returns reason or None.""" | |
| for pattern, reason in SAFE_COMMANDS: | |
| if re.match(pattern, cmd): | |
| return reason | |
| return None | |
| # --- Process all segments --- | |
| segments = split_command_chain(cmd) | |
| reasons = [] | |
| for segment in segments: | |
| core_cmd, wrappers = strip_wrappers(segment) | |
| reason = check_safe(core_cmd) | |
| if not reason: | |
| # One unsafe segment = reject entire command | |
| sys.exit(0) | |
| if wrappers: | |
| reasons.append(f"{'+'.join(wrappers)} + {reason}") | |
| else: | |
| reasons.append(reason) | |
| # All segments safe - approve with combined reason | |
| approve(" | ".join(reasons)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment