aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-19 17:14:54 -0500
committerCraig Jennings <c@cjennings.net>2026-04-19 17:14:54 -0500
commit208a079f4230edd520f5aa92288ae48247340910 (patch)
tree236db8e21cd7369c7d0b741c673524464ba9af36
parent4957c60c9ee985628ad59344e593d20a18ca8fdb (diff)
downloadrulesets-208a079f4230edd520f5aa92288ae48247340910.tar.gz
rulesets-208a079f4230edd520f5aa92288ae48247340910.zip
feat(hooks): shared _common.py helpers + systemMessage AI-attribution warning
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.
-rw-r--r--hooks/README.md4
-rw-r--r--hooks/_common.py75
-rwxr-xr-xhooks/gh-pr-create-confirm.py27
-rwxr-xr-xhooks/git-commit-confirm.py25
4 files changed, 103 insertions, 28 deletions
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