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
124 changes: 124 additions & 0 deletions .claude/hooks/check-dangerous-commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,35 @@
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


# Mask the password segment of basic-auth URLs (e.g. https://user:TOKEN@host) before logging.
# Only applies to scheme://user:pass@ forms; SSH-style git@host:path URLs have no password to scrub.
_CRED_URL_RE = re.compile(r'(://[^/:\s]+:)[^@\s]+(@)')


def _safe_command(command: str) -> str:
return _CRED_URL_RE.sub(r'\1***\2', command)


SHELL_OPERATORS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}


Expand Down Expand Up @@ -50,6 +74,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 <path>`,
# `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|-[mMcC]|--(?:move|copy)))\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)"""

Expand Down Expand Up @@ -110,19 +212,41 @@ 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:
# Block-on-secret reasons reference the secret path that triggered the match; logging
# the full command would re-emit that path. Drop the command for those cases.
if "secrets file" in reason or "secrets" in reason:
_log(f"decision=block reason={reason!r} (command omitted)")
else:
_log(f"command={_safe_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={_safe_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={_safe_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
123 changes: 109 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,95 @@ 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"),
("git branch -m (rename)", "git branch -m oldname newname", "ASK"),
("git branch -M (force rename)", "git branch -M oldname newname", "ASK"),
("git branch -c (copy)", "git branch -c oldname newname", "ASK"),
("git branch -C (force copy)", "git branch -C oldname newname", "ASK"),
("git branch --move (long rename)", "git branch --move oldname newname", "ASK"),
("git branch --copy (long copy)", "git branch --copy oldname newname", "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()
Expand Down Expand Up @@ -328,7 +423,7 @@ def tally(result):
"check-secrets-file.py",
tool_input,
desc,
should_block,
"BLOCK" if should_block else "ALLOW",
))

# =========================================================================
Expand Down
Loading