diff --git a/.claude/hooks/check-dangerous-commands.py b/.claude/hooks/check-dangerous-commands.py index 01bc1f0380..ec427c6ee4 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,84 @@ def command_touches_secret(command: str) -> bool: return False +GIT_ASK_PATTERNS = [ + # Leading gap is [^\n;&|]*? so flags that take a separate-token value (e.g. `git -C `, + # `git -c key=value`) don't bypass the match. The gap is constrained to a single logical + # command (no pipe/semicolon/&&) and non-greedy to keep matches tight. + # Force push: match before plain push so we emit the more specific reason. + ( + r'\bgit\s+[^\n;&|]*?\bpush\b[^\n;&|]*(?:--force\b|--force-with-lease\b|\s-f\b)', + "git force-push detected — confirm before proceeding", + ), + ( + r'\bgit\s+[^\n;&|]*?\bpush\b', + "git push detected — confirm before proceeding", + ), + ( + r'\bgit\s+[^\n;&|]*?\bcommit\b', + "git commit detected — confirm before proceeding", + ), + ( + r'\bgit\s+[^\n;&|]*?\breset\b[^\n;&|]*\s--hard\b', + "git reset --hard detected — confirm before proceeding", + ), + ( + r'\bgit\s+[^\n;&|]*?\bbranch\b[^\n;&|]*\s(?-i:-D)\b', + "git branch -D detected — confirm before proceeding", + ), + ( + r'\bgit\s+[^\n;&|]*?(?:checkout\s+-[bB]|switch\s+(?:-[cC]|--(?:force-)?create)|branch\s+(?:(?!-)\S+|-t|--track))\b', + "git branch creation detected — confirm name before proceeding", + ), + ( + r'\bgh\s+[^\n;&|]*?\bpr\s+create\b', + "gh pr create detected — confirm title/body before proceeding", + ), + ( + r'\bgh\s+[^\n;&|]*?\bpr\s+edit\b', + "gh pr edit detected — confirm title/body before proceeding", + ), + ( + r'\bgh\s+[^\n;&|]*?\bpr\s+merge\b', + "gh pr merge detected — confirm before proceeding", + ), + ( + r'\bgh\s+[^\n;&|]*?\bpr\s+close\b', + "gh pr close detected — confirm before proceeding", + ), +] + + +def check_git_for_ask(command: str) -> tuple[bool, str]: + """Returns (should_ask, joined_reason). Surfaces every distinct op in compound commands. + + For overlapping matches (e.g. the force-push pattern is a superset of the plain-push + pattern), the wider/earlier-listed pattern wins and suppresses the narrower one. + """ + matches = [] # (start, end, reason) + for pattern, reason in GIT_ASK_PATTERNS: + for m in re.finditer(pattern, command, re.IGNORECASE): + matches.append((m.start(), m.end(), reason)) + + # Sort by position; at equal start, prefer the wider span (negative end as tiebreaker). + matches.sort(key=lambda t: (t[0], -t[1])) + + kept_spans = [] + ordered_reasons = [] + seen = set() + for start, end, reason in matches: + if any(ks <= start < ke for ks, ke in kept_spans): + continue + kept_spans.append((start, end)) + if reason not in seen: + seen.add(reason) + ordered_reasons.append(reason) + + if not ordered_reasons: + return False, "" + return True, "; ".join(ordered_reasons) + + def check_command(command: str) -> tuple[bool, str]: """Returns (blocked, reason)""" @@ -110,19 +203,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..c0970e933b 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,89 @@ 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: branch creation/reset variants beyond the basic -b / -c + ("git checkout -B (force create/reset)", "git checkout -B foo", "ASK"), + ("git checkout -B with start point", "git checkout -B foo origin/foo", "ASK"), + ("git switch --create (long form)", "git switch --create foo", "ASK"), + ("git switch --force-create (long force)", "git switch --force-create foo origin/foo", "ASK"), + ("git branch -t (track + create)", "git branch -t newname origin/main", "ASK"), + ("git branch --track (long form)", "git branch --track newname origin/main", "ASK"), + + # ASK: gh pr write actions + ("gh pr create", "gh pr create --title foo --body bar", "ASK"), + ("gh pr edit", "gh pr edit 123 --body foo", "ASK"), + ("gh pr merge", "gh pr merge 123 --squash", "ASK"), + ("gh pr close", "gh pr close 123", "ASK"), + + # ASK: compound commands should surface every matched op + ("compound: commit && push", "git commit -m hi && git push", "ASK"), + ("compound: force-push && commit", "git push --force && git commit -m hi", "ASK"), + + # ASK: dangerous flag on a later command in a compound. The leading git verb still triggers + # ASK via its own pattern (plain push); the regression is that the trailing -f must NOT be + # attributed to the push and reported as a force-push. + ("compound: push then unrelated -f", "git push origin main && gradle test -f", "ASK"), + + # ALLOW: a dangerous-looking flag on an UNRELATED later command must not cross the shell + # separator and false-positive on the leading git verb. Before the [^\n;&|] fix these + # incorrectly matched reset --hard / branch -D. + ("compound: reset HEAD then unrelated --hard", "git reset HEAD && other --hard", "ALLOW"), + ("compound: branch list then unrelated -D", "git branch && other -D", "ALLOW"), + + # 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"), + ("git switch --no-track (no create flag)", "git switch --no-track foo", "ALLOW"), + ("git switch existing branch", "git switch 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 +417,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..9be0120185 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,36 @@ { + "permissions": { + "ask": [ + "Bash(git push:*)", + "Bash(git commit:*)", + "Bash(git reset --hard:*)", + "Bash(git branch -D:*)", + "Bash(git checkout -b:*)", + "Bash(git checkout -B:*)", + "Bash(git switch -c:*)", + "Bash(git switch -C:*)", + "Bash(git switch --create:*)", + "Bash(git switch --force-create:*)", + "Bash(gh pr create:*)", + "Bash(gh pr edit:*)", + "Bash(gh pr merge:*)", + "Bash(gh pr close:*)", + "PowerShell(git push:*)", + "PowerShell(git commit:*)", + "PowerShell(git reset --hard:*)", + "PowerShell(git branch -D:*)", + "PowerShell(git checkout -b:*)", + "PowerShell(git checkout -B:*)", + "PowerShell(git switch -c:*)", + "PowerShell(git switch -C:*)", + "PowerShell(git switch --create:*)", + "PowerShell(git switch --force-create:*)", + "PowerShell(gh pr create:*)", + "PowerShell(gh pr edit:*)", + "PowerShell(gh pr merge:*)", + "PowerShell(gh pr close:*)" + ] + }, "hooks": { "PreToolUse": [ { @@ -10,6 +42,15 @@ } ] }, + { + "matcher": "PowerShell", + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && if command -v python3 >/dev/null 2>&1; then python3 .claude/hooks/check-dangerous-commands.py; else python .claude/hooks/check-dangerous-commands.py; fi" + } + ] + }, { "matcher": "Read|Edit|Write|MultiEdit|Grep|Glob", "hooks": [ @@ -22,4 +63,3 @@ ] } } - 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..d481780b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,32 @@ 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_