Skip to content

Instantly share code, notes, and snippets.

@ranaroussi
Last active July 24, 2025 12:23
Show Gist options
  • Save ranaroussi/42b7c319e1e36be39b554b66e8fba3cb to your computer and use it in GitHub Desktop.
Save ranaroussi/42b7c319e1e36be39b554b66e8fba3cb to your computer and use it in GitHub Desktop.
{
"enableAllProjectMcpServers": true,
"permissions": {
"allow": [
"Read(**)",
"Edit(**)",
"MultiEdit(**)",
"Write(**)",
"Glob(**)",
"Grep(**)",
"LS(**)",
"WebSearch(**)",
"WebFetch(**)",
"TodoRead()",
"TodoWrite(**)",
"Task(**)",
"Bash(git status*)",
"Bash(git log*)",
"Bash(git diff*)",
"Bash(git show*)",
"Bash(git blame*)",
"Bash(git branch*)",
"Bash(git remote -v*)",
"Bash(git config --get*)",
"Bash(cp*)",
"Bash(mv*)",
"Bash(rm*)",
"Bash(mkdir*)",
"Bash(touch*)",
"Bash(chmod*)",
"Bash(eza*)",
"Bash(ls*)",
"Bash(cat *)",
"Bash(less *)",
"Bash(head*)",
"Bash(tail*)",
"Bash(grep*)",
"Bash(find*)",
"Bash(tree*)",
"Bash(pwd*)",
"Bash(wc*)",
"Bash(diff *)",
"Bash(sed -n*)",
"Bash(awk*)",
"Bash(cut*)",
"Bash(sort*)",
"Bash(uniq*)",
"Bash(basename *)",
"Bash(dirname *)",
"Bash(realpath *)",
"Bash(readlink *)",
"Bash(curl*)",
"Bash(jq*)",
"Bash(yq eval*)",
"Bash(python*)",
"Bash(python3*)",
"Bash(uv*)",
"Bash(uvx*)",
"Bash(pip install*)",
"Bash(node*)",
"Bash(npm list*)",
"Bash(npm run*)",
"Bash(npx*)",
"Bash(black --check*)",
"Bash(black --diff*)",
"Bash(pylint*)",
"Bash(flake8*)",
"Bash(pyright*)",
"Bash(mypy*)",
"Bash(eslint*)",
"Bash(pytest*)",
"Bash(make test*)",
"Bash(npm test*)",
"Bash(make -n*)",
"Bash(man *)",
"Bash(pydoc*)",
"Bash(which *)",
"Bash(type *)",
"Bash(echo *)",
"Bash(printf *)",
"Bash(test *)",
"Bash(true*)",
"Bash(false*)",
"Bash(* | grep*)",
"Bash(* | jq*)",
"Bash(* | head*)",
"Bash(* | tail*)",
"Bash(* | wc*)",
"Bash(* | sort*)",
"Bash(* | uniq*)",
"Bash(gh:*)",
"Bash(claude*)",
"Bash(task-master*)",
"Bash(psql:*)",
"Bash(do*)",
"Bash(done)",
"Bash(for*)",
"Bash(pkill:*)",
"Bash(say:*)",
"Bash(time:*)",
"Bash(timeout:*)"
],
"deny": ["Bash(rm -rf:*)"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/pre_tool_use.py"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/notify.py --notification"
}
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/notify.py --stop"
}
]
}
]
}
}
@ranaroussi
Copy link
Author

ranaroussi commented Jul 24, 2025

~/.claude/hooks/pre_tool_use.py

import json
import sys
import re


