Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .claude/hooks/check-dangerous-commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}


Expand Down Expand Up @@ -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)"""

Expand Down Expand Up @@ -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)


Expand Down
26 changes: 24 additions & 2 deletions .claude/hooks/check-secrets-file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)


Expand Down
91 changes: 77 additions & 14 deletions .claude/hooks/test-hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -328,7 +391,7 @@ def tally(result):
"check-secrets-file.py",
tool_input,
desc,
should_block,
"BLOCK" if should_block else "ALLOW",
))

# =========================================================================
Expand Down
14 changes: 14 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<label>_<id>` — feature/bug-fix branch off `develop`. `label` is a short snake_case description (use underscores to separate words, not dashes); `id` is the issue or Scrumwise ID. Omit `_<id>` only when no ID exists (e.g., test fixes); coordinate the label to avoid collisions.
- `XX.Y_fb_<label>_<id>` — feature/bug-fix branch targeting a specific release.
- `releaseXX.Y-SNAPSHOT` — beta release branch (protected); base release-targeted feature branches from it.
- `releaseXX.Y` — final release branch (protected); receives merges from the SNAPSHOT branch only. Patch releases are tagged `XX.Y.Z`.

Use an identical branch name across every repo involved in a story. Branches matching these patterns are built by TeamCity — pick a non-matching name to opt out.

Before creating a branch, always propose the name and confirm it with the user. Do not run `git checkout -b` (or equivalent) until the user approves.

## Pull Request Format

PRs should include sections for: **Rationale** (why the change is needed), **Related Pull Requests**, and **Changes** (notable items).
If the repo has a `pull_request_template.md` (typically under `.github/`), follow it. Otherwise, include sections for: **Rationale** (why the change is needed), **Related Pull Requests**, and **Changes** (notable items). Keep descriptions brief.

Before opening a PR, always draft the title and description and confirm them with the user. Do not run `gh pr create` until the user approves.