diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-21 09:26:18 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-21 09:26:18 -0500 |
| commit | 36af850dcb4a2edcf9b219c8d00c9e9ba5a06287 (patch) | |
| tree | 103caa36f463fff9f6fd0026a64fced0015a795b /hooks/git-commit-confirm.py | |
| parent | c90683ed477c891e54034de595c97f149c420c17 (diff) | |
| download | rulesets-main.tar.gz rulesets-main.zip | |
The hook used to emit a confirmation modal on every git commit. That
produced too many benign interruptions — the modal fired even on clean,
well-formed, attribution-free commits. Now it only emits a modal when
one of these safety checks fires:
- AI-attribution patterns in the commit message (Co-Authored-By: Claude,
robot emoji, Generated-with-AI footers, etc.) — the primary leak
- Message not parseable from command line (editor would open, which
silently blocks Claude)
- No files staged (the commit would fail anyway)
- Git author unusable (user.name / user.email not configured)
Clean commits pass through silent. The AI-attribution scan is unchanged;
the always-on review is gone.
README updated to describe the new behavior.
Diffstat (limited to 'hooks/git-commit-confirm.py')
| -rwxr-xr-x | hooks/git-commit-confirm.py | 81 |
1 files changed, 66 insertions, 15 deletions
diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py index ad2dd66..2441d23 100755 --- a/hooks/git-commit-confirm.py +++ b/hooks/git-commit-confirm.py @@ -1,13 +1,23 @@ #!/usr/bin/env python3 -"""PreToolUse hook for Bash: gate `git commit` behind a confirmation modal. +"""PreToolUse hook for Bash: silent-unless-suspicious gate on `git commit`. Reads tool-call JSON from stdin. If the Bash command is a `git commit`, -parse the message (HEREDOC or -m forms), list staged files and diff -stats, and emit JSON with permissionDecision=ask and a formatted reason -so the user sees what will actually be committed. +parse the message, run safety checks, and only emit a confirmation modal +when one of them fires: -For non-commit Bash calls, exit 0 with no output — default permission -rules apply. + - AI-attribution patterns in the commit message (Co-Authored-By: Claude, + robot emoji, etc.) — the primary leak we want to catch + - Message could not be parsed from the command line (no -m / HEREDOC; + likely to drop into $EDITOR, which silently blocks Claude) + - Zero staged files (the commit will fail; better to ask why) + - git author unusable (user.name / user.email not configured) + +On a clean, well-formed commit, exit 0 with no output — the commit runs +without a modal. Non-git-commit Bash calls also exit 0 silent. + +Previously this hook asked on every commit; that produced too many benign +modals for Craig's workflow. The attribution-scan safety is preserved; +the always-on review is not. Wire in ~/.claude/settings.json (or per-project .claude/settings.json): @@ -38,6 +48,11 @@ from _common import read_payload, respond_ask, scan_attribution MAX_FILES_SHOWN = 25 MAX_MESSAGE_LINES = 30 +UNPARSEABLE_MESSAGE = ( + "(commit message not parseable from command line; " + "will be edited interactively)" +) + def main() -> int: payload = read_payload() @@ -53,19 +68,50 @@ def main() -> int: stats = get_diff_stats() author = get_author() - reason = format_confirmation(message, staged, stats, author) + issues = collect_issues(message, staged, author) + if not issues: + return 0 # silent pass-through on clean commits - hits = scan_attribution(message) + reason = format_confirmation(message, staged, stats, author, issues) + + attribution_hits = [ + i for i in issues if i.startswith("AI-attribution") + ] system_message = ( - f"WARNING — commit message contains AI-attribution patterns: " - f"{'; '.join(hits)}. Policy forbids AI credit in commits." - if hits else None + f"WARNING — {attribution_hits[0]}. " + "Policy forbids AI credit in commits." + if attribution_hits else None ) respond_ask(reason, system_message=system_message) return 0 +def collect_issues(message: str, staged: list[str], author: str) -> list[str]: + """Return a list of human-readable issues worth asking the user about. + + Empty list → silent pass-through. Any hits → modal. + """ + issues: list[str] = [] + + hits = scan_attribution(message) + if hits: + issues.append("AI-attribution pattern in message: " + "; ".join(hits)) + + if message == UNPARSEABLE_MESSAGE: + issues.append( + "commit message not parseable from command — editor will open" + ) + + if not staged: + issues.append("no staged files — the commit will fail") + + if author.startswith("(") and author.endswith(")"): + issues.append(f"git author unusable: {author}") + + return issues + + def is_git_commit(cmd: str) -> bool: """True if the command invokes `git commit` (possibly with env/cd prefix).""" # Strip leading assignments and subshells; find a `git commit` word boundary @@ -94,7 +140,7 @@ def extract_commit_message(cmd: str) -> str: if long_form: return "\n\n".join(msg for _, msg in long_form).strip() - return "(commit message not parseable from command line; will be edited interactively)" + return UNPARSEABLE_MESSAGE def get_staged_files() -> list[str]: @@ -146,9 +192,14 @@ def get_author() -> str: def format_confirmation( - message: str, files: list[str], stats: str, author: str + message: str, files: list[str], stats: str, author: str, issues: list[str] ) -> str: - lines = ["Create commit?", ""] + lines = ["Create commit? (flagged for review)", ""] + + lines.append("Issues detected:") + for issue in issues: + lines.append(f" ! {issue}") + lines.append("") lines.append("Author:") lines.append(f" {author}") @@ -172,7 +223,7 @@ def format_confirmation( lines.append(f"Stats: {stats}") lines.append("") - lines.append("Confirm the message, author, and file list before proceeding.") + lines.append("Review the issues above before proceeding.") return "\n".join(lines) |
