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
107 changes: 107 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,81 @@ 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+edit\b',
"gh pr edit 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, 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)"""

Expand Down Expand Up @@ -110,19 +200,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
96 changes: 82 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,68 @@ 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 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"),

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

# =========================================================================
Expand Down
15 changes: 15 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
{
"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 edit:*)",
"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
Loading