Skip to content

Commit 89fc305

Browse files
committed
chore(git): block AI/bot authors and co-author trailers
Add shared blocklist + checker, local hooks (commit-msg/pre-push), install-git-hooks.sh, and a CI workflow so Cursor/Claude/Copilot/etc cannot land on main via author, committer, or Co-authored-by.
1 parent 998ce6a commit 89fc305

7 files changed

Lines changed: 419 additions & 0 deletions

File tree

.github/workflows/git-identity.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Guard: no AI/bot git identities on mainline
2+
#
3+
# Blocks commits whose author, committer, or Co-authored-by / Signed-off-by
4+
# trailers match scripts/git-identity-blocklist.txt (Cursor, Claude, Copilot,
5+
# Dependabot-as-author, etc.).
6+
#
7+
# Local companion: ./scripts/install-git-hooks.sh
8+
9+
name: git-identity
10+
11+
on:
12+
push:
13+
branches: [main, master]
14+
pull_request:
15+
branches: [main, master]
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
check:
22+
name: forbid bot authors / trailers
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
30+
- name: Make checker executable
31+
run: chmod +x scripts/check-git-identity.sh
32+
33+
- name: Determine range
34+
id: range
35+
run: |
36+
set -euo pipefail
37+
if [ "${{ github.event_name }}" = "pull_request" ]; then
38+
base="${{ github.event.pull_request.base.sha }}"
39+
head="${{ github.event.pull_request.head.sha }}"
40+
echo "range=${base}..${head}" >> "$GITHUB_OUTPUT"
41+
else
42+
# push to main: check commits introduced by this push
43+
before="${{ github.event.before }}"
44+
after="${{ github.event.after }}"
45+
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
46+
# new branch / first push — check last 50 commits as a bound
47+
echo "range=${after}~50..${after}" >> "$GITHUB_OUTPUT"
48+
else
49+
echo "range=${before}..${after}" >> "$GITHUB_OUTPUT"
50+
fi
51+
fi
52+
53+
- name: Scan commits
54+
run: |
55+
set -euo pipefail
56+
range="${{ steps.range.outputs.range }}"
57+
echo "Scanning $range"
58+
# If range is invalid (shallow/orphan), fall back to HEAD only.
59+
if ! git rev-list "$range" >/dev/null 2>&1; then
60+
echo "Range unresolvable; checking HEAD only"
61+
scripts/check-git-identity.sh --commit HEAD
62+
else
63+
scripts/check-git-identity.sh --range "$range"
64+
fi

scripts/check-git-identity.sh

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
#!/usr/bin/env bash
2+
# Shared checker: reject commits whose author/committer/trailers match the
3+
# identity blocklist. Used by local hooks and CI.
4+
#
5+
# Usage:
6+
# check-git-identity.sh --message-file FILE [--author "N <e>"] [--committer "N <e>"]
7+
# check-git-identity.sh --range A..B
8+
# check-git-identity.sh --commit SHA
9+
# check-git-identity.sh --stdin # reads a full commit message from stdin
10+
#
11+
# Exit 0 = clean, 1 = blocked identity found, 2 = usage/setup error.
12+
13+
set -euo pipefail
14+
15+
SCRIPT_DIR=$(CDPATH= cd -P "$(dirname "$0")" && pwd)
16+
ROOT=$(CDPATH= cd -P "$SCRIPT_DIR/.." && pwd)
17+
BLOCKLIST=${KOMA_IDENTITY_BLOCKLIST:-"$SCRIPT_DIR/git-identity-blocklist.txt"}
18+
19+
die() { echo "check-git-identity: $*" >&2; exit 2; }
20+
fail() { echo "check-git-identity: BLOCKED: $*" >&2; exit 1; }
21+
22+
[ -f "$BLOCKLIST" ] || die "blocklist not found: $BLOCKLIST"
23+
24+
# Load patterns (lowercase) into a bash array.
25+
mapfile -t PATTERNS < <(
26+
awk '
27+
/^[[:space:]]*#/ { next }
28+
/^[[:space:]]*$/ { next }
29+
{
30+
line = $0
31+
sub(/^[[:space:]]+/, "", line)
32+
sub(/[[:space:]]+$/, "", line)
33+
if (line != "") print tolower(line)
34+
}
35+
' "$BLOCKLIST"
36+
)
37+
[ "${#PATTERNS[@]}" -gt 0 ] || die "blocklist is empty: $BLOCKLIST"
38+
39+
identity_hits() {
40+
# $1 = label, $2 = free-form identity text
41+
local label=$1
42+
local text
43+
text=$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]')
44+
[ -n "$text" ] || return 0
45+
local p
46+
for p in "${PATTERNS[@]}"; do
47+
case "$text" in
48+
*"$p"*) echo "$label matches blocklist pattern '$p' ← $2" ;;
49+
esac
50+
done
51+
}
52+
53+
check_message_trailers() {
54+
# Scan full commit message for Co-authored-by / Signed-off-by / Reviewed-by.
55+
local msg=$1
56+
local line name_email hits
57+
while IFS= read -r line || [ -n "$line" ]; do
58+
case "$line" in
59+
[Cc][Oo]-[Aa][Uu][Tt][Hh][Oo][Rr][Ee][Dd]-[Bb][Yy]:*|\
60+
[Ss][Ii][Gg][Nn][Ee][Dd]-[Oo][Ff][Ff]-[Bb][Yy]:*|\
61+
[Rr][Ee][Vv][Ii][Ee][Ww][Ee][Dd]-[Bb][Yy]:*|\
62+
[Aa][Cc][Kk][Nn][Oo][Ww][Ll][Ee][Dd][Gg][Ee][Dd]-[Bb][Yy]:*)
63+
name_email=${line#*:}
64+
name_email=${name_email## }
65+
hits=$(identity_hits "trailer '$line'" "$name_email" || true)
66+
if [ -n "${hits:-}" ]; then
67+
printf '%s\n' "$hits"
68+
fi
69+
;;
70+
esac
71+
done <<EOF
72+
$msg
73+
EOF
74+
}
75+
76+
check_one_commit() {
77+
local sha=$1
78+
local author committer msg hits
79+
author=$(git -C "$ROOT" log -1 --format='%an <%ae>' "$sha")
80+
committer=$(git -C "$ROOT" log -1 --format='%cn <%ce>' "$sha")
81+
msg=$(git -C "$ROOT" log -1 --format='%B' "$sha")
82+
hits=$(
83+
{
84+
identity_hits "author" "$author"
85+
identity_hits "committer" "$committer"
86+
check_message_trailers "$msg"
87+
} | sed '/^$/d'
88+
)
89+
if [ -n "${hits:-}" ]; then
90+
echo "commit $sha:"
91+
printf '%s\n' "$hits" | sed 's/^/ /'
92+
return 1
93+
fi
94+
return 0
95+
}
96+
97+
MODE=""
98+
MSG_FILE=""
99+
AUTHOR=""
100+
COMMITTER=""
101+
RANGE=""
102+
COMMIT=""
103+
104+
while [ "$#" -gt 0 ]; do
105+
case "$1" in
106+
--message-file) MSG_FILE=$2; MODE=message; shift 2 ;;
107+
--author) AUTHOR=$2; shift 2 ;;
108+
--committer) COMMITTER=$2; shift 2 ;;
109+
--range) RANGE=$2; MODE=range; shift 2 ;;
110+
--commit) COMMIT=$2; MODE=commit; shift 2 ;;
111+
--stdin) MODE=stdin; shift ;;
112+
-h|--help)
113+
sed -n '1,20p' "$0"
114+
exit 0
115+
;;
116+
*) die "unknown arg: $1" ;;
117+
esac
118+
done
119+
120+
[ -n "$MODE" ] || die "specify --message-file, --range, --commit, or --stdin"
121+
122+
case "$MODE" in
123+
message|stdin)
124+
if [ "$MODE" = message ]; then
125+
[ -n "$MSG_FILE" ] && [ -f "$MSG_FILE" ] || die "--message-file missing"
126+
MSG=$(cat "$MSG_FILE")
127+
else
128+
MSG=$(cat)
129+
fi
130+
# Resolve author/committer: explicit flag > git env > git config.
131+
if [ -z "$AUTHOR" ] || [ -z "${AUTHOR// }" ] || [ "$AUTHOR" = " <>" ]; then
132+
an=${GIT_AUTHOR_NAME:-$(git -C "$ROOT" config user.name 2>/dev/null || true)}
133+
ae=${GIT_AUTHOR_EMAIL:-$(git -C "$ROOT" config user.email 2>/dev/null || true)}
134+
AUTHOR="$an <$ae>"
135+
fi
136+
if [ -z "$COMMITTER" ] || [ -z "${COMMITTER// }" ] || [ "$COMMITTER" = " <>" ]; then
137+
cn=${GIT_COMMITTER_NAME:-$(git -C "$ROOT" config user.name 2>/dev/null || true)}
138+
ce=${GIT_COMMITTER_EMAIL:-$(git -C "$ROOT" config user.email 2>/dev/null || true)}
139+
COMMITTER="$cn <$ce>"
140+
fi
141+
HITS=$(
142+
{
143+
identity_hits "author" "$AUTHOR"
144+
identity_hits "committer" "$COMMITTER"
145+
check_message_trailers "$MSG"
146+
} | sed '/^$/d'
147+
)
148+
if [ -n "${HITS:-}" ]; then
149+
echo "check-git-identity: blocked identity in pending commit:" >&2
150+
printf '%s\n' "$HITS" | sed 's/^/ /' >&2
151+
echo "Remove the bot trailer/author or re-commit as a human." >&2
152+
echo "Blocklist: $BLOCKLIST" >&2
153+
exit 1
154+
fi
155+
;;
156+
commit)
157+
[ -n "$COMMIT" ] || die "--commit needs a SHA"
158+
if ! check_one_commit "$COMMIT"; then
159+
fail "forbidden identity in $COMMIT"
160+
fi
161+
;;
162+
range)
163+
[ -n "$RANGE" ] || die "--range needs A..B"
164+
# Empty range (e.g. PR with no commits) is fine.
165+
SHAS=$(git -C "$ROOT" rev-list "$RANGE" 2>/dev/null || true)
166+
BAD=0
167+
for sha in $SHAS; do
168+
if ! check_one_commit "$sha"; then
169+
BAD=1
170+
fi
171+
done
172+
if [ "$BAD" -ne 0 ]; then
173+
fail "forbidden identity in range $RANGE"
174+
fi
175+
;;
176+
esac
177+
178+
exit 0

scripts/git-identity-blocklist.txt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Forbidden git identities (authors, committers, Co-authored-by / Signed-off-by).
2+
# One pattern per line. Matching is case-insensitive substring against
3+
# "Name <email>" (and bare name/email fields).
4+
# Lines starting with # and blank lines are ignored.
5+
#
6+
# Keep this list mean and specific — false positives hurt more than misses
7+
# once CI is the backstop. Prefer email domains and known bot handles.
8+
9+
# Cursor
10+
cursoragent
11+
cursoragent@cursor.com
12+
cursor.com
13+
noreply@cursor.com
14+
15+
# Claude / Anthropic tooling
16+
claude@
17+
claude code
18+
anthropic.com
19+
noreply@anthropic.com
20+
21+
# OpenAI / Codex / ChatGPT agent
22+
openai.com
23+
chatgpt
24+
@openai.com
25+
codex@
26+
27+
# GitHub Copilot / bot accounts
28+
copilot
29+
users.noreply.github.com+copilot
30+
github-copilot
31+
copilot-swe-agent
32+
33+
# Google / Gemini agent surfaces
34+
gemini-code
35+
gemini@
36+
bard@
37+
38+
# Other coding agents
39+
windsurf
40+
codeium
41+
amp-agent
42+
aider@
43+
devtools-bot
44+
devin-ai
45+
swe-agent
46+
openhands
47+
gpt-engineer
48+
49+
# Generic CI bot authors that should never be primary authors on main
50+
github-actions[bot]
51+
github-actions@github.com
52+
dependabot[bot]
53+
dependabot@
54+
renovate[bot]
55+
renovate@
56+
greenkeeper[bot]
57+
imgbot[bot]
58+
allcontributors[bot]

scripts/githooks/commit-msg

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
# commit-msg — block forbidden authors / Co-authored-by trailers at commit time.
3+
set -euo pipefail
4+
MSG_FILE=${1:-}
5+
[ -n "$MSG_FILE" ] || exit 0
6+
7+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
8+
CHECK="$REPO_ROOT/scripts/check-git-identity.sh"
9+
[ -f "$CHECK" ] || exit 0
10+
11+
AUTHOR="${GIT_AUTHOR_NAME:-} <${GIT_AUTHOR_EMAIL:-}>"
12+
COMMITTER="${GIT_COMMITTER_NAME:-} <${GIT_COMMITTER_EMAIL:-}>"
13+
14+
exec bash "$CHECK" --message-file "$MSG_FILE" --author "$AUTHOR" --committer "$COMMITTER"

scripts/githooks/pre-push

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
# pre-push — reject pushes that introduce blocked identities.
3+
set -euo pipefail
4+
5+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
6+
CHECK="$REPO_ROOT/scripts/check-git-identity.sh"
7+
[ -f "$CHECK" ] || exit 0
8+
9+
zero=0000000000000000000000000000000000000000
10+
status=0
11+
12+
while read -r local_ref local_sha remote_ref remote_sha; do
13+
[ -n "${local_sha:-}" ] || continue
14+
case "$local_sha" in
15+
"$zero") continue ;; # delete
16+
esac
17+
18+
if [ "$remote_sha" = "$zero" ]; then
19+
if git rev-parse --verify origin/main >/dev/null 2>&1; then
20+
range="origin/main..$local_sha"
21+
else
22+
range=$local_sha
23+
fi
24+
else
25+
range="$remote_sha..$local_sha"
26+
fi
27+
28+
if ! bash "$CHECK" --range "$range"; then
29+
echo "pre-push: blocked push of $local_ref (forbidden identity in $range)" >&2
30+
status=1
31+
fi
32+
done
33+
34+
exit $status

scripts/githooks/update

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env bash
2+
# update — block forbidden identities when rewriting history / amending via push receive.
3+
# (Local clones rarely invoke this; kept for completeness / server-side hookPath reuse.)
4+
set -euo pipefail
5+
6+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
7+
CHECK="$REPO_ROOT/scripts/check-git-identity.sh"
8+
[ -f "$CHECK" ] || exit 0
9+
10+
while read -r _oldsha newsha refname; do
11+
[ -n "${newsha:-}" ] || continue
12+
case "$newsha" in
13+
0000000000000000000000000000000000000000) continue ;;
14+
esac
15+
if ! bash "$CHECK" --commit "$newsha"; then
16+
echo "update hook: refused $refname$newsha (forbidden identity)" >&2
17+
exit 1
18+
fi
19+
done
20+
exit 0

0 commit comments

Comments
 (0)