#!/usr/bin/env dash # Reject pushes that include commits which: # * lack a good signature, or # * have an author, committer, or coauthor that looks like a coding # agent (Copilot, Claude, Codex, ChatGPT, Cursor, Aider, Devin, ...). # I want human names on anything I push, even when an agent helped # write it. Use `git commit --amend --reset-author` after # agent-authored 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 # shellcheck source=./_dispatch.sh . "${0%/*}/_dispatch.sh" # Buffer stdin so both the per-repo hook and our own loop below get the # full ref list (git only feeds it once). _stdin_buf=$(mktemp) trap 'rm -f "$_stdin_buf"' EXIT INT TERM cat >"$_stdin_buf" dispatch_repo_hook pre-push "$@" <"$_stdin_buf" zero=$(git hash-object --stdin " 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 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 (is_agent(tolower($3 " " $4))) { reasons = reasons " [agent-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 fi printf '\non %s:\n%s\n' "$remote_ref" "$bad" >&2 fail=1 fi done <"$_stdin_buf" if [ "$fail" -ne 0 ]; then printf '\nfix signature:\n' >&2 printf ' git rebase --exec "git commit --amend --no-edit -S" \n' >&2 printf 'fix AI identity (re-stamp yourself as author/committer, 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