Add smart-approve hook (#122)

Adds a PreToolUse hook that decomposes compound bash commands
(&&, ||, ;, |, $(), newlines) into individual sub-commands and
checks each against allow/deny patterns in Claude Code settings.

Source: https://github.com/liberzon/claude-hooks
This commit is contained in:
Yair Liberzon
2026-03-27 16:24:04 +03:00
committed by GitHub
parent b6544738e9
commit 0440252f2e
3 changed files with 620 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
# Claude Code Toolkit # Claude Code Toolkit
**The most comprehensive toolkit for Claude Code -- 135 agents, 35 curated skills (+400,000 via [SkillKit](https://agenstskills.com)), 42 commands, 150+ plugins, 19 hooks, 15 rules, 7 templates, 8 MCP configs, and more.** **The most comprehensive toolkit for Claude Code -- 135 agents, 35 curated skills (+400,000 via [SkillKit](https://agenstskills.com)), 42 commands, 150+ plugins, 20 hooks, 15 rules, 7 templates, 8 MCP configs, and more.**
[![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/sindresorhus/awesome) [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/sindresorhus/awesome)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
@@ -38,7 +38,7 @@ curl -fsSL https://raw.githubusercontent.com/rohitg00/awesome-claude-code-toolki
- [Agents](#agents) (135) - [Agents](#agents) (135)
- [Skills](#skills) (35 curated + community) - [Skills](#skills) (35 curated + community)
- [Commands](#commands) (42) - [Commands](#commands) (42)
- [Hooks](#hooks) (19 scripts) - [Hooks](#hooks) (20 scripts)
- [Rules](#rules) (15) - [Rules](#rules) (15)
- [Templates](#templates) (7) - [Templates](#templates) (7)
- [MCP Configs](#mcp-configs) (8) - [MCP Configs](#mcp-configs) (8)
@@ -616,7 +616,7 @@ Then invoke in Claude Code:
## Hooks ## Hooks
Nineteen hook scripts covering all eight Claude Code lifecycle events. Place `hooks.json` in your `.claude/` directory. Twenty hook scripts covering all eight Claude Code lifecycle events. Place `hooks.json` in your `.claude/` directory.
### Hook Scripts ### Hook Scripts
@@ -627,6 +627,7 @@ Nineteen hook scripts covering all eight Claude Code lifecycle events. Place `ho
| `context-loader.js` | SessionStart | Load CLAUDE.md, git status, pending todos | | `context-loader.js` | SessionStart | Load CLAUDE.md, git status, pending todos |
| `learning-log.js` | SessionEnd | Extract and save session learnings | | `learning-log.js` | SessionEnd | Extract and save session learnings |
| `pre-compact.js` | PreCompact | Save important context before compaction | | `pre-compact.js` | PreCompact | Save important context before compaction |
| [`smart-approve.py`](https://github.com/liberzon/claude-hooks) | PreToolUse (Bash) | Decompose compound bash commands (&&, \|\|, ;, \|, $()) into sub-commands and check each against allow/deny patterns |
| `block-dev-server.js` | PreToolUse (Bash) | Block dev server commands outside tmux | | `block-dev-server.js` | PreToolUse (Bash) | Block dev server commands outside tmux |
| `pre-push-check.js` | PreToolUse (Bash) | Verify branch and remote before push | | `pre-push-check.js` | PreToolUse (Bash) | Verify branch and remote before push |
| `block-md-creation.js` | PreToolUse (Write) | Block unnecessary .md file creation | | `block-md-creation.js` | PreToolUse (Write) | Block unnecessary .md file creation |
@@ -770,7 +771,7 @@ claude-code-toolkit/ 800+ files
skills/ 35 SKILL.md files skills/ 35 SKILL.md files
commands/ 42 commands across 8 categories commands/ 42 commands across 8 categories
hooks/ hooks/
hooks.json 24 hook entries hooks.json 25 hook entries
scripts/ 19 Node.js scripts scripts/ 19 Node.js scripts
rules/ 15 coding rules rules/ 15 coding rules
templates/claude-md/ 7 CLAUDE.md templates templates/claude-md/ 7 CLAUDE.md templates

View File

@@ -1,5 +1,11 @@
{ {
"hooks": [ "hooks": [
{
"type": "PreToolUse",
"matcher": "Bash",
"description": "Decompose compound bash commands and check each sub-command against allow/deny patterns",
"command": "python3 hooks/scripts/smart-approve.py"
},
{ {
"type": "PreToolUse", "type": "PreToolUse",
"matcher": "Bash", "matcher": "Bash",

View File

@@ -0,0 +1,609 @@
#!/usr/bin/env python3
"""Smart PreToolUse hook for Claude Code.
Decomposes compound bash commands (&&, ||, ;, |, $(), newlines) into
individual sub-commands and checks each against the allow/deny patterns
in ~/.claude/settings.json.
Source: https://github.com/liberzon/claude-hooks
Author: Yair Liberzon
License: MIT
Input: JSON on stdin with tool_name and tool_input.command
Output: JSON with {"decision": "allow"/"deny", "reason": "..."} or silent exit
"""
import fnmatch
import json
import os
import re
import sys
def load_settings(path=None):
"""Load and return the permissions dict from settings.json."""
if path is None:
path = os.path.expanduser("~/.claude/settings.json")
path = os.path.expanduser(path)
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def load_merged_settings(global_path=None):
"""Load and merge all settings layers matching Claude Code's behavior.
Loads up to three sources and merges their permissions.allow/deny arrays:
1. Global: ~/.claude/settings.json (or $CLAUDE_SETTINGS_PATH)
2. Project: $CLAUDE_PROJECT_DIR/.claude/settings.json (committed)
3. Project-local: $CLAUDE_PROJECT_DIR/.claude/settings.local.json (gitignored)
"""
settings = load_settings(global_path)
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
if not project_dir:
return settings
# Load both project settings files
project_shared = load_settings(
os.path.join(project_dir, ".claude", "settings.json")
)
project_local = load_settings(
os.path.join(project_dir, ".claude", "settings.local.json")
)
if not project_shared and not project_local:
return settings
# Merge permissions arrays from all layers (deduplicated, order-preserving)
global_perms = settings.get("permissions", {})
shared_perms = project_shared.get("permissions", {})
local_perms = project_local.get("permissions", {})
merged_allow = list(dict.fromkeys(
global_perms.get("allow", [])
+ shared_perms.get("allow", [])
+ local_perms.get("allow", [])
))
merged_deny = list(dict.fromkeys(
global_perms.get("deny", [])
+ shared_perms.get("deny", [])
+ local_perms.get("deny", [])
))
settings.setdefault("permissions", {})
settings["permissions"]["allow"] = merged_allow
settings["permissions"]["deny"] = merged_deny
return settings
def parse_bash_patterns(patterns):
"""Extract command prefixes from Bash(...) permission patterns.
"Bash(git status:*)" -> "git status"
"Bash(rm:*)" -> "rm"
Non-Bash patterns are skipped.
Returns a list of (prefix_string, glob_pattern) tuples.
The glob_pattern is what fnmatch should match against.
"""
result = []
for pat in patterns:
m = re.match(r'^Bash\((.+)\)$', pat)
if not m:
continue
inner = m.group(1)
# inner is like "git status:*" or "rm:*" or "/Users/yair/...adb*"
# Split on first ':'
colon_idx = inner.find(':')
if colon_idx == -1:
# Pattern like "Bash(something)" with no colon — treat as exact prefix
result.append((inner, inner))
else:
prefix = inner[:colon_idx]
suffix = inner[colon_idx + 1:]
# The glob pattern is prefix + ' ' + suffix (for matching with args)
# But we also want bare prefix to match (no args)
glob_pat = prefix + ' ' + suffix if suffix else prefix
result.append((prefix, glob_pat))
return result
def command_matches_pattern(cmd, patterns):
"""Check if a command matches any of the parsed Bash patterns.
Each pattern is (prefix, glob_pattern).
A command matches if:
- It equals the prefix exactly (bare command, no args), OR
- fnmatch(cmd, glob_pattern) is True
"""
for prefix, glob_pat in patterns:
if cmd == prefix:
return True
if fnmatch.fnmatch(cmd, glob_pat):
return True
return False
def extract_subshells(command):
"""Extract contents of $() and backtick subshells, recursively.
Returns a list of subshell content strings.
"""
subshells = []
# Extract $(...) — handle nested parens, but skip $((...)) arithmetic
i = 0
while i < len(command):
if command[i] == '$' and i + 1 < len(command) and command[i + 1] == '(' \
and not (i + 2 < len(command) and command[i + 2] == '('):
# Find matching closing paren
depth = 0
start = i + 2
j = i + 1
while j < len(command):
if command[j] == '(':
depth += 1
elif command[j] == ')':
depth -= 1
if depth == 0:
content = command[start:j]
subshells.append(content)
# Recurse into content for nested subshells
subshells.extend(extract_subshells(content))
break
j += 1
i = j + 1
else:
i += 1
# Extract backtick subshells (no nesting)
parts = command.split('`')
# Odd-indexed parts are inside backticks
for idx in range(1, len(parts), 2):
content = parts[idx]
if content.strip():
subshells.append(content)
subshells.extend(extract_subshells(content))
return subshells
def strip_heredocs(command):
"""Strip heredoc bodies from a command, leaving just the <<DELIM marker.
Heredocs like <<'EOF'\\n...\\nEOF are replaced with the marker only
(body removed). This prevents heredoc content lines from being treated
as sub-commands when we split on newlines.
"""
lines = command.split('\n')
result = []
heredoc_delim = None
i = 0
while i < len(lines):
if heredoc_delim is not None:
# Inside heredoc body — look for the terminator line
if lines[i].strip() == heredoc_delim:
heredoc_delim = None
i += 1
continue
# Check for heredoc marker: <<[-]?['"]?WORD['"]?
m = re.search(r'<<-?\s*[\'"]?(\w+)[\'"]?', lines[i])
if m:
heredoc_delim = m.group(1)
result.append(lines[i])
i += 1
return '\n'.join(result)
def split_on_operators(command):
"""Split a command string on &&, ||, ;, |, and newlines.
Respects quoted strings and $() subshells (doesn't split inside them).
Returns the top-level command segments.
"""
# Strip heredoc bodies so their lines aren't treated as commands
command = strip_heredocs(command)
# Collapse backslash-newline continuations before parsing
command = command.replace('\\\n', ' ')
segments = []
current = []
i = 0
in_single_quote = False
in_double_quote = False
paren_depth = 0
while i < len(command):
ch = command[i]
# Handle backslash escaping (not inside single quotes, where \ is literal)
if ch == '\\' and not in_single_quote and i + 1 < len(command):
current.append(ch)
current.append(command[i + 1])
i += 2
continue
# Track quoting
if ch == "'" and not in_double_quote and paren_depth == 0:
in_single_quote = not in_single_quote
current.append(ch)
i += 1
continue
if ch == '"' and not in_single_quote and paren_depth == 0:
in_double_quote = not in_double_quote
current.append(ch)
i += 1
continue
if in_single_quote or in_double_quote:
current.append(ch)
i += 1
continue
# Track $() subshell depth — consume $( as a single token
if ch == '$' and i + 1 < len(command) and command[i + 1] == '(':
paren_depth += 1
current.append('$')
current.append('(')
i += 2
continue
if ch == '(' and paren_depth > 0:
paren_depth += 1
current.append(ch)
i += 1
continue
if ch == ')' and paren_depth > 0:
paren_depth -= 1
current.append(ch)
i += 1
continue
if paren_depth > 0:
current.append(ch)
i += 1
continue
# Split on operators at top level
if ch == '&' and i + 1 < len(command) and command[i + 1] == '&':
segments.append(''.join(current))
current = []
i += 2
continue
if ch == '|' and i + 1 < len(command) and command[i + 1] == '|':
segments.append(''.join(current))
current = []
i += 2
continue
if ch == ';':
segments.append(''.join(current))
current = []
i += 1
continue
if ch == '|':
segments.append(''.join(current))
current = []
i += 1
continue
if ch == '\n':
segments.append(''.join(current))
current = []
i += 1
continue
current.append(ch)
i += 1
segments.append(''.join(current))
return [s.strip() for s in segments if s.strip()]
def _skip_shell_value(cmd, i):
"""Skip past one shell 'word' value starting at position i.
Handles quoted strings, $() subshells (tracking paren depth), and
bare non-whitespace runs. Returns the index just past the value.
"""
if i >= len(cmd):
return i
# Quoted value
if cmd[i] == '"':
i += 1
while i < len(cmd) and cmd[i] != '"':
if cmd[i] == '\\' and i + 1 < len(cmd):
i += 2
else:
i += 1
if i < len(cmd):
i += 1 # skip closing quote
return i
if cmd[i] == "'":
i += 1
while i < len(cmd) and cmd[i] != "'":
i += 1
if i < len(cmd):
i += 1 # skip closing quote
return i
# Unquoted value — consume non-whitespace, tracking $() depth
paren_depth = 0
while i < len(cmd):
ch = cmd[i]
if ch == '$' and i + 1 < len(cmd) and cmd[i + 1] == '(':
paren_depth += 1
i += 2
continue
if ch == '(' and paren_depth > 0:
paren_depth += 1
i += 1
continue
if ch == ')' and paren_depth > 0:
paren_depth -= 1
i += 1
continue
if paren_depth > 0:
i += 1
continue
if ch in (' ', '\t'):
break
i += 1
return i
def strip_env_vars(cmd):
"""Strip leading environment variable assignments (FOO=bar cmd ...).
Returns the command with env var prefixes removed.
Correctly handles values containing $() subshells.
"""
while True:
m = re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', cmd)
if not m:
break
# Find end of value (respecting quotes and $() depth)
i = _skip_shell_value(cmd, m.end())
# If nothing follows the assignment, this IS the command — keep it
rest = cmd[i:].lstrip()
if not rest:
break
cmd = rest
return cmd
def strip_redirections(cmd):
"""Strip output/input redirections from a command.
Removes patterns like >file, >>file, 2>&1, <file, etc.
"""
# Remove redirections: N>file, N>>file, N>&N, <file, <<word, <<<word
cmd = re.sub(r'\d*>>?\s*&?\d*\S*', '', cmd)
cmd = re.sub(r'<<<?\s*\S+', '', cmd)
# Simple input redirection: < file
cmd = re.sub(r'<\s*\S+', '', cmd)
return cmd.strip()
# Shell keywords that are structural, not commands to approve/deny.
# These appear as segments after splitting on ;/newlines in for/while/if blocks.
SHELL_KEYWORDS = frozenset({
'do', 'done', 'then', 'else', 'elif', 'fi', 'esac', '{', '}',
'break', 'continue',
})
# Keywords that can prefix a command when joined by ; (e.g. "do echo hello").
# These should be stripped to expose the real command underneath.
_KEYWORD_PREFIX_RE = re.compile(
r'^(do|then|else|elif)\s+'
)
# Patterns for shell compound statement headers (for, while, until, if, case).
# These introduce control flow but aren't executable commands themselves.
_COMPOUND_HEADER_RE = re.compile(
r'^(for|while|until|if|case|select)\b'
)
def strip_keyword_prefix(cmd):
"""Strip leading shell keyword prefix from a command.
"do echo hello" -> "echo hello"
"then git status" -> "git status"
"""
m = _KEYWORD_PREFIX_RE.match(cmd)
if m:
return cmd[m.end():]
return cmd
def is_shell_structural(cmd):
"""Return True if cmd is a shell keyword or compound-statement header."""
if cmd in SHELL_KEYWORDS:
return True
if _COMPOUND_HEADER_RE.match(cmd):
return True
return False
def is_standalone_assignment(cmd):
"""Return True if cmd is purely a variable assignment (no following command).
e.g. "result=$(curl ...)" or "FOO=bar" — these are not commands to check.
The subshell contents, if any, are extracted and checked separately.
"""
m = re.match(r'^[A-Za-z_][A-Za-z0-9_]*=', cmd)
if not m:
return False
# Check if the entire string is consumed by the assignment value
end = _skip_shell_value(cmd, m.end())
rest = cmd[end:].strip()
return rest == ''
def normalize_command(cmd):
"""Normalize a command by stripping env vars, redirections, and whitespace."""
cmd = cmd.strip()
if not cmd:
return cmd
cmd = strip_keyword_prefix(cmd)
cmd = strip_env_vars(cmd)
cmd = strip_redirections(cmd)
# Collapse multiple spaces
cmd = re.sub(r'\s+', ' ', cmd)
return cmd.strip()
def decompose_command(command):
"""Decompose a compound command into all individual sub-commands.
Splits on operators, extracts subshell contents, normalizes each.
Filters out shell structural keywords (for/do/done/etc.) and
standalone variable assignments (whose subshell contents are checked
separately).
Returns a list of normalized command strings.
"""
all_commands = []
# Get top-level segments
segments = split_on_operators(command)
for seg in segments:
# Also decompose any subshells found in this segment
subshells = extract_subshells(seg)
for sub in subshells:
sub_segments = split_on_operators(sub)
for ss in sub_segments:
normalized = normalize_command(ss)
if normalized:
all_commands.append(normalized)
# Normalize the top-level segment itself
normalized = normalize_command(seg)
if normalized:
all_commands.append(normalized)
# Filter out shell structural keywords and standalone assignments
return [
cmd for cmd in all_commands
if not is_shell_structural(cmd) and not is_standalone_assignment(cmd)
]
def decide(command, settings):
"""Make a permission decision for a compound command.
Returns:
("allow", reason) if all sub-commands match allow patterns
("deny", reason) if any sub-command matches a deny pattern
(None, None) if we should fall through to normal prompting
"""
if not command or not command.strip():
return None, None
permissions = settings.get("permissions", {})
allow_patterns = parse_bash_patterns(permissions.get("allow", []))
deny_patterns = parse_bash_patterns(permissions.get("deny", []))
sub_commands = decompose_command(command)
if not sub_commands:
return None, None
# Check deny first
for cmd in sub_commands:
if command_matches_pattern(cmd, deny_patterns):
return "deny", f"Sub-command '{cmd}' matches deny pattern"
# Check if ALL match allow
all_allowed = True
for cmd in sub_commands:
if not command_matches_pattern(cmd, allow_patterns):
all_allowed = False
break
if all_allowed:
return "allow", "All sub-commands match allow patterns"
return None, None
_log_lines = []
def _verbose_enabled():
"""Check if verbose logging is enabled.
Controlled by SMART_APPROVE_VERBOSE env var:
"1", "true", "yes" → enabled
"0", "false", "no", unset → disabled
"""
return os.environ.get("SMART_APPROVE_VERBOSE", "").lower() in ("1", "true", "yes")
def log(msg):
"""Collect verbose log line when enabled."""
if _verbose_enabled():
_log_lines.append(msg)
def _build_reason(reason):
"""Build the permissionDecisionReason, appending verbose logs if any."""
if not _log_lines:
return reason
verbose = " | ".join(_log_lines)
if reason:
return f"{reason} | {verbose}"
return verbose
def main():
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
log("no valid JSON on stdin, skipping")
sys.exit(0)
tool_name = input_data.get("tool_name", "")
if tool_name != "Bash":
log(f"tool={tool_name}, not Bash — skipping")
sys.exit(0)
command = input_data.get("tool_input", {}).get("command", "")
if not command:
log("empty command, skipping")
sys.exit(0)
cmd_preview = command[:80].replace('\n', '\\n')
log(f"checking: {cmd_preview}{'...' if len(command) > 80 else ''}")
settings_path = os.environ.get("CLAUDE_SETTINGS_PATH")
settings = load_merged_settings(settings_path)
sub_commands = decompose_command(command)
log(f"sub-commands: {sub_commands[:5]}{'...' if len(sub_commands) > 5 else ''}")
decision, reason = decide(command, settings)
log(f"decision={decision or 'passthrough'} reason={reason or 'no pattern matched'}")
if decision is not None:
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": _build_reason(reason),
}
}
json.dump(output, sys.stdout)
sys.stdout.write("\n")
# else: silent exit — fall through to normal prompting
if __name__ == "__main__":
main()