From 208a079f4230edd520f5aa92288ae48247340910 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 19 Apr 2026 17:14:54 -0500 Subject: feat(hooks): shared _common.py helpers + systemMessage AI-attribution warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates stdin-parse and response-emit across the two confirm hooks into `hooks/_common.py` (stdlib-only, sibling symlinked alongside the hooks it serves). Net ~28 lines less duplication. Adds a `systemMessage` banner alongside the confirmation modal when the commit message or PR title/body contains AI-attribution patterns: - Co-Authored-By: Claude|Anthropic|GPT|AI trailers - πŸ€– robot emoji - "Generated with Claude Code" / similar footers - "Created with …" / "Assisted by …" variants Scanning targets structural leak patterns only β€” bare mentions of "Claude" or "Anthropic" in diff context don't fire, so discussing the tools themselves in a commit message doesn't false-positive. Clean-room synthesis from GowayLee/cchooks (MIT) β€” specifically, the systemMessage-alongside-reason pattern and event-aware stdin helpers. --- hooks/README.md | 4 +++ hooks/_common.py | 75 +++++++++++++++++++++++++++++++++++++++++++ hooks/gh-pr-create-confirm.py | 27 ++++++++-------- hooks/git-commit-confirm.py | 25 +++++++-------- 4 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 hooks/_common.py diff --git a/hooks/README.md b/hooks/README.md index 847b81b..97dd881 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -10,6 +10,10 @@ Machine-wide Claude Code hooks that install into `~/.claude/hooks/` and apply to | `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. | | `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). | +Shared library (not a hook): `_common.py` β€” `read_payload()`, `respond_ask()`, `scan_attribution()`. Installed as a sibling symlink so the two Python hooks can `from _common import …` at runtime. + +Both confirm hooks emit a `systemMessage` warning alongside the confirmation modal when they detect AI-attribution patterns (`Co-Authored-By: Claude`, πŸ€–, "Generated with Claude Code", etc.) in the commit message or PR title/body β€” useful as an automated policy check for environments where AI credit is forbidden. + ## Install ### One-liner (from this repo) diff --git a/hooks/_common.py b/hooks/_common.py new file mode 100644 index 0000000..d4bf520 --- /dev/null +++ b/hooks/_common.py @@ -0,0 +1,75 @@ +"""Shared helpers for Claude Code PreToolUse confirmation hooks. + +Not a hook itself β€” imported by sibling scripts in ~/.claude/hooks/ +(installed as symlinks by `make install-hooks`). Python resolves imports +relative to the invoked script's directory, so sibling symlinks just work. + +Provides: + read_payload() β†’ dict parsed from stdin (empty dict on failure) + respond_ask(...) β†’ emit a PreToolUse permissionDecision=ask response + scan_attribution() β†’ detect AI-attribution patterns in commit/PR text + +AI-attribution scanning targets structural leak patterns (trailers, +footers, robot emoji) β€” NOT bare mentions of 'Claude' or 'Anthropic', +which are legitimate words and would false-positive on diffs discussing +the tools themselves. +""" + +import json +import re +import sys +from typing import Optional + + +ATTRIBUTION_PATTERNS: list[tuple[str, str]] = [ + (r"Co-Authored-By:\s*(?:Claude|Anthropic|GPT|AI\b|an? LLM)", + "Co-Authored-By trailer crediting an AI"), + (r"πŸ€–", + "robot emoji (πŸ€–)"), + (r"Generated (?:with|by) (?:Claude|Anthropic|AI|an? LLM)", + "'Generated with AI' footer"), + (r"Created (?:with|by) (?:Claude|Anthropic|AI|an? LLM)", + "'Created with AI' footer"), + (r"Assisted by (?:Claude|Anthropic|AI|an? LLM)", + "'Assisted by AI' credit"), + (r"\[\s*(?:Claude|AI|LLM)\s*(?:Code)?\s*\]", + "[Claude] / [AI] bracketed tag"), +] + + +def read_payload() -> dict: + """Parse tool-call JSON from stdin. Return {} on any parse failure.""" + try: + return json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + return {} + + +def respond_ask(reason: str, system_message: Optional[str] = None) -> None: + """Emit a PreToolUse response asking the user to confirm. + + `reason` fills the modal body (permissionDecisionReason). + `system_message`, if set, surfaces a secondary banner/warning to the + user in a slot distinct from the modal. + """ + output: dict = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": reason, + } + } + if system_message: + output["systemMessage"] = system_message + print(json.dumps(output)) + + +def scan_attribution(text: str) -> list[str]: + """Return human-readable descriptions of any AI-attribution hits.""" + if not text: + return [] + hits: list[str] = [] + for pattern, description in ATTRIBUTION_PATTERNS: + if re.search(pattern, text, re.IGNORECASE): + hits.append(description) + return hits diff --git a/hooks/gh-pr-create-confirm.py b/hooks/gh-pr-create-confirm.py index b983352..e3c2f13 100755 --- a/hooks/gh-pr-create-confirm.py +++ b/hooks/gh-pr-create-confirm.py @@ -24,20 +24,17 @@ Wire in ~/.claude/settings.json alongside git-commit-confirm.py: } """ -import json import re import sys +from _common import read_payload, respond_ask, scan_attribution + MAX_BODY_LINES = 20 def main() -> int: - try: - payload = json.loads(sys.stdin.read()) - except (json.JSONDecodeError, ValueError): - return 0 - + payload = read_payload() if payload.get("tool_name") != "Bash": return 0 @@ -48,14 +45,16 @@ def main() -> int: fields = parse_pr_create(cmd) reason = format_pr_confirmation(fields) - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "ask", - "permissionDecisionReason": reason, - } - } - print(json.dumps(output)) + # Scan both title and body β€” PRs leak attribution in either slot. + scan_text = "\n".join(filter(None, [fields.get("title"), fields.get("body")])) + hits = scan_attribution(scan_text) + system_message = ( + f"WARNING β€” PR title/body contains AI-attribution patterns: " + f"{'; '.join(hits)}. Policy forbids AI credit in PRs." + if hits else None + ) + + respond_ask(reason, system_message=system_message) return 0 diff --git a/hooks/git-commit-confirm.py b/hooks/git-commit-confirm.py index bea6410..ad2dd66 100755 --- a/hooks/git-commit-confirm.py +++ b/hooks/git-commit-confirm.py @@ -28,22 +28,19 @@ Wire in ~/.claude/settings.json (or per-project .claude/settings.json): } """ -import json import re import subprocess import sys +from _common import read_payload, respond_ask, scan_attribution + MAX_FILES_SHOWN = 25 MAX_MESSAGE_LINES = 30 def main() -> int: - try: - payload = json.loads(sys.stdin.read()) - except (json.JSONDecodeError, ValueError): - return 0 # malformed; don't block - + payload = read_payload() if payload.get("tool_name") != "Bash": return 0 @@ -58,14 +55,14 @@ def main() -> int: reason = format_confirmation(message, staged, stats, author) - output = { - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "ask", - "permissionDecisionReason": reason, - } - } - print(json.dumps(output)) + hits = scan_attribution(message) + system_message = ( + f"WARNING β€” commit message contains AI-attribution patterns: " + f"{'; '.join(hits)}. Policy forbids AI credit in commits." + if hits else None + ) + + respond_ask(reason, system_message=system_message) return 0 -- cgit v1.2.3