diff options
Diffstat (limited to 'hooks')
| -rw-r--r-- | hooks/README.md | 2 | ||||
| -rwxr-xr-x | hooks/git-commit-confirm.py | 81 |
2 files changed, 67 insertions, 16 deletions
diff --git a/hooks/README.md b/hooks/README.md index 5555514..09abe09 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -7,7 +7,7 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | Hook | Trigger | Purpose | |---|---|---| | `precompact-priorities.sh` | `PreCompact` | Injects a priority-preservation block into Claude's compaction prompt so the generated summary retains information most expensive to reconstruct (unanswered questions, root causes with `file:line`, subagent findings, exact numbers/IDs, A-vs-B decisions, open TODOs, classified-data handling). | -| `git-commit-confirm.py` | `PreToolUse(Bash)` | Gates `git commit` behind a confirmation modal showing the parsed message, staged files, diff stats, and git author. Parses both HEREDOC and `-m`/`--message` forms. | +| `git-commit-confirm.py` | `PreToolUse(Bash)` | Silent-unless-suspicious gate on `git commit`. Only prompts when the message contains AI-attribution patterns, the message can't be parsed (editor would open), no files are staged, or the git author is unusable. Clean commits pass through without a modal. Parses both HEREDOC and `-m`/`--message` forms. | | `gh-pr-create-confirm.py` | `PreToolUse(Bash)` | Gates `gh pr create` behind a confirmation modal showing title, base←head, reviewers, labels, assignees, milestone, draft flag, and body (HEREDOC or quoted). | | `destructive-bash-confirm.py` | `PreToolUse(Bash)` | Gates destructive commands (`git push --force`, `git reset --hard`, `git clean -f`, `git branch -D`, `rm -rf`) with a modal showing the command, local context (branch, uncommitted file counts, targeted paths), and a warning banner. Elevates severity when force-pushing protected branches or targeting root/home/wildcard paths. | 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) |
