Skip to content

Instantly share code, notes, and snippets.

@jeremynsl
Last active August 28, 2025 01:03
Show Gist options
  • Select an option

  • Save jeremynsl/255cdf5aea21ef2fd36acd2134c0b7ff to your computer and use it in GitHub Desktop.

Select an option

Save jeremynsl/255cdf5aea21ef2fd36acd2134c0b7ff to your computer and use it in GitHub Desktop.
Claude Code Hooks
#!/usr/bin/env python3
"""
Claude Code Audit Logger
Tracks all tool usage for compliance and debugging
"""
import json
import os
import sys
from datetime import datetime
from pathlib import Path
def get_audit_log_path():
"""Get the audit log file path."""
claude_dir = Path.home() / '.claude'
claude_dir.mkdir(exist_ok=True)
return claude_dir / 'audit.log'
def extract_command_info(hook_data_str):
"""Extract relevant command information from PostToolUse hook data."""
try:
hook_data = json.loads(hook_data_str)
# Build comprehensive audit record
info = {
'session_id': hook_data.get('session_id', 'unknown'),
'tool_name': hook_data.get('tool_name', 'Unknown'),
'hook_event': hook_data.get('hook_event_name', 'PostToolUse'),
'timestamp': datetime.now().isoformat(),
'user': os.environ.get('USER', 'unknown'),
'working_dir': hook_data.get('cwd', os.getcwd()),
'project': os.path.basename(hook_data.get('cwd', os.getcwd())),
'transcript_path': hook_data.get('transcript_path', ''),
}
# Extract tool input metadata only (no content for compliance)
tool_input = hook_data.get('tool_input', {})
if tool_input:
# Log metadata only for compliance
metadata = {}
if 'file_path' in tool_input:
metadata['file_path'] = tool_input['file_path']
if 'command' in tool_input:
metadata['command'] = tool_input['command'][:100] # First 100 chars only
if 'content' in tool_input:
metadata['content_size'] = len(str(tool_input['content']))
if 'new_string' in tool_input:
metadata['new_string_size'] = len(str(tool_input['new_string']))
if 'old_string' in tool_input:
metadata['old_string_size'] = len(str(tool_input['old_string']))
# Keep small metadata intact
for key, value in tool_input.items():
if key not in ['content', 'new_string', 'old_string'] and len(str(value)) < 200:
metadata[key] = value
info['tool_metadata'] = metadata
# Extract tool response metadata only
tool_response = hook_data.get('tool_response', {})
if tool_response:
# Log response metadata, not full content
response_metadata = {}
if isinstance(tool_response, dict):
for key, value in tool_response.items():
if len(str(value)) < 200: # Small responses only
response_metadata[key] = value
else:
response_metadata[f"{key}_size"] = len(str(value))
else:
response_metadata = {"response_size": len(str(tool_response))}
info['response_metadata'] = response_metadata
return info
except (json.JSONDecodeError, KeyError) as e:
return {
'tool_name': 'Unknown',
'hook_event': 'PostToolUse',
'timestamp': datetime.now().isoformat(),
'user': os.environ.get('USER', 'unknown'),
'project': os.path.basename(os.getcwd()),
'error': f'Failed to parse hook data: {str(e)}',
'raw_data': hook_data_str[:200] + '...' if len(hook_data_str) > 200 else hook_data_str
}
def log_audit_entry(info):
"""Write audit entry to log file."""
try:
audit_log = get_audit_log_path()
log_entry = json.dumps(info, separators=(',', ':'))
with open(audit_log, 'a', encoding='utf-8') as f:
f.write(log_entry + '\n')
except Exception as e:
# Fallback to stderr if logging fails
print(f"Audit logging failed: {e}", file=sys.stderr)
def main():
"""Main audit logging function."""
# Hook data comes via stdin as JSON
hook_input = sys.stdin.read().strip()
if not hook_input:
sys.exit(0)
# Extract and log the command information
info = extract_command_info(hook_input)
log_audit_entry(info)
# Exit successfully
sys.exit(0)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
def main():
try:
# Read and parse the hook input from stdin
hook_input = sys.stdin.read()
data = json.loads(hook_input)
# Get the file path from the tool input
file_path_str = data.get('tool_input', {}).get('file_path', '')
if not file_path_str:
sys.exit(0) # Nothing to check
file_path = Path(file_path_str)
# Define the list of sensitive file extensions
sensitive_extensions = ['.env', '.pem', '.key', '.credential', '.token']
# Check if the file path has a sensitive extension
if file_path.suffix.lower() in sensitive_extensions:
# This is a sensitive file, block the action
# Construct a clear, helpful error message for Claude
error_message = (
f"SECURITY_POLICY_VIOLATION: Access to the sensitive file '{file_path.name}' is blocked. "
f"Reason: Files with extensions like {', '.join(sensitive_extensions)} contain credentials and should not be accessed or modified by the AI. "
"Please use environment variables or a secure secret management tool instead."
)
# Print the error message to stderr
print(error_message, file=sys.stderr)
# Exit with code 2 to block the action and feed the error to Claude
sys.exit(2)
except (json.JSONDecodeError, KeyError):
# Fail silently if input is malformed
sys.exit(0)
# If no sensitive file is detected, exit with 0 to allow the action
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Python Threading Blocker Hook
Blocks usage of Python threading and suggests async/await patterns
"""
import json
import os
import sys
import re
from datetime import datetime
def check_for_threading(content):
"""Check if content contains threading patterns."""
if not content:
return None
# Patterns to detect threading usage
threading_patterns = [
(r'import\s+threading', 'import threading'),
(r'from\s+threading\s+import', 'from threading import'),
(r'threading\.Thread', 'threading.Thread'),
(r'Thread\s*\(', 'Thread class instantiation'),
(r'\.start\s*\(\s*\)', 'thread.start()'),
(r'\.join\s*\(\s*\)', 'thread.join()'),
(r'threading\.Lock', 'threading.Lock'),
(r'threading\.Event', 'threading.Event'),
(r'threading\.Semaphore', 'threading.Semaphore'),
(r'concurrent\.futures\.ThreadPoolExecutor', 'ThreadPoolExecutor'),
]
detected_patterns = []
for pattern, description in threading_patterns:
if re.search(pattern, content, re.IGNORECASE | re.MULTILINE):
detected_patterns.append(description)
return detected_patterns if detected_patterns else None
def generate_async_suggestion(detected_patterns):
"""Generate suggestions for async/await alternatives."""
suggestions = {
'import threading': 'Use: import asyncio',
'from threading import': 'Use: import asyncio',
'threading.Thread': 'Use: asyncio.create_task() or async def functions',
'Thread class instantiation': 'Use: async def function and asyncio.create_task()',
'thread.start()': 'Use: await asyncio.create_task(your_async_function())',
'thread.join()': 'Use: await task or asyncio.gather(*tasks)',
'threading.Lock': 'Use: asyncio.Lock()',
'threading.Event': 'Use: asyncio.Event()',
'threading.Semaphore': 'Use: asyncio.Semaphore()',
'ThreadPoolExecutor': 'Use: asyncio.create_task() for I/O bound, or asyncio.run_in_executor() for CPU-bound tasks'
}
advice = []
for pattern in detected_patterns:
if pattern in suggestions:
advice.append(f"โ€ข {pattern} โ†’ {suggestions[pattern]}")
else:
advice.append(f"โ€ข {pattern} โ†’ Consider async/await pattern")
return advice
def main():
"""Main hook function."""
try:
# Hook data comes via stdin as JSON
hook_input = sys.stdin.read().strip()
debug_log = "/tmp/threading_hook_debug.log"
with open(debug_log, "a") as f:
f.write(f"\n=== Threading Hook Debug {datetime.now().isoformat()} ===\n")
f.write(f"Hook input from stdin: {hook_input}\n")
if not hook_input:
sys.exit(0)
hook_data = json.loads(hook_input)
with open(debug_log, "a") as f:
f.write(f"Parsed hook_data keys: {list(hook_data.keys())}\n")
f.write(f"Hook data: {json.dumps(hook_data, indent=2)}\n")
tool_name = hook_data.get('tool_name', '') or os.environ.get('CLAUDE_TOOL_NAME', '')
tool_input = hook_data.get('tool_input', {}) or hook_data
with open(debug_log, "a") as f:
f.write(f"Tool name: {tool_name}\n")
f.write(f"Tool input keys: {list(tool_input.keys())}\n")
# Only check Write, Edit, MultiEdit tools for Python content
if tool_name not in ['Write', 'Edit', 'MultiEdit']:
with open(debug_log, "a") as f:
f.write(f"Skipping tool: {tool_name}\n")
sys.exit(0)
# Check if it's a Python file
file_path = tool_input.get('file_path', '')
if not file_path.endswith('.py'):
with open(debug_log, "a") as f:
f.write(f"Skipping non-Python file: {file_path}\n")
sys.exit(0)
with open(debug_log, "a") as f:
f.write(f"Checking Python file: {file_path}\n")
# Get content to check
content = None
if tool_name == 'Write':
content = tool_input.get('content', '')
elif tool_name == 'Edit':
content = tool_input.get('new_string', '')
elif tool_name == 'MultiEdit':
# Check all edits
edits = tool_input.get('edits', [])
all_content = []
for edit in edits:
all_content.append(edit.get('new_string', ''))
content = '\n'.join(all_content)
# Check for threading patterns
detected = check_for_threading(content)
if detected:
# Generate helpful error message
suggestions = generate_async_suggestion(detected)
error_msg = f"""๐Ÿšซ Threading usage detected in {file_path}
Detected patterns:
{chr(10).join(f"โ€ข {pattern}" for pattern in detected)}
๐Ÿ”„ Recommended async/await alternatives:
{chr(10).join(suggestions)}
๐Ÿ’ก Why async/await?
โ€ข Better performance for I/O-bound operations
โ€ข Easier debugging and testing
โ€ข More predictable execution model
โ€ข Better integration with modern Python frameworks
Example conversion:
```python
# Instead of:
import threading
def worker():
# do work
pass
thread = threading.Thread(target=worker)
thread.start()
thread.join()
# Use:
import asyncio
async def worker():
# do async work
pass
await worker()
```
Please refactor to use async/await patterns instead of threading."""
print(error_msg, file=sys.stderr)
sys.exit(2) # Block the operation
# No threading detected, allow operation
sys.exit(0)
except Exception as e:
# Don't block on errors, just log
print(f"Threading check failed: {e}", file=sys.stderr)
sys.exit(0)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
import os
import sys
import subprocess
import json
from pathlib import Path
def get_file_extension(file_path):
"""Get the file extension from a file path."""
return Path(file_path).suffix.lower()
def format_python(file_path):
"""Format Python files using ruff."""
try:
subprocess.run(['ruff', 'format', file_path], check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def format_rust(file_path):
"""Format Rust files using rustfmt."""
try:
subprocess.run(['rustfmt', file_path], check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def format_go(file_path):
"""Format Go files using gofmt."""
try:
subprocess.run(['gofmt', '-w', file_path], check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def format_javascript_typescript(file_path):
"""Format JS/TS files using prettier if available, fallback to built-in tools."""
# Try prettier first
try:
subprocess.run(['prettier', '--write', file_path], check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# Fallback to eslint --fix if available
try:
subprocess.run(['eslint', '--fix', file_path], check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def should_format_file(file_path):
"""Check if file should be formatted based on tool args."""
if not os.path.exists(file_path):
return False
# Only format if the file was actually modified by Write, Edit, or MultiEdit tools
tool_name = os.environ.get('CLAUDE_TOOL_NAME', '')
if tool_name not in ['Write', 'Edit', 'MultiEdit']:
return False
return True
def main():
"""Main formatting function."""
# Get tool arguments from environment
tool_args = os.environ.get('CLAUDE_TOOL_ARGS', '{}')
try:
args_data = json.loads(tool_args)
file_path = args_data.get('file_path')
if not file_path or not should_format_file(file_path):
sys.exit(0)
extension = get_file_extension(file_path)
formatted = False
if extension == '.py':
formatted = format_python(file_path)
elif extension == '.rs':
formatted = format_rust(file_path)
elif extension == '.go':
formatted = format_go(file_path)
elif extension in ['.js', '.ts', '.jsx', '.tsx']:
formatted = format_javascript_typescript(file_path)
if formatted:
print(f"Formatted {file_path}")
except (json.JSONDecodeError, KeyError):
# Silently ignore if we can't parse tool args
pass
sys.exit(0)
if __name__ == '__main__':
main()

The Python Power Pack for Claude Code

A collection of 4 essential, cross-platform hooks to make your Claude Code experience safer and more productive. All hooks are written in pure Python with zero dependencies (well except for the formatters for format_code).


๐Ÿš€ Quick Install (2 Minutes)

1. Create a Hooks Folder & Download the Scripts

First, create a dedicated folder for your Claude hooks. A good place is in your home directory:

mkdir -p ~/.claude-hooks

Save each of the Python scripts from this Gist into that new ~/.claude-hooks/ folder.

2. Make the Hooks Executable

Open your terminal and run this command to make all the scripts runnable:

chmod +x ~/.claude-hooks/*.py

(Note: This step is not needed on Windows.)

3. Tell Claude Code Where to Find Your Hooks

You need to edit your settings.json file.

  • **Claude Code hooks are configured in your settings files:

  • ~/.claude/settings.json - User settings < if you want these hooks to run on every project, use this one.

    • .claude/settings.json - Project settings
    • .claude/settings.local.json - Local project settings (not committed)
  • What to add: Add the "hooks" configuration block below. Make sure to use the full, absolute path to your hooks folder.

{
  // ... your other settings ...

  "hooks": {
    "PostToolUse": [
      {
        "type": "command",
        "command": "/Users/your_username/.claude-hooks/auto_formatter.py"
      },
      {
        "type": "command",
        "command": "/Users/your_username/.claude-hooks/audit_logger.py"
      }
    ],
    "PreToolUse": [
      {
        "type": "command",
        "command": "/Users/your_username/.claude-hooks/threading_blocker.py"
      },
      {
        "type": "command",
        "command": "if echo \"$CLAUDE_TOOL_ARGS\" | grep -q '\\.env'; then echo 'Error: Access to .env files is prohibited' >&2 && exit 2; fi"
      }
    ]
  }
}

Important:

  • Replace /Users/your_username/ with the actual path to your home directory.
  • If you already have a "hooks" section, simply add these commands to the appropriate lists (PostToolUse, PreToolUse).

That's it! Restart Claude Code, and your hooks will be active.


@coygeek
Copy link

coygeek commented Aug 28, 2025

Code Review for Claude Code Python Hooks

Hi Jeremy,

Thank you for sharing your collection of Python hooks for Claude Code. This is an excellent initiative! These "Power Pack" scripts demonstrate a deep understanding of practical developer needs and showcase the power of the hooks system for enforcing compliance, maintaining code quality, and improving security. The overall quality is high, and the goals of each script are clear and valuable.

This review provides feedback on the implementation, cross-referencing it with the official Claude Code documentation you provided to ensure the scripts are robust, portable, and future-proof.

High-Level Feedback & Key Recommendations

The scripts are well-written, but there's a critical discrepancy in how they receive data from Claude Code compared to the documented API. Addressing this will be key to ensuring they work reliably for all users.

  1. Data Input Mechanism (Critical): The format_code.py script and parts of block_threading.py rely on environment variables (CLAUDE_TOOL_ARGS, CLAUDE_TOOL_NAME) to get information about the tool call. The official hooks and hooks-guide documentation is very clear that hook data is passed as a JSON object via stdin.

    • Reference: The "Hook Input" section of the hooks documentation states: "Hooks receive JSON data via stdin containing session information and event-specific data." It then provides detailed schemas for PreToolUse and PostToolUse events.
    • Recommendation: All scripts should be updated to read from sys.stdin and parse the JSON to get tool information. Relying on undocumented environment variables is risky, as they could be changed or removed in future versions of Claude Code. The audit_logger.py and block_secrets.py scripts already do this perfectly, and they should be used as the model for the others.
  2. Installation Portability: The install.md guide uses a hardcoded absolute path (/Users/your_username/...). This is not portable across different users or operating systems.

    • Recommendation: Instruct users to use the tilde (~) character (e.g., ~/.claude-hooks/audit_logger.py), which is expanded by most shells to the user's home directory. This makes the configuration snippet copy-paste friendly for everyone.
  3. Hook Configuration Efficiency: The install.md guide registers each hook as a separate command in settings.json. For a "pack" of related scripts, it can be more efficient to have a single "router" script that takes an argument and executes the appropriate logic. This is a minor suggestion for a potential future enhancement, as the current method is perfectly valid.


Script-by-Script Review

1. audit_logger.py (PostToolUse)

This script is excellent and serves as a fantastic example of how to write a robust, compliance-aware hook.

What's Great:

  • Correct API Usage: It correctly reads the JSON payload from stdin and parses it, perfectly aligning with the official documentation.
  • Security & Compliance: The logic to extract only metadata (e.g., content_size) instead of full, potentially sensitive content is a brilliant design choice. This makes the audit log safe and useful without creating a security liability.
  • Robustness: The error handling for JSON parsing and the detailed information captured (user, project, session ID) make it production-ready.
  • Best Practices: Uses pathlib for path manipulation and writes logs to a sensible location (~/.claude/).

Suggestions:

  • No major changes needed. This script is the gold standard for the pack.

2. block_secrets.py (PreToolUse)

This is a simple, effective, and critical security hook. It's perfectly implemented.

What's Great:

  • Correct API Usage: It correctly uses the PreToolUse event to intercept the action before it happens.
  • Clear Feedback Loop: The script uses sys.exit(2) and prints a detailed, helpful error message to stderr.
    • Reference: This perfectly implements the "Simple: Exit Code" mechanism from the hooks documentation: "Exit code 2: Blocking error. stderr is fed back to Claude to process automatically." The error message is well-crafted to help Claude understand the policy and correct its behavior.
  • Clean Code: The logic is straightforward and easy to understand.

Suggestions:

  • Consider expanding the sensitive_extensions list to include common variations like .secret, .credentials, or files like id_rsa.

3. block_threading.py (PreToolUse)

This is a fantastic example of using hooks to enforce opinionated coding standards. The feedback provided to the model is top-notch.

What's Great:

  • Excellent Feedback: The error message sent to stderr is a model of what a good blocking hook should do. It explains why the action was blocked, lists the detected patterns, suggests a specific alternative (asyncio), and even provides a code example. This is extremely effective for guiding the LLM.
  • Targeted Logic: The script correctly checks that it's only acting on Python files being modified by relevant tools.

Suggestions:

  • Input Source: The script correctly reads from stdin, but it also includes fallback logic to check environment variables (os.environ.get('CLAUDE_TOOL_NAME', '')). This fallback should be removed in favor of relying solely on the documented stdin API for consistency and reliability.
  • Debug Logging: The script logs debug information to a hardcoded path (/tmp/threading_hook_debug.log). For a distributable script, it would be better to make this opt-in, perhaps by checking for an environment variable like CLAUDE_HOOK_DEBUG=1.

4. format_code.py (PostToolUse)

The concept is excellent and highly useful, but the implementation needs to be aligned with the official hooks API.

What's Great:

  • Functionality: The core idea of running formatters based on file type is a perfect use case for a PostToolUse hook.
  • Multi-language Support: It's great that it supports multiple formatters (ruff, rustfmt, gofmt, prettier).

Critical Issue for Correction:

  • Input Source: As mentioned in the high-level feedback, this script exclusively uses os.environ.get('CLAUDE_TOOL_ARGS', '{}') to get the file path. This will fail in a standard Claude Code environment that passes data via stdin.
    • Reference: The PostToolUse Input schema in the hooks documentation shows that the tool_input object, containing the file_path, is part of the JSON payload sent to stdin.
    • Required Fix: The script must be rewritten to read from sys.stdin, parse the JSON, and extract the file_path from the tool_input key.

Example Fix:

# --- In format_code.py ---

def main():
    """Main formatting function."""
    # Read the JSON payload from stdin
    hook_input = sys.stdin.read()
    
    try:
        data = json.loads(hook_input)
        
        # Extract file_path from the documented stdin structure
        file_path = data.get('tool_input', {}).get('file_path')
        tool_name = data.get('tool_name', '')
        
        # Only format if the file was modified by these tools
        if tool_name not in ['Write', 'Edit', 'MultiEdit'] or not file_path:
            sys.exit(0)
            
        if not os.path.exists(file_path):
            sys.exit(0)

        # ... (rest of the formatting logic remains the same) ...
        extension = get_file_extension(file_path)
        # ...

    except (json.JSONDecodeError, KeyError):
        # Silently ignore if we can't parse input
        sys.exit(0)

install.md Review

The installation guide is clear and easy to follow. A few small tweaks will make it even better.

Suggestions:

  1. Use ~ for Home Directory: In the settings.json example, replace /Users/your_username/ with ~/. This will work for almost all users on macOS and Linux out-of-the-box.
  2. Address Undocumented Env Vars: The inline shell command if echo \"$CLAUDE_TOOL_ARGS\"... relies on the same undocumented environment variable as format_code.py. This should be replaced with a proper hook script that reads from stdin. A simplified version of block_secrets.py could achieve this. For example, ~/.claude-hooks/block_env_files.py.
  3. Cross-Platform Note: For Windows users, the Python scripts will work, but they will need to ensure Python is in their PATH. The chmod command won't work, but it's also not necessary. Adding a small "For Windows Users" note could be helpful.

Final Thoughts

This is a very impressive and highly practical set of tools. The audit_logger and block_secrets scripts are perfect as-is, and the block_threading script is nearly perfect. With the recommended change to the data input mechanism in format_code.py, this "Power Pack" will be a robust, reliable, and incredibly useful addition to any developer's Claude Code setup.

It's clear you've put a lot of thought into creating genuinely useful automations. Keep up the fantastic work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment