#!/bin/sh # Reject pushes that include commits which: # * lack a good signature, or # * have a committer different from this repo's user.name / user.email, or # * have an author that looks like a coding agent (Copilot, Claude, # Codex, ChatGPT, Cursor, Aider, Devin, ...) -- I want my name on # anything I push, even when an agent helped write it. Use # `git commit --amend --reset-author` after agent-assisted work. # # Activated via core.hooksPath in ~/.config/git/config so it applies to # every repo unless that repo overrides hooksPath itself (this dotfiles # repo does, pointing at .githooks/ which delegates here). # # Bypass for one push: git push --no-verify set -eu zero=$(git hash-object --stdin &2 exit 1 fi # %G? signature status codes from git-log: # G good signature # U good, unknown validity (e.g. trust level not set) # X good but expired signature # Y good but key is expiring # R good but key has been revoked # B bad signature # E signature cannot be checked (e.g. missing key) # N no signature # We accept G/U/X/Y and reject anything else. ok='G U X Y' # Case-insensitive substrings that disqualify an author. Matched against # " " lowercased. Plain substring matching (index()) is # used in the awk below to dodge regex-escaping pitfalls. Keep this list # narrow on purpose: false positives are worse than misses (a slipped # commit can be amended and force-pushed; a noisy hook gets disabled). agent_subs='copilot claude codex chatgpt cursor aider devin [bot] @openai. @anthropic.' fail=0 while read -r _local_ref local_sha remote_ref remote_sha; do # Branch deletion: nothing to verify on our side. [ "$local_sha" = "$zero" ] && continue if [ "$remote_sha" = "$zero" ]; then # New branch: verify the commits unique to this push, excluding # anything already reachable from any remote-tracking ref. set -- "$local_sha" --not --remotes else set -- "${remote_sha}..${local_sha}" fi # One pass: per-commit emit # SHA \t %G? \t cn \t ce \t an \t ae \t co-authored-by-list \t subject # Co-authored-by trailers are joined with 0x1f (unit separator) which # cannot appear in identity strings. Tabs aren't valid in identity # fields either, so awk -F'\t' parses unambiguously. bad=$(git rev-list \ --format='%H%x09%G?%x09%cn%x09%ce%x09%an%x09%ae%x09%(trailers:key=Co-authored-by,valueonly,unfold,separator=%x1f)%x09%s' \ --no-commit-header "$@" | awk -F'\t' \ -v ok="$ok" \ -v en="$expected_name" \ -v ee="$expected_email" \ -v agent_subs="$agent_subs" ' BEGIN { split(ok, a, " "); for (i in a) good[a[i]] = 1 n_subs = split(agent_subs, subs, " ") } function is_agent(s, i) { for (i = 1; i <= n_subs; i++) if (index(s, subs[i])) return 1 return 0 } { reasons = "" if (!($2 in good)) reasons = reasons " [sig=" $2 "]" if ($3 != en || $4 != ee) { reasons = reasons " [committer=" $3 " <" $4 ">]" } if (is_agent(tolower($5 " " $6))) { reasons = reasons " [agent-author=" $5 " <" $6 ">]" } if ($7 != "") { n_co = split($7, coauthors, "\037") for (i = 1; i <= n_co; i++) { if (coauthors[i] != "" && is_agent(tolower(coauthors[i]))) { reasons = reasons " [agent-coauthor=" coauthors[i] "]" } } } if (reasons != "") print $1 reasons " " $8 } ') if [ -n "$bad" ]; then if [ "$fail" -eq 0 ]; then printf '\nrefusing to push: bad commits found\n' >&2 printf '(expected committer: %s <%s>)\n' \ "$expected_name" "$expected_email" >&2 fi printf '\non %s:\n%s\n' "$remote_ref" "$bad" >&2 fail=1 fi done if [ "$fail" -ne 0 ]; then printf '\nfix sig + committer:\n' >&2 printf ' git rebase --exec "git commit --amend --no-edit -S" \n' >&2 printf 'fix author (re-stamp yourself as author, keep agent out):\n' >&2 printf ' git rebase --exec "git commit --amend --no-edit --reset-author -S" \n' >&2 printf 'bypass:\n git push --no-verify\n\n' >&2 exit 1 fi exit 0