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