def is_dangerous_rm_command(command):
    """
    Comprehensive detection of dangerous rm commands.
    Matches various forms of rm -rf and similar destructive patterns.
    """
    # Normalize command by removing extra spaces and converting to lowercase
    normalized = " ".join(command.lower().split())

    # Pattern 1: Standard rm -rf variations
    patterns = [
        r"\brm\s+.*-[a-z]*r[a-z]*f",  # rm -rf, rm -fr, rm -Rf, etc.
        r"\brm\s+.*-[a-z]*f[a-z]*r",  # rm -fr variations
        r"\brm\s+--recursive\s+--force",  # rm --recursive --force
        r"\brm\s+--force\s+--recursive",  # rm --force --recursive
        r"\brm\s+-r\s+.*-f",  # rm -r ... -f
        r"\brm\s+-f\s+.*-r",  # rm -f ... -r
    ]

    # Check for dangerous patterns
    for pattern in patterns:
        if re.search(pattern, normalized):
            return True

    # Pattern 2: Check for rm with recursive flag targeting dangerous paths
    dangerous_paths = [
        r"/",  # Root directory
        r"/\*",  # Root with wildcard
        r"~",  # Home directory
        r"~/",  # Home directory path
        r"\$HOME",  # Home environment variable
        r"\.\.",  # Parent directory references
        r"\*",  # Wildcards in general rm -rf context
        r"\.",  # Current directory
        r"\.\s*$",  # Current directory at end of command
    ]

    if re.search(r"\brm\s+.*-[a-z]*r", normalized):  # If rm has recursive flag
        for path in dangerous_paths:
            if re.search(path, normalized):
                return True

    return False


def is_env_file_access(tool_name, tool_input):
    """
    Check if any tool is trying to access .env files containing sensitive data.
    """
    if tool_name in ["Read", "Edit", "MultiEdit", "Write", "Bash"]:
        # Check file paths for file-based tools
        if tool_name in ["Read", "Edit", "MultiEdit", "Write"]:
            file_path = tool_input.get("file_path", "")
            if ".env" in file_path and not file_path.endswith(".env.sample"):
                return True

        # Check bash commands for .env file access
        elif tool_name == "Bash":
            command = tool_input.get("command", "")
            # Pattern to detect .env file access (but allow .env.sample)
            env_patterns = [
                r"\b\.env\b(?!\.sample)",  # .env but not .env.sample
                r"cat\s+.*\.env\b(?!\.sample)",  # cat .env
                r"echo\s+.*>\s*\.env\b(?!\.sample)",  # echo > .env
                r"touch\s+.*\.env\b(?!\.sample)",  # touch .env
                r"cp\s+.*\.env\b(?!\.sample)",  # cp .env
                r"mv\s+.*\.env\b(?!\.sample)",  # mv .env
            ]

            for pattern in env_patterns:
                if re.search(pattern, command):
                    return True

    return False


def main():
    try:
        # Read JSON input from stdin
        input_data = json.load(sys.stdin)

        tool_name = input_data.get("tool_name", "")
        tool_input = input_data.get("tool_input", {})

        # Check for .env file access (blocks access to sensitive environment files)
        if is_env_file_access(tool_name, tool_input):
            print(
                "BLOCKED: Access to .env files containing sensitive data is prohibited",
                file=sys.stderr,
            )
            print("Use .env.sample for template files instead", file=sys.stderr)
            sys.exit(2)  # Exit code 2 blocks tool call and shows error to Claude

        # Check for dangerous rm -rf commands
        if tool_name == "Bash":
            command = tool_input.get("command", "")

            # Block rm -rf commands with comprehensive pattern matching
            if is_dangerous_rm_command(command):
                print(
                    "BLOCKED: Dangerous rm command detected and prevented",
                    file=sys.stderr,
                )
                sys.exit(2)  # Exit code 2 blocks tool call and shows error to Claude
        sys.exit(0)

    except json.JSONDecodeError:
        # Gracefully handle JSON decode errors
        sys.exit(0)
    except Exception:
        # Handle any other errors gracefully
        sys.exit(0)


if __name__ == "__main__":
    main()

@ranaroussi
Copy link
Author

~/.claude/hooks/notify.py

#!/usr/bin/env -S uv run --script

import os
import sys
import subprocess
import json
from pathlib import Path
import re


# used for device-specific notifications
DEVICES = {"desktop": "DEVICE-NAME", "mobile": "DEVICE-NA<E"}


def get_most_recent_session_file():
    """Find the most recently modified session file across all projects."""
    projects_dir = Path.home() / ".claude" / "projects"
    if not projects_dir.exists():
        return None

    # Get all session files from all projects
    all_session_files = []
    for project_dir in projects_dir.iterdir():
        if project_dir.is_dir():
            session_files = list(project_dir.glob("*.jsonl"))
            all_session_files.extend(session_files)

    if not all_session_files:
        return None

    # Get the most recently modified file across all projects
    most_recent = max(all_session_files, key=lambda f: f.stat().st_mtime)
    return most_recent


