diff --git a/.claude/hooks/check-dangerous-commands.py b/.claude/hooks/check-dangerous-commands.py index 01bc1f0380..b0cfbd3137 100644 --- a/.claude/hooks/check-dangerous-commands.py +++ b/.claude/hooks/check-dangerous-commands.py @@ -9,11 +9,26 @@ import re import os import shlex +from datetime import datetime sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from secrets_patterns import contains_secrets_reference, is_secrets_path +DEBUG = False + + +def _log(detail: str) -> None: + if not DEBUG: + return + try: + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hooks.log") + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(f"{datetime.now().isoformat()} check-dangerous-commands {detail}\n") + except Exception: + pass + + SHELL_OPERATORS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"} @@ -50,6 +65,55 @@ def command_touches_secret(command: str) -> bool: return False +GIT_ASK_PATTERNS = [ + # Force push: match before plain push so we emit the more specific reason + ( + r'\bgit\s+(?:-\S+\s+)*push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)', + "git force-push detected — confirm before proceeding", + ), + ( + r'\bgit\s+(?:-\S+\s+)*push\b', + "git push detected — confirm before proceeding", + ), + ( + r'\bgit\s+(?:-\S+\s+)*commit\b', + "git commit detected — confirm before proceeding", + ), + ( + r'\bgit\s+(?:-\S+\s+)*reset\b[^\n]*\s--hard\b', + "git reset --hard detected — confirm before proceeding", + ), + ( + r'\bgit\s+(?:-\S+\s+)*branch\b[^\n]*\s(?-i:-D)\b', + "git branch -D detected — confirm before proceeding", + ), + ( + r'\bgit\s+(?:-\S+\s+)*(?:checkout\s+-b|switch\s+-[cC]|branch\s+(?!-)\S+)\b', + "git branch creation detected — confirm name before proceeding", + ), + ( + r'\bgh\s+(?:-\S+\s+)*pr\s+create\b', + "gh pr create detected — confirm title/body before proceeding", + ), + ( + r'\bgh\s+(?:-\S+\s+)*pr\s+merge\b', + "gh pr merge detected — confirm before proceeding", + ), + ( + r'\bgh\s+(?:-\S+\s+)*pr\s+close\b', + "gh pr close detected — confirm before proceeding", + ), +] + + +def check_git_for_ask(command: str) -> tuple[bool, str]: + """Returns (should_ask, reason) for git ops that warrant a confirmation prompt.""" + for pattern, reason in GIT_ASK_PATTERNS: + if re.search(pattern, command, re.IGNORECASE): + return True, reason + return False, "" + + def check_command(command: str) -> tuple[bool, str]: """Returns (blocked, reason)""" @@ -110,19 +174,36 @@ def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): + _log("command='' decision=allow reason=unparseable-input") sys.exit(0) # Can't parse input — allow and move on command = data.get("tool_input", {}).get("command", "") if not command: + _log("command='' decision=allow reason=no-command") sys.exit(0) blocked, reason = check_command(command) if blocked: + _log(f"command={command!r} decision=block reason={reason!r}") response = {"decision": "block", "reason": reason} print(json.dumps(response)) sys.exit(2) + ask, ask_reason = check_git_for_ask(command) + if ask: + _log(f"command={command!r} decision=ask reason={ask_reason!r}") + response = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": ask_reason, + } + } + print(json.dumps(response)) + sys.exit(0) + + _log(f"command={command!r} decision=allow") sys.exit(0) diff --git a/.claude/hooks/check-secrets-file.py b/.claude/hooks/check-secrets-file.py index c97bea416d..f1a8087ac3 100644 --- a/.claude/hooks/check-secrets-file.py +++ b/.claude/hooks/check-secrets-file.py @@ -8,11 +8,26 @@ import json import sys import os +from datetime import datetime sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from secrets_patterns import contains_secrets_reference, is_secrets_path, is_secrets_directory +DEBUG = False + + +def _log(detail: str) -> None: + if not DEBUG: + return + try: + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hooks.log") + with open(log_path, "a", encoding="utf-8") as fh: + fh.write(f"{datetime.now().isoformat()} check-secrets-file {detail}\n") + except Exception: + pass + + def iter_candidate_paths(tool_input: dict) -> list[str]: """Collect direct and combined selectors used by file-oriented tools.""" candidates = [] @@ -43,29 +58,36 @@ def main(): try: data = json.load(sys.stdin) except (json.JSONDecodeError, ValueError): + _log("decision=allow reason=unparseable-input") sys.exit(0) tool_input = data.get("tool_input", {}) candidates = iter_candidate_paths(tool_input) if not candidates: + _log("decision=allow reason=no-candidates") sys.exit(0) for file_path in candidates: if is_secrets_path(file_path) or contains_secrets_reference(file_path): + reason = f"Blocked: accessing potential secrets file: {file_path}" + _log(f"candidates={candidates!r} decision=block reason={reason!r}") response = { "decision": "block", - "reason": f"Blocked: accessing potential secrets file: {file_path}" + "reason": reason } print(json.dumps(response)) sys.exit(2) if is_secrets_directory(file_path): + reason = f"Blocked: accessing directory that contains secrets: {file_path}" + _log(f"candidates={candidates!r} decision=block reason={reason!r}") response = { "decision": "block", - "reason": f"Blocked: accessing directory that contains secrets: {file_path}" + "reason": reason } print(json.dumps(response)) sys.exit(2) + _log(f"candidates={candidates!r} decision=allow") sys.exit(0) diff --git a/.claude/hooks/test-hooks.py b/.claude/hooks/test-hooks.py index f22ba0c6a2..df5a29ae0e 100644 --- a/.claude/hooks/test-hooks.py +++ b/.claude/hooks/test-hooks.py @@ -66,8 +66,8 @@ def load_hook_commands(): return commands -def run_hook_test(script_name, tool_input, description, should_block): - """Run a single hook test case.""" +def run_hook_test(script_name, tool_input, description, expected): + """Run a single hook test case. expected is one of 'BLOCK', 'ASK', 'ALLOW'.""" hook_input = json.dumps({"tool_input": tool_input}) script_path = os.path.join(SCRIPT_DIR, script_name) @@ -78,21 +78,28 @@ def run_hook_test(script_name, tool_input, description, should_block): text=True, ) - was_blocked = result.returncode == 2 - passed = was_blocked == should_block - - status = "PASS" if passed else "FAIL" - expected = "BLOCK" if should_block else "ALLOW" - actual = "BLOCK" if was_blocked else "ALLOW" - + actual = "ALLOW" detail = "" - if was_blocked and result.stdout.strip(): + if result.returncode == 2: + actual = "BLOCK" + if result.stdout.strip(): + try: + resp = json.loads(result.stdout.strip()) + detail = f" -- {resp.get('reason', '')}" + except json.JSONDecodeError: + detail = f" -- {result.stdout.strip()}" + elif result.returncode == 0 and result.stdout.strip(): try: resp = json.loads(result.stdout.strip()) - detail = f" -- {resp.get('reason', '')}" + hso = resp.get("hookSpecificOutput") or {} + if hso.get("permissionDecision") == "ask": + actual = "ASK" + detail = f" -- {hso.get('permissionDecisionReason', '')}" except json.JSONDecodeError: - detail = f" -- {result.stdout.strip()}" + pass + passed = actual == expected + status = "PASS" if passed else "FAIL" print(f" [{status}] {description:45s} expected={expected} actual={actual}{detail}") return passed @@ -231,7 +238,63 @@ def tally(result): "check-dangerous-commands.py", {"command": cmd}, desc, - should_block, + "BLOCK" if should_block else "ALLOW", + )) + + # ========================================================================= + print() + print("--- check-dangerous-commands.py git-ask patterns ---") + print() + + GIT_ASK_TESTS = [ + # ASK: git commit variants + ("git commit (bare)", "git commit", "ASK"), + ("git commit -m", "git commit -m 'msg'", "ASK"), + ("git commit -am", "git commit -am 'msg'", "ASK"), + ("git commit --amend", "git commit --amend", "ASK"), + ("git commit --allow-empty", "git commit --allow-empty -m hi", "ASK"), + + # ASK: git push variants + ("git push (bare)", "git push", "ASK"), + ("git push origin main", "git push origin main", "ASK"), + ("git push --force", "git push --force origin main", "ASK"), + ("git push --force-with-lease", "git push --force-with-lease", "ASK"), + ("git push -f", "git push -f origin main", "ASK"), + + # ASK: git reset --hard + ("git reset --hard", "git reset --hard", "ASK"), + ("git reset --hard HEAD~1", "git reset --hard HEAD~1", "ASK"), + ("git reset --hard origin/main", "git reset --hard origin/main", "ASK"), + + # ASK: git branch -D (force delete) + ("git branch -D", "git branch -D feature/foo", "ASK"), + + # ASK: gh pr write actions + ("gh pr create", "gh pr create --title foo --body bar", "ASK"), + ("gh pr merge", "gh pr merge 123 --squash", "ASK"), + ("gh pr close", "gh pr close 123", "ASK"), + + # ALLOW: read-only or non-destructive git/gh ops should pass through + ("git log", "git log --oneline", "ALLOW"), + ("git diff", "git diff HEAD~1", "ALLOW"), + ("git fetch", "git fetch origin", "ALLOW"), + ("git pull", "git pull origin main", "ALLOW"), + ("git branch -d (lowercase, soft delete)", "git branch -d feature/foo", "ALLOW"), + ("git reset --soft", "git reset --soft HEAD~1", "ALLOW"), + ("git reset HEAD~1 (no --hard)", "git reset HEAD~1", "ALLOW"), + ("git stash", "git stash", "ALLOW"), + ("git checkout main", "git checkout main", "ALLOW"), + ("gh pr view", "gh pr view 123", "ALLOW"), + ("gh pr diff", "gh pr diff 123", "ALLOW"), + ("gh pr list", "gh pr list", "ALLOW"), + ] + + for desc, cmd, expected in GIT_ASK_TESTS: + tally(run_hook_test( + "check-dangerous-commands.py", + {"command": cmd}, + desc, + expected, )) hook_commands = load_hook_commands() @@ -328,7 +391,7 @@ def tally(result): "check-secrets-file.py", tool_input, desc, - should_block, + "BLOCK" if should_block else "ALLOW", )) # ========================================================================= diff --git a/.claude/settings.json b/.claude/settings.json index 3434b5ca39..00c33ce4ad 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,18 @@ { + "permissions": { + "ask": [ + "Bash(git push:*)", + "Bash(git commit:*)", + "Bash(git reset --hard:*)", + "Bash(git branch -D:*)", + "Bash(git checkout -b:*)", + "Bash(git switch -c:*)", + "Bash(git switch -C:*)", + "Bash(gh pr create:*)", + "Bash(gh pr merge:*)", + "Bash(gh pr close:*)" + ] + }, "hooks": { "PreToolUse": [ { diff --git a/.gitignore b/.gitignore index 9f220ceeca..9071dba2eb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,11 @@ clientAPIs/* .idea/vcs.xml .idea/sqldialects.xml -# Local Claude settings +# AI files .claude/settings.local.json +.claude/hooks/*.log +.playwright-mcp/ + +# Python bytecode +__pycache__/ +*.pyc \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3960577764..0e2bcc138e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,20 @@ All external library versions are centralized in `gradle.properties` (200+ versi When searching for Java method usages, always include `*.jsp` and `*.jspf` files in addition to `*.java`. JSP files contain inline Java code and are significant callers of API methods (especially anything in `JspBase`). +## Git Branch Naming + +- `develop` — primary development branch (protected; no direct commits). +- `fb_