From a4d0b6260d6f4961e25d385a537c1e676827cc37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 21:55:51 +0000 Subject: [PATCH 1/3] chore: strip Claude attribution from commits via a checked-in commit-msg hook Vendors the commit-cleaner hook from jacobrakaiFoundation/.github under .githooks/, and adds .claude/settings.json so Claude Code sessions activate it on start and block --no-verify and hooksPath bypasses. Re-includes .claude/settings.json, .claude/hooks/ and .githooks/ in .gitignore; personal Claude state stays untracked. Local contributors run: git config core.hooksPath .githooks --- .claude/hooks/commit-guard.py | 138 ++++++++++++++++++++++++++++++++++ .claude/settings.json | 25 ++++++ .githooks/commit-msg | 50 ++++++++++++ .gitignore | 6 ++ 4 files changed, 219 insertions(+) create mode 100755 .claude/hooks/commit-guard.py create mode 100644 .claude/settings.json create mode 100755 .githooks/commit-msg diff --git a/.claude/hooks/commit-guard.py b/.claude/hooks/commit-guard.py new file mode 100755 index 0000000..380a08b --- /dev/null +++ b/.claude/hooks/commit-guard.py @@ -0,0 +1,138 @@ +#!/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..5165e25 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "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/ From f4385ec9afdd3f05ee0c047994f2356e9542d56c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:08:09 +0000 Subject: [PATCH 2/3] chore: turn off Claude Code commit attribution via settings Sets attribution.commit to an empty string and attribution.sessionUrl to false in .claude/settings.json, the supported way to stop Claude Code writing Co-Authored-By and Claude-Session trailers. Works on every machine the project settings load on, without git hook configuration; the commit-msg hook stays as a backstop. The SessionStart command now cds into CLAUDE_PROJECT_DIR first so it works when the session cwd is not the repository. --- .claude/settings.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5165e25..c38bba0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,11 +1,15 @@ { + "attribution": { + "commit": "", + "sessionUrl": false + }, "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", - "command": "git config core.hooksPath .githooks" + "command": "cd \"${CLAUDE_PROJECT_DIR:-.}\" && git config core.hooksPath .githooks" } ] } From 8d6d12ba655cff2ee6703312d3251116ef519292 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 22:14:37 +0000 Subject: [PATCH 3/3] chore: format commit-guard.py to black/ruff style Keeps repositories that run ruff format --check or black over the whole tree green. No behaviour change. --- .claude/hooks/commit-guard.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/.claude/hooks/commit-guard.py b/.claude/hooks/commit-guard.py index 380a08b..a8b0d59 100755 --- a/.claude/hooks/commit-guard.py +++ b/.claude/hooks/commit-guard.py @@ -16,6 +16,7 @@ {"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 /guard.py"}]}]}} """ + import json import re import shlex @@ -79,8 +80,18 @@ def check(words): if not words or words[0] != "git": # Tampering with the hook file itself. joined = " ".join(words) - if re.search(r"hooks/commit-msg|\.githooks/commit-msg", joined) and words[0] in ( - "rm", "mv", "cp", "chmod", "truncate", "tee", "sed", "install", "ln", + if re.search(r"hooks/commit-msg|\.githooks/commit-msg", joined) and words[ + 0 + ] in ( + "rm", + "mv", + "cp", + "chmod", + "truncate", + "tee", + "sed", + "install", + "ln", ): deny("refusing to modify or remove the commit-msg hook") return @@ -89,14 +100,21 @@ def check(words): opts, i = [], 1 while i < len(words) and words[i].startswith("-"): opts.append(words[i]) - if words[i] in ("-c", "-C", "--git-dir", "--work-tree", "--config-env", "--exec-path"): + if words[i] in ( + "-c", + "-C", + "--git-dir", + "--work-tree", + "--config-env", + "--exec-path", + ): if i + 1 < len(words): opts.append(words[i + 1]) i += 1 i += 1 if i >= len(words): return - sub, args = words[i], words[i + 1:] + 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") @@ -114,7 +132,14 @@ def check(words): 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"): + 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")