def summarize_with_gemini(text):
    """Use external AI to summarize the message into title and body."""
    try:
        # Load environment variables from .env file
        env_files = [Path(".claude/.env"), Path.home() / ".claude/.env"]
        env_vars = os.environ.copy()

        for env_file in env_files:
            if env_file.exists():
                with open(env_file) as f:
                    for line in f:
                        if "=" in line and not line.strip().startswith("#"):
                            key, value = line.strip().split("=", 1)
                            env_vars[key] = value.strip('"').strip("'")
                break

        # Ensure GEMINI_API_KEY is available
        if "GEMINI_API_KEY" not in env_vars and "GEMINI_API_KEY" in os.environ:
            env_vars["GEMINI_API_KEY"] = os.environ["GEMINI_API_KEY"]
        prompt = f"""Summarize this AI assistant response into a notification:

{text}

Return ONLY a JSON object with:
- "title": 4-5 words maximum, describing what was done
- "message": short summary, max 72 characters

Example: {{"title": "Updated notification hook", "message": "Modified the hook to read session transcripts and show context-aware messages."}}"""

        cmd = ["gemini", "-p", prompt]
        result = subprocess.run(
            cmd, capture_output=True, text=True, timeout=30, env=env_vars
        )

        if result.returncode == 0 and result.stdout:
            try:
                # Try to parse as direct JSON first
                summary = json.loads(result.stdout.strip())
                return summary.get("title"), summary.get("message")
            except json.JSONDecodeError:
                # Try to extract JSON from the response
                json_match = re.search(r"\{[^}]+\}", result.stdout, re.DOTALL)
                if json_match:
                    summary = json.loads(json_match.group())
                    return summary.get("title"), summary.get("message")
        return None, None
    except Exception:
        return None, None


def extract_latest_assistant_message(session_file, use_claude_summary=True):
    """Extract the latest assistant message from the session file."""
    if not session_file or not session_file.exists():
        return None

    assistant_messages = []

    try:
        with open(session_file, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue

                try:
                    entry = json.loads(line)
                    if entry.get("type") == "assistant" and "message" in entry:
                        assistant_messages.append(entry)
                except json.JSONDecodeError:
                    continue

        if not assistant_messages:
            return None

        # Get the last valid assistant message - skip API key errors and tool uses
        last_message = None

        for entry in reversed(assistant_messages):
            if "message" in entry and isinstance(entry["message"], dict):
                # Handle nested message structure from Claude Code
                content = entry["message"].get("content", [])
                if isinstance(content, list) and content:
                    # Extract text from text content blocks (skip tool_use blocks)
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            temp_msg = block.get("text", "")
                            # Skip messages about API keys and empty messages
                            if (
                                temp_msg
                                and "Invalid API key" not in temp_msg
                                and len(temp_msg.strip()) > 10
                            ):
                                last_message = temp_msg
                                break
                    if last_message:
                        break
            elif "message" in entry:
                # Fallback for simple string messages
                temp_msg = entry.get("message", "")
                # Skip the specific problematic message and API key errors
                if (
                    temp_msg
                    and temp_msg != "Invalid API key Β· Fix external API key"
                    and "Invalid API key" not in temp_msg
                    and len(temp_msg.strip()) > 10
                ):
                    last_message = temp_msg
                    break

        if not last_message:
            return None

        # Clean up the message - remove code blocks but keep the content meaningful
        cleaned_message = re.sub(r"```[\s\S]*?```", "", last_message)
        cleaned_message = re.sub(
            r"`([^`]+)`", r"\1", cleaned_message
        )  # Keep content of inline code
        cleaned_message = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned_message)
        cleaned_message = re.sub(r"\*(.*?)\*", r"\1", cleaned_message)

        # Split into sentences and get the first meaningful one
        sentences = re.split(r"[.!?]\s+", cleaned_message)

        # Find the first sentence with substance
        for sentence in sentences:
            sentence = sentence.strip()
            # Skip short sentences, list items, or fragments
            if len(sentence) > 15 and not sentence.startswith(
                ("-", "*", "β€’", "1.", "2.", "3.")
            ):
                cleaned_message = sentence
                break
        else:
            # If no good sentence found, just clean up the whole message
            cleaned_message = re.sub(r"\n+", " ", cleaned_message)
            cleaned_message = re.sub(r"\s+", " ", cleaned_message).strip()

        # Remove any trailing punctuation for cleaner notifications
        cleaned_message = cleaned_message.rstrip(".,;:")

        # Try to get a Claude summary if enabled
        if use_claude_summary and last_message:
            title, summary = summarize_with_gemini(last_message)
            if title and summary:
                return {"title": title, "message": summary}

        # Fallback to cleaned message
        # Truncate to max_chars
        if len(cleaned_message) > 150:
            cleaned_message = cleaned_message[:147] + "..."

        return {"title": None, "message": cleaned_message}

    except Exception as e:
        print(f"Error reading session file: {e}")
        return None


