Skip to content

Instantly share code, notes, and snippets.

@mrocklin
Created January 19, 2026 15:21
Show Gist options
  • Select an option

  • Save mrocklin/729ca37c4c278f647e9b62580b4afaf8 to your computer and use it in GitHub Desktop.

Select an option

Save mrocklin/729ca37c4c278f647e9b62580b4afaf8 to your computer and use it in GitHub Desktop.
#!/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