diff --git a/.claude/hooks/commit-guard.py b/.claude/hooks/commit-guard.py new file mode 100755 index 0000000..a8b0d59 --- /dev/null +++ b/.claude/hooks/commit-guard.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Claude Code PreToolUse hook: keep the commit-msg hook from being bypassed. + +Reads the Bash tool call from stdin (Claude Code hook JSON) and exits 2 with a +reason when the command would skip git hooks for a commit. Exit 0 lets the +call through. Nothing here rewrites the command; it only refuses the evasions. + +Blocked, in commands that invoke `git commit`: + --no-verify / -n (and -n inside a flag cluster such as -anm) + -c core.hooksPath=... / --config-env / core.hooksPath overrides + HOME=, GIT_DIR=, GIT_CONFIG_GLOBAL= prefixes that relocate the hooks + git config ... core.hooksPath (changing where hooks live) +Also blocked: writing over .git/hooks/commit-msg or a .githooks/commit-msg. + +Wire it in .claude/settings.json: + {"hooks": {"PreToolUse": [{"matcher": "Bash", + "hooks": [{"type": "command", "command": "python3 /guard.py"}]}]}} +""" + +import json +import re +import shlex +import sys + + +def deny(reason: str) -> None: + # Exit code 2 = block the tool call; stderr is shown to Claude as feedback. + sys.stderr.write(f"commit-cleaner guard: {reason}\n") + sys.exit(2) + + +def segments(cmd: str): + """Split a shell command into simple-command word lists, unwrapping one + layer of `bash -c '...'`, `sh -c`, `eval`, and $(...)/backtick bodies.""" + # Pull out substitution bodies so nested commands are inspected too. + bodies = [cmd] + bodies += re.findall(r"\$\(([^()]*)\)", cmd) + bodies += re.findall(r"`([^`]*)`", cmd) + out = [] + for body in bodies: + for piece in re.split(r"(?= len(words): + return + sub, args = words[i], words[i + 1 :] + + if sub == "config" and any("hookspath" in a.lower() for a in args): + deny("changing core.hooksPath would disable the commit-msg hook") + + if sub != "commit": + return + + for a in args: + if a in ("--no-verify",) or a.startswith("--no-verif"): + deny("`--no-verify` skips the commit-msg hook; commit without it") + if re.match(r"^-[a-zA-Z]*n[a-zA-Z]*$", a): # -n, -an, -anm ... + deny("`-n` (--no-verify) skips the commit-msg hook; commit without it") + for o in opts: + if "hookspath" in o.lower(): + deny("overriding core.hooksPath on the command line skips the hook") + if any(o == "--git-dir" or o.startswith("--git-dir=") for o in opts): + deny("relocating --git-dir for a commit bypasses the repository hooks") + for k in ( + "HOME", + "GIT_DIR", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG_PARAMETERS", + "XDG_CONFIG_HOME", + ): + if k in env: + deny(f"setting {k} for a commit can relocate the hooks; commit without it") + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except Exception: + sys.exit(0) + if payload.get("tool_name") != "Bash": + sys.exit(0) + cmd = (payload.get("tool_input") or {}).get("command") or "" + if "commit" not in cmd and "hooks" not in cmd.lower(): + sys.exit(0) + for words in segments(cmd): + check(list(words)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c38bba0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,29 @@ +{ + "attribution": { + "commit": "", + "sessionUrl": false + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && git config core.hooksPath .githooks" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/commit-guard.py" + } + ] + } + ] + } +} diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..dbad6dc --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,50 @@ +#!/bin/sh +# commit-msg hook: strip Claude Code attribution from the commit message. +# +# Removes, wherever they appear in the message: +# Co-Authored-By: Claude ... <...@anthropic.com> +# Claude-Session: https://claude.ai/code/session_... +# Generated with [Claude Code](...) (with or without the robot emoji) +# bare https://claude.ai/code/session_... lines +# +# Ordinary Co-Authored-By trailers for human collaborators are kept. A message +# with nothing to strip is left byte-identical. Trailing blank lines left +# behind by the removal are trimmed; git's own cleanup collapses the rest. +# +# Installed by tools/commit-cleaner/install.sh (see README.md). Safe under +# `set -e`: every failure path exits 0 with the original message intact, so +# the hook can never block a commit. + +msg_file="$1" +[ -n "$msg_file" ] && [ -f "$msg_file" ] || exit 0 + +tmp="${msg_file}.commit-cleaner.$$" +trap 'rm -f "$tmp"' EXIT + +awk ' +function is_attribution(line) { + l = tolower(line) + if (l ~ /^co-authored-by:[[:space:]]*.*<[^>]*@anthropic\.com>[[:space:]]*$/) return 1 + if (l ~ /^co-authored-by:[[:space:]]*claude([[:space:]]|$)/) return 1 + if (l ~ /^claude-session:/) return 1 + if (l ~ /generated with \[claude code\]\(/) return 1 + if (l ~ /^[[:space:]]*https:\/\/claude\.ai\/code\/session_[a-z0-9_-]+[[:space:]]*$/) return 1 + return 0 +} +{ + if (is_attribution($0)) { removed = 1; next } + lines[++n] = $0 +} +END { + if (!removed) exit 3 # nothing to do: caller keeps the file as-is + while (n > 0 && lines[n] ~ /^[[:space:]]*$/) n-- + for (i = 1; i <= n; i++) print lines[i] +} +' "$msg_file" > "$tmp" +status=$? + +case "$status" in + 0) cat "$tmp" > "$msg_file" ;; # rewrite in place, preserving the inode + *) : ;; # 3 = unchanged; anything else = leave alone +esac +exit 0 diff --git a/.gitignore b/.gitignore index 4f7c329..8e1dc4d 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,9 @@ validation_screenshots/ *.sqlite *.sqlite3 + +# Claude Code: track the shared settings and hooks; personal state stays local. +!.claude/ +.claude/* +!.claude/settings.json +!.claude/hooks/