def is_screen_on():
    """Check if the screen is on (user is active) on macOS."""
    try:
        result = subprocess.run(
            [
                "python3",
                "-c",
                'import Quartz; print("1" if Quartz.CGSessionCopyCurrentDictionary() else "0")',
            ],
            capture_output=True,
            text=True,
        )
        return result.stdout.strip() == "1"
    except Exception:
        # Default to screen on if we can't detect
        return True


def main():
    # Load environment variables from .env file
    env_files = [
        Path(".claude/.env"),
        Path.home() / ".claude/.env",
    ]  # Check .claude/.env first

    for env_file in env_files:
        if env_file.exists():
            with open(env_file) as f:
                for line in f:
                    if "=" in line and not line.strip().startswith("#"):
                        key, value = line.strip().split("=", 1)
                        os.environ[key] = value.strip('"').strip("'")
            break  # Use the first .env file found

    # Send Pushover notification
    pushover_token = os.getenv("PUSHOVER_TOKEN")
    pushover_user = os.getenv("PUSHOVER_USER")

    if pushover_token and pushover_user:
        # Get project name from current directory
        cwd = Path.cwd()
        project_name = cwd.name

        # Try to get the latest session message
        context_data = None
        try:
            session_file = get_most_recent_session_file()
            context_data = extract_latest_assistant_message(session_file)
        except Exception as e:
            print(f"Error extracting session context: {e}")

        # Determine notification type and content based on arguments
        if "--subagent-stop" in sys.argv:
            icon = "πŸ€–"
            if context_data and isinstance(context_data, dict):
                title = context_data.get("title") or "Subagent Complete"
                message = f"{icon} [{project_name}] {context_data.get('message', 'Background task finished')}"
            else:
                title = "Subagent Complete"
                message = f"{icon} [{project_name}] Background task finished"

        elif "--stop" in sys.argv:
            icon = "🏁"
            if context_data and isinstance(context_data, dict):
                title = context_data.get("title") or "Task Complete"
                message = f"{icon} [{project_name}] {context_data.get('message', 'Ready for your input')}"
            else:
                title = "Task Complete"
                message = f"{icon} [{project_name}] Ready for your input"

        elif "--notification" in sys.argv:
            icon = "πŸ””"
            if context_data and isinstance(context_data, dict):
                title = context_data.get("title") or "Permission Required"
                msg = context_data.get("message", "")[:50]
                message = f"{icon} [{project_name}] Tool approval needed: {msg}..."
            else:
                title = "Permission Required"
                message = (
                    f"{icon} [{project_name}] Your approval is required to use a tool"
                )

        else:
            icon = "πŸ›ŽοΈ"
            title = "Claude Idle"
            message = (
                f"{icon} [{project_name}] Waiting for your input (idle 60+ seconds)"
            )

        # Determine target device based on screen state
        screen_on = is_screen_on()
        device = DEVICES["desktop"] if screen_on else DEVICES["mobile"]

        # Send notification via curl
        cmd = [
            "curl",
            "-s",
            "--form-string",
            f"token={pushover_token}",
            "--form-string",
            f"user={pushover_user}",
            "--form-string",
            f"title={title}",
            "--form-string",
            f"message={message}",
            "--form-string",
            f"device={device}",
            "https://api.pushover.net/1/messages.json",
        ]

        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            print(f"Pushover notification failed: {result.stderr}")


if __name__ == "__main__":
    main()

@ranaroussi
Copy link
Author

~/.claude/.env

MY_NAME=Engineer
PUSHOVER_TOKEN=""
PUSHOVER_USER=""
GEMINI_API_KEY=""

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