Last active
February 11, 2026 14:40
-
-
Save vroomfondel/ba4f08de097e3726e1d88eaea77e3501 to your computer and use it in GitHub Desktop.
Claude Code settings + hook scripts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Notification hook: Claude needs permission approval | |
| paplay /usr/share/sounds/freedesktop/stereo/onboard-key-feedback.oga & |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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