Skip to content

Instantly share code, notes, and snippets.

@vroomfondel
Last active February 11, 2026 14:40
Show Gist options
  • Select an option

  • Save vroomfondel/ba4f08de097e3726e1d88eaea77e3501 to your computer and use it in GitHub Desktop.

Select an option

Save vroomfondel/ba4f08de097e3726e1d88eaea77e3501 to your computer and use it in GitHub Desktop.
Claude Code settings + hook scripts
#!/usr/bin/env python3
"""Claude Code PreToolUse hook for permission decisions.
Registered in ~/.claude/settings.json as a PreToolUse hook. Claude Code calls
this script via stdin (JSON) before every tool invocation. The script decides
whether to auto-allow, block, or defer to the normal permission prompt.
== Decision priority ==
1. Read-only tools (Read, Glob, Grep, WebSearch, WebFetch) → always allowed
2. ~/CLAUDE_PERMISSION_AUTO_DISALLOW exists → blocked (ask user)
3. <project>/CLAUDE_PERMISSION_AUTO_DISALLOW exists → blocked (ask user)
4. <project>/CLAUDE_PERMISSION_AUTO_ALLOW exists → allowed (with rules)
5. ~/CLAUDE_PERMISSION_AUTO_ALLOW exists → allowed (with rules)
6. None of the above → normal permission prompt
Disallow always beats allow, regardless of level. Global disallow beats
project allow. Project disallow beats global allow.
== Allow files: empty vs. rules ==
*** IMPORTANT: Rules inside an allow file are EXCEPTIONS — they define ***
*** what should NOT be auto-allowed. A regex that matches means "still ***
*** ask the user for confirmation, even though auto-allow is active". ***
*** Prefix a rule with ! to HARD-DENY (Claude cannot use the tool at ***
*** all, no prompt, no override). ***
- Empty file (or only comments/blank lines): allow ALL non-read-only tools.
- File with rules: allow everything EXCEPT tools whose summary matches a
rule. Matching tools fall through to the normal permission prompt.
- Rules prefixed with ! are hard-denied: the tool call is rejected
outright, Claude is told the reason, and no user prompt is shown.
- If both a ! rule and a normal rule match, ! (deny) ALWAYS wins,
regardless of which file or position the rules are in.
- Rules from BOTH files (global + project) are merged, project rules
are checked first. A global !-rule cannot be overridden by a project
ask-rule. E.g., if the global file says "!^Bash: rm -rf", no project
can weaken that to an ask-prompt.
Rules are case-insensitive Python regexes (re.search), one per line.
Lines starting with # are comments. Blank lines are ignored.
== Summary format (what rules match against) ==
tool_summary() produces a string in the format "ToolName: detail":
Bash: git push origin main
Bash: npm test
Bash: ansible-playbook site.yml --check --diff
Edit: /home/user/project/main.py
Write: /home/user/project/new_file.py
Grep: some_pattern
Task: deploy k8s manifests
WebSearch: python regex howto
WebFetch: https://example.com
NotebookEdit (tool name only, no detail)
== Example CLAUDE_PERMISSION_AUTO_ALLOW files ==
# --- Allow everything (empty file, same as touch) ---
# (no rules)
# --- Allow everything, but git push/commit needs confirmation ---
^Bash: git (push|commit)
# --- Allow everything, but block all shell commands ---
^Bash:
# --- Allow everything, but ansible-playbook without --check needs prompt ---
^Bash: ansible-playbook (?!.*--check)
# --- Allow everything, but writing outside the project needs prompt ---
^Write: /(?!home/user/myproject/)
# --- Hard-deny dangerous commands (! prefix, Claude cannot use at all) ---
!^Bash: rm -rf
!^Bash: docker rm
!^Bash: kubectl delete
# --- Mix: ask for git push, hard-deny force push ---
^Bash: git push
!^Bash: git push.*--force
== Usage ==
# Global auto-allow (all tools, all projects):
touch ~/CLAUDE_PERMISSION_AUTO_ALLOW
# Global auto-allow, but git push/commit still needs confirmation:
echo '^Bash: git (push|commit)' > ~/CLAUDE_PERMISSION_AUTO_ALLOW
# Project-level auto-allow (all tools in this project):
touch /path/to/project/CLAUDE_PERMISSION_AUTO_ALLOW
# Project-level auto-allow, but no unreviewed shell commands:
echo '^Bash:' > /path/to/project/CLAUDE_PERMISSION_AUTO_ALLOW
# Block a specific project (even if global allow exists):
touch /path/to/project/CLAUDE_PERMISSION_AUTO_DISALLOW
# Global kill switch (blocks all projects):
touch ~/CLAUDE_PERMISSION_AUTO_DISALLOW
# Remove to deactivate:
rm ~/CLAUDE_PERMISSION_AUTO_ALLOW
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from typing import Any, Literal, NoReturn
GLOBAL_ALLOW_FILE: str = os.path.expanduser("~/CLAUDE_PERMISSION_AUTO_ALLOW")
GLOBAL_DISALLOW_FILE: str = os.path.expanduser("~/CLAUDE_PERMISSION_AUTO_DISALLOW")
PROJECT_ALLOW_FILENAME: str = "CLAUDE_PERMISSION_AUTO_ALLOW"
PROJECT_DISALLOW_FILENAME: str = "CLAUDE_PERMISSION_AUTO_DISALLOW"
ALWAYS_ALLOW_TOOLS: frozenset[str] = frozenset(
{"Read", "Glob", "Grep", "WebSearch", "WebFetch"}
)
RuleAction = Literal["ask", "deny"]
Rule = tuple[RuleAction, str]
def notify(
title: str,
body: str,
*,
urgency: str = "low",
icon: str = "dialog-information",
timeout: int = 3000,
sound: str = "dialog-information",
) -> None:
subprocess.Popen(
["notify-send", "-u", urgency, "-i", icon, "-t", str(timeout), title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
subprocess.Popen(
["canberra-gtk-play", "-i", sound],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _emit_decision(decision_type: str, reason: str) -> NoReturn:
decision: dict[str, Any] = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision_type,
"permissionDecisionReason": reason,
}
}
json.dump(decision, sys.stdout)
sys.exit(0)
def deny(reason: str) -> NoReturn:
_emit_decision("deny", reason)
def allow(reason: str) -> NoReturn:
_emit_decision("allow", reason)
def read_rules(filepath: str) -> list[Rule]:
"""Read rules from a file. Returns list of (action, regex) tuples.
Each line is a regex. Prefix with ! to hard-deny (instead of ask).
Lines starting with # are comments. Blank lines are ignored.
Returns empty list if file is empty or unreadable.
"""
rules: list[Rule] = []
try:
with open(filepath) as f:
for line in f:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("!"):
rules.append(("deny", stripped[1:]))
else:
rules.append(("ask", stripped))
except OSError:
pass
return rules
def check_rules(rules: list[Rule], summary: str) -> RuleAction | None:
"""Check if summary matches any rule. Returns "deny", "ask", or None.
If multiple rules match, "deny" takes precedence over "ask".
A "deny" match returns immediately; "ask" keeps checking for a later "deny".
"""
result: RuleAction | None = None
for action, pattern in rules:
if re.search(pattern, summary, re.IGNORECASE):
if action == "deny":
return "deny"
result = "ask"
return result
def tool_summary(tool_name: str, tool_input: dict[str, Any]) -> str:
if tool_name == "Bash":
cmd: str = tool_input.get("command", "")
return f"Bash: {cmd[:80]}" if cmd else "Bash"
if tool_name in ("Edit", "Write"):
return f"{tool_name}: {tool_input.get('file_path', '')}"
if tool_name in ("Read", "Glob"):
path: str = tool_input.get("file_path") or tool_input.get("pattern", "")
return f"{tool_name}: {path}"
if tool_name == "Grep":
return f"Grep: {tool_input.get('pattern', '')}"
if tool_name == "Task":
return f"Task: {tool_input.get('description', '')}"
if tool_name in ("WebSearch", "WebFetch"):
query: str = tool_input.get("query") or tool_input.get("url", "")
return f"{tool_name}: {query[:80]}"
return tool_name
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--no-via", dest="show_via", action="store_false", default=True,
help="Suppress 'via <filepath>' from notification popups",
)
args = parser.parse_args()
hook_input: dict[str, Any] = json.loads(sys.stdin.read())
tool_name: str = hook_input.get("tool_name", "")
tool_input: dict[str, Any] = hook_input.get("tool_input", {})
summary: str = tool_summary(tool_name, tool_input)
# Auto-allow read-only tools (no override needed)
if tool_name in ALWAYS_ALLOW_TOOLS:
notify("Claude: read-only", summary,
urgency="low", icon="dialog-information", timeout=3000,
sound="dialog-information")
allow(f"{tool_name} is a read-only tool")
# For everything else: check overrides
# Priority: any disallow beats any allow; disallows block, allows grant
# 1. global disallow → block (beats project allow)
# 2. project disallow → block (beats global allow)
# 3. no allow file → ask user
# 4. deny-exception rules from both files merged; if any matches → ask user
# 5. project allow → grant (takes precedence for notification)
# 6. global allow → grant
# 7. default → ask user
project_dir: str = hook_input.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR", "")
project_disallow_file: str = (
os.path.join(project_dir, PROJECT_DISALLOW_FILENAME) if project_dir else ""
)
project_allow_file: str = (
os.path.join(project_dir, PROJECT_ALLOW_FILENAME) if project_dir else ""
)
if os.path.isfile(GLOBAL_DISALLOW_FILE):
body = f"{summary}\nvia {GLOBAL_DISALLOW_FILE}" if args.show_via else summary
notify("Claude: BLOCKED [global]", body,
urgency="normal", icon="dialog-error", timeout=5000,
sound="dialog-error")
sys.exit(0)
if project_disallow_file and os.path.isfile(project_disallow_file):
body = f"{summary}\nvia {project_disallow_file}" if args.show_via else summary
notify("Claude: BLOCKED [project]", body,
urgency="normal", icon="dialog-error", timeout=5000,
sound="dialog-error")
sys.exit(0)
has_project_allow: bool = bool(project_allow_file) and os.path.isfile(project_allow_file)
has_global_allow: bool = os.path.isfile(GLOBAL_ALLOW_FILE)
if not has_project_allow and not has_global_allow:
sys.exit(0)
# Merge deny-exception rules: project first, then global (global always applies).
# A ! (deny) rule always wins over a normal (ask) rule, regardless of order.
all_rules: list[Rule] = []
if has_project_allow:
all_rules.extend(read_rules(project_allow_file))
if has_global_allow:
all_rules.extend(read_rules(GLOBAL_ALLOW_FILE))
rule_result: RuleAction | None = check_rules(all_rules, summary) if all_rules else None
if rule_result == "deny":
notify("Claude: DENIED [rule]", f"{summary}\nhard-denied by ! rule",
urgency="normal", icon="dialog-error", timeout=5000,
sound="dialog-error")
deny(f"Hard-denied by rule for: {summary}")
if rule_result == "ask":
# Matched an exception → fall through to normal permission prompt
sys.exit(0)
# Auto-allow; report which level triggered it
if has_project_allow:
body = f"{summary}\nvia {project_allow_file}" if args.show_via else summary
notify("Claude: ALLOWED [project]", body,
urgency="normal", icon="dialog-warning", timeout=5000,
sound="dialog-warning")
allow(f"Project auto-allow override active for {tool_name}")
else:
body = f"{summary}\nvia {GLOBAL_ALLOW_FILE}" if args.show_via else summary
notify("Claude: ALLOWED [global]", body,
urgency="normal", icon="dialog-warning", timeout=5000,
sound="dialog-warning")
allow(f"Global auto-allow override active for {tool_name}")
# Default: ask the user (normal permission prompt behavior)
sys.exit(0)
if __name__ == "__main__":
main()
#!/bin/bash
# Notification hook: Claude needs permission approval
paplay /usr/share/sounds/freedesktop/stereo/onboard-key-feedback.oga &
{
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "sonnet"
},
"attribution": {
"commit": "",
"pr": ""
},
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.py"
},
"enabledPlugins": {
"dev-browser@dev-browser-marketplace": true,
"commit-commands@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"claude-code-setup@claude-plugins-official": true
},
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "~/.local/bin/claude-request-permission.py"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/permission-prompt-alert.sh"
}
]
},
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/idle-prompt-alert.sh"
}
]
},
{
"matcher": "elicitation_dialog",
"hooks": [
{
"type": "command",
"command": "~/.local/bin/elicitation-dialog.sh"
}
]
}
]
}
}
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
# Ensure UTF-8 output (Python defaults to ASCII when stdout is a pipe)
sys.stdout.reconfigure(encoding='utf-8')
# Read JSON from stdin
data = json.load(sys.stdin)
# --- 1. Extract Data ---
model = data.get("model", {}).get("display_name", "Claude")
context = data.get("context_window") or {}
pct = context.get("used_percentage")
pct_int = int(pct) if pct is not None else None
# Git branch
branch = ""
try:
result = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
branch = result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
# --- 2. Claude Max Usage (cached) ---
CACHE_FILE = os.path.expanduser("~/.claude/.usage_cache.json")
CACHE_TTL = 300 # 5 minutes
CREDS_FILE = os.path.expanduser("~/.claude/.credentials.json")
five_hour_pct = None
seven_day_pct = None
def fetch_usage():
"""Fetch Claude Max usage from API and cache the result."""
try:
with open(CREDS_FILE) as f:
creds = json.load(f)
token = creds["claudeAiOauth"]["accessToken"]
except (FileNotFoundError, KeyError, json.JSONDecodeError):
return None, None
try:
req = urllib.request.Request(
"https://api.anthropic.com/api/oauth/usage",
headers={
"Authorization": f"Bearer {token}",
"anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json",
},
)
with urllib.request.urlopen(req, timeout=3) as resp:
usage = json.loads(resp.read())
five_h = usage.get("five_hour", {}).get("utilization")
seven_d = usage.get("seven_day", {}).get("utilization")
# Write cache
try:
with open(CACHE_FILE, "w") as f:
json.dump({"ts": time.time(), "5h": five_h, "7d": seven_d}, f)
except OSError:
pass
return five_h, seven_d
except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
return None, None
# Try cache first
try:
with open(CACHE_FILE) as f:
cache = json.load(f)
if time.time() - cache["ts"] < CACHE_TTL:
five_hour_pct = cache.get("5h")
seven_day_pct = cache.get("7d")
else:
five_hour_pct, seven_day_pct = fetch_usage()
except (FileNotFoundError, KeyError, json.JSONDecodeError):
five_hour_pct, seven_day_pct = fetch_usage()
# --- 3. Colors ---
reset = "\033[0m"
def usage_color(val):
"""Green < 50%, Yellow 50-75%, Red > 75%."""
if val is None:
return "\033[90m" # Gray for unknown
v = int(val)
if v > 75:
return "\033[31m"
elif v > 50:
return "\033[33m"
return "\033[32m"
# --- 4. Build Status Line ---
status = ""
if branch:
status += f"On \ue0a0 {branch} | "
status += f"{model}"
if pct_int is not None:
color_ctx = usage_color(pct_int)
status += f" | {color_ctx}Ctx: {pct_int}%{reset}"
if five_hour_pct is not None or seven_day_pct is not None:
parts = []
if five_hour_pct is not None:
c = usage_color(five_hour_pct)
parts.append(f"{c}5h: {int(five_hour_pct)}%{reset}")
if seven_day_pct is not None:
c = usage_color(seven_day_pct)
parts.append(f"{c}7d: {int(seven_day_pct)}%{reset}")
status += " | " + " ".join(parts)
print(status)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment