bug#81520: Add pre-receive hook to Savannah to reject LLM-encumbered commits
Jim Porter <[email protected]> Thu, 30 Jul 2026 17:06:57 -0700
| Newsgroups | gmane.emacs.bugs |
|---|---|
| Message-ID | <[email protected]> |
On 7/30/2026 10:36 AM, Sean Whitton wrote: > We explicitly permit it in CONTRIBUTE. > > So yeah, let's match on e-mail addresses. Ok, here's a patch series to do this locally. The first patch is just cleaning up the existing pre-push hook to make it easier to work with, plus using some better-suited Git commands to get the SHAs that are about to be pushed. After thinking over my options here, a pre-push hook seemed better than commit-msg for two reasons: 1. We're only trying to prevent LLM-assisted commits from ending up on Savannah. Any such patches that the user keeps locally won't affect us. (Technically, this hook prevents pushing LLM-assisted commits anywhere, but I don't think it's worth trying to account for the case where someone wants to push such patches to another remote we don't control.) 2. LLM agents tend to try all possibilities - even bad ones[1] - to resolve failed commands. If we checked this in the commit-msg hook, an LLM that vibecoded a patch might well see the error from the hook and "fix" the problem by simply deleting the attribution, even without the user being aware. [1] I've heard tales of LLMs using Docker to copy files to root-controlled directories without having been granted root permissions.
0001-Simplify-commit-msg-file-hook.patch
(text/plain, 4.4 KB)
From 40e2f9bbf60d348484dc1251a97bfd88cfe1ccc4 Mon Sep 17 00:00:00 2001 From: Jim Porter <[email protected]> Date: Thu, 30 Jul 2026 14:31:45 -0700 Subject: [PATCH 1/2] Simplify commit msg file hook * build-aux/git-hooks/commit-msg-files.awk (get_commit_changes): Use "git diff-tree" to simplify parsing of touched files. * build-aux/git-hooks/pre-push: Use a simpler invocation of "git rev-list" to get all commits to be pushed. --- build-aux/git-hooks/commit-msg-files.awk | 20 ++++---- build-aux/git-hooks/pre-push | 60 ++++++------------------ 2 files changed, 23 insertions(+), 57 deletions(-) diff --git a/build-aux/git-hooks/commit-msg-files.awk b/build-aux/git-hooks/commit-msg-files.awk index 852488b551e..52df7b746ce 100644 --- a/build-aux/git-hooks/commit-msg-files.awk +++ b/build-aux/git-hooks/commit-msg-files.awk @@ -30,20 +30,16 @@ ### Code: -function get_commit_changes(commit_sha, changes, cmd, i, j, len, \ +function get_commit_changes(commit_sha, changes, cmd, i, len, \ bits, filename) { - # Collect all the files touched in the specified commit. - cmd = ("git show --name-status --first-parent --format= " commit_sha) + # Collect all the files and their parent directories touched in the + # specified commit. + cmd = ("git diff-tree --no-commit-id --name-only -r " commit_sha) while ((cmd | getline) > 0) { - for (i = 2; i <= NF; i++) { - len = split($i, bits, "/") - for (j = 1; j <= len; j++) { - if (j == 1) - filename = bits[j] - else - filename = filename "/" bits[j] - changes[filename] = 1 - } + len = split($0, bits, "/") + for (i = 1; i <= len; i++) { + filename = i == 1 ? bits[i] : filename "/" bits[i] + changes[filename] = 1 } } close(cmd) diff --git a/build-aux/git-hooks/pre-push b/build-aux/git-hooks/pre-push index 81ba85697c3..009cab73157 100755 --- a/build-aux/git-hooks/pre-push +++ b/build-aux/git-hooks/pre-push @@ -32,6 +32,8 @@ ### Code: HOOKS_DIR=`dirname "$0"` +REMOTE_NAME="$1" +NULL_SHA="0000000000000000000000000000000000000000" # Prefer gawk if available, as it handles NUL bytes properly. if type gawk >/dev/null 2>&1; then @@ -40,49 +42,17 @@ else awk="awk" fi -# Standard input receives lines of the form: -# <local ref> SP <local sha> SP <remote ref> SP <remote sha> LF -$awk -v origin_name="$1" ' - # If the local SHA is all zeroes, ignore it. - $2 ~ /^0{40}$/ { - next - } +while read local_ref local_sha remote_ref remote_sha; do + if [ "$local_sha" = $NULL_SHA ]; then # Deleted the ref. + continue + fi - # Check any lines with a valid local SHA and whose remote ref is - # master or an emacs-NN release branch. (We want to avoid checking - # feature or scratch branches here.) - $2 ~ /^[a-z0-9]{40}$/ && $3 ~ /^refs\/heads\/(master|emacs-[0-9]+)$/ { - newref = $2 - # If the remote SHA is all zeroes, this is a new object to be - # pushed (likely a branch)... - if ($4 ~ /^0{40}$/) { - back = 0 - # ... Go backwards until we find a SHA on an origin branch. - # Stop trying after 1000 commits, just in case... - for (back = 0; back < 1000; back++) { - cmd = ("git branch -r -l '\''" origin_name "/*'\''" \ - " --contains " newref "~" back) - rv = (cmd | getline) - close(cmd) - if (rv > 0) - break; - } - - cmd = ("git rev-parse " newref "~" back) - cmd | getline oldref - if (!(oldref ~ /^[a-z0-9]{40}$/)) { - # The SHA is misformatted! Skip this line. - next - } - close(cmd) - } else if ($4 ~ /^[a-z0-9]{40}$/) { - oldref = $4 - } else { - # The SHA is misformatted! Skip this line. - next - } - - # Print every SHA after oldref, up to (and including) newref. - system("git rev-list --first-parent --reverse " oldref ".." newref) - } -' | $awk -v reason=pre-push -f "$HOOKS_DIR"/commit-msg-files.awk + # Check any lines whose remote ref is master or an emacs-NN release + # branch. (We want to avoid checking feature or scratch branches + # here.) + if echo "$remote_ref" | grep -Eq "^refs/heads/(master|emacs-[0-9]+)$"; then + # List the SHAs of all commits to be pushed to the remote for the + # current local SHA. + git rev-list "$local_sha" --not --remotes="$REMOTE_NAME" --reverse + fi +done | $awk -v reason=pre-push -f "$HOOKS_DIR"/commit-msg-files.awk -- 2.25.1
0002-Check-commits-for-LLM-attributions-as-a-pre-push-hoo.patch
(text/plain, 7.5 KB)
From 7130e800aef3be73f5824b416914398bf9e7e245 Mon Sep 17 00:00:00 2001 From: Jim Porter <[email protected]> Date: Thu, 30 Jul 2026 16:23:41 -0700 Subject: [PATCH 2/2] Check commits for LLM attributions as a pre-push hook * build-aux/git-hooks/commit-msg-files.awk: Accept 'verbose' variable directly from the command line, and update callers to print final summary messages. * build-aux/git-hooks/pre-push: Add call to check LLM attributions. (get_outgoing_shas): New function. * build-aux/git-hooks/llm-attribution.awk: New file. --- autogen.sh | 2 +- build-aux/git-hooks/commit-msg-files.awk | 15 +---- build-aux/git-hooks/llm-attribution.awk | 82 ++++++++++++++++++++++++ build-aux/git-hooks/post-commit | 6 +- build-aux/git-hooks/pre-push | 32 +++++++-- 5 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 build-aux/git-hooks/llm-attribution.awk diff --git a/autogen.sh b/autogen.sh index a61d74b981d..2537ed1a8e8 100755 --- a/autogen.sh +++ b/autogen.sh @@ -370,7 +370,7 @@ tailored_hooks= sample_hooks= for hook in commit-msg pre-commit prepare-commit-msg post-commit \ - pre-push commit-msg-files.awk; do + pre-push commit-msg-files.awk llm-attribution.awk; do cmp -- build-aux/git-hooks/$hook "$hooks/$hook" >/dev/null 2>&1 || tailored_hooks="$tailored_hooks $hook" done diff --git a/build-aux/git-hooks/commit-msg-files.awk b/build-aux/git-hooks/commit-msg-files.awk index 52df7b746ce..e4fc83c6920 100644 --- a/build-aux/git-hooks/commit-msg-files.awk +++ b/build-aux/git-hooks/commit-msg-files.awk @@ -101,24 +101,11 @@ function check_commit_msg_files(commit_sha, verbose, changes, good, \ return good } -BEGIN { - if (reason == "pre-push") - verbose = 1 -} - /^[a-z0-9]{40}$/ { - if (! check_commit_msg_files($0, verbose)) { + if (! check_commit_msg_files($0, verbose)) status = 1 - } } END { - if (status != 0) { - if (reason == "pre-push") - error_msg = "Push aborted" - else - error_msg = "Bad commit message" - printf("%s; please see the file 'CONTRIBUTE'\n", error_msg) - } exit status } diff --git a/build-aux/git-hooks/llm-attribution.awk b/build-aux/git-hooks/llm-attribution.awk new file mode 100644 index 00000000000..3e96e536ec2 --- /dev/null +++ b/build-aux/git-hooks/llm-attribution.awk @@ -0,0 +1,82 @@ +# Check the commit messages for each SHA to reject LLM-attributed changes. + +# Copyright 2026 Free Software Foundation, Inc. + +# This file is part of GNU Emacs. + +# GNU Emacs is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# GNU Emacs is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>. + +### Commentary: + +# This script accepts a list of (unabbreviated) Git commit SHAs, and +# will then iterate over them to check that the commit messages don't +# contain any LLM attributions. If they do, it will print out the +# invalid commits as well as the LLM attribution we found. + +# Specifically, this checks that none of the author, committer, or the +# Co-Authored-By or Co-Developed-By fields contain an email address +# associated with an LLM. Additionally, it rejects all commits with +# Assisted-By or AI-Assisted fields. + +### Code: + +BEGIN { + LLM_EMAILS[1] = "[email protected]" + LLM_EMAILS[2] = "[email protected]" + LLM_EMAILS[3] = "[email protected]" +} + +function check_email(email) { + # Returns non-zero if the specified email is an LLM-associated + # address. This matches either the exact email address or the address + # contained somewhere in the string between angle brackets. + for (i in LLM_EMAILS) { + if (email == LLM_EMAILS[i] || + index(email, ("<" LLM_EMAILS[i] ">"))) + return 1 + } + return 0 +} + +function check_commit(commit_sha, lowered) { + # Returns non-zero if the commit contains any LLM attributions. + bad = 0 + cmd = ("git show -s --format='Author: %ae%nCommitter: %ce%n%B' " \ + commit_sha) + while ((cmd | getline) > 0) { + lowered = tolower($0) + if ((match(lowered, /^(author|committer|co-(authored|developed)-by):[[:space:]]*/) && + check_email(substr($0, RLENGTH+1))) || + lowered ~ /^(assisted-by|ai-assisted):$/) { + if (! bad) { + ("git show -s --format='%s' " commit_sha) | getline msg + printf("In commit %s \"%s\"...\n", substr(commit_sha, 1, 10), msg) + printf("LLM attributions detected:\n") + } + printf(" %s\n", $0) + bad = 1 + } + } + close(cmd) + return bad +} + +/^[a-z0-9]{40}$/ { + if (check_commit($0)) + status = 1 +} + +END { + exit status +} diff --git a/build-aux/git-hooks/post-commit b/build-aux/git-hooks/post-commit index 73fd5b28780..cc02d4df719 100755 --- a/build-aux/git-hooks/post-commit +++ b/build-aux/git-hooks/post-commit @@ -43,5 +43,7 @@ else awk="awk" fi -git rev-parse HEAD | $awk -v reason=post-commit \ - -f "$HOOKS_DIR"/commit-msg-files.awk +if ! git rev-parse HEAD | $awk -f "$HOOKS_DIR"/commit-msg-files.awk; then + # It's too late to abort the commit, so just print a warning. + echo "Bad commit message; please see the file 'CONTRIBUTE'" +fi diff --git a/build-aux/git-hooks/pre-push b/build-aux/git-hooks/pre-push index 009cab73157..e9444cbc943 100755 --- a/build-aux/git-hooks/pre-push +++ b/build-aux/git-hooks/pre-push @@ -35,6 +35,12 @@ HOOKS_DIR=`dirname "$0"` REMOTE_NAME="$1" NULL_SHA="0000000000000000000000000000000000000000" +# List the SHAs of all commits to be pushed to the remote for the +# specified SHA. +get_outgoing_shas() { + git rev-list "$1" --not --remotes="$REMOTE_NAME" --reverse +} + # Prefer gawk if available, as it handles NUL bytes properly. if type gawk >/dev/null 2>&1; then awk="gawk" @@ -42,17 +48,29 @@ else awk="awk" fi +result=0 while read local_ref local_sha remote_ref remote_sha; do if [ "$local_sha" = $NULL_SHA ]; then # Deleted the ref. continue fi - # Check any lines whose remote ref is master or an emacs-NN release - # branch. (We want to avoid checking feature or scratch branches - # here.) + # Check the file names in the commit messages being pushed to master + # or an emacs-NN release branch. (We want to avoid checking feature + # or scratch branches here.) if echo "$remote_ref" | grep -Eq "^refs/heads/(master|emacs-[0-9]+)$"; then - # List the SHAs of all commits to be pushed to the remote for the - # current local SHA. - git rev-list "$local_sha" --not --remotes="$REMOTE_NAME" --reverse + get_outgoing_shas "$local_sha" | \ + $awk -v verbose=1 -f "$HOOKS_DIR"/commit-msg-files.awk + result=$(expr $result "|" $?) fi -done | $awk -v reason=pre-push -f "$HOOKS_DIR"/commit-msg-files.awk + + # Check for LLM attributions in all outgoing commits. We do this in a + # separate pass from above so that the output is kept separate. + get_outgoing_shas "$local_sha" | \ + $awk -f "$HOOKS_DIR"/llm-attribution.awk + result=$(expr $result "|" $?) +done + +if [ $result -ne 0 ]; then + echo "Push aborted; please see the file 'CONTRIBUTE'\n" +fi +exit $result -- 2.25.1