Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
#
# commit-msg: refuse a message that carries an AI-attribution line (tools/ai-trace-commit-check.sh;
# the patterns are tools/ai-trace-patterns.json). Enable once per clone:
# git config core.hooksPath .githooks
root=$(git rev-parse --show-toplevel) || exit 0
exec "$root/tools/ai-trace-commit-check.sh" --msg-file "$1"
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,20 @@ jobs:
with:
publish: false
arm: false # aarch64/arm64 build at release only

# A commit message is published like a file. A pull request's own commits must carry no
# AI-attribution line (tools/ai-trace-commit-check.sh); what is already on the base is not read.
ai-trace:
name: commit messages carry no AI attribution
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: The checker catches a fixture and goes blind when its patterns are removed
run: tools/ai-trace-commit-check.sh --selftest
- name: Scan the commits of the pull request
run: tools/ai-trace-commit-check.sh origin/${{ github.event.pull_request.base.ref }}..HEAD
114 changes: 114 additions & 0 deletions tools/ai-trace-commit-check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
#
# ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line.
#
# ai-trace-commit-check.sh <range> every commit in the range (e.g. origin/main..HEAD)
# ai-trace-commit-check.sh --msg-file FILE one message (the commit-msg hook; # lines are git comments, skipped)
# ai-trace-commit-check.sh --text-file FILE any text, every line judged (pull request titles and bodies)
# ai-trace-commit-check.sh --selftest fixtures plus a sabotage run
#
# Exit 0 clean, 1 a hit (each commit and offending line is named), 2 a range, file or pattern list that cannot be read
# (any crash is 2). A caller refuses on 2: a checker that cannot judge is never a pass.
# The patterns are ai-trace-patterns.json beside this script; AI_TRACE_PATTERNS names another JSON (self-tests).
set -euo pipefail
export AI_TRACE_SELF="${BASH_SOURCE[0]}"
exec python3 -I - "$@" <<'PY'
import json, os, re, subprocess, sys

here = os.path.dirname(os.path.realpath(os.environ["AI_TRACE_SELF"]))
pfile = os.environ.get("AI_TRACE_PATTERNS") or os.path.join(here, "ai-trace-patterns.json")


def load():
with open(pfile, encoding="utf-8") as f:
return [(m["name"], re.compile(m["pattern"], re.I | re.M)) for m in json.load(f)["markers"]]


def hits(text, markers):
out = []
for n, line in enumerate(text.splitlines(), 1):
for name, rx in markers:
if rx.search(line):
out.append((n, name, line.strip()))
return out


def report(label, text, markers):
h = hits(text, markers)
for n, name, line in h:
print(f"{label}: line {n} [{name}]: {line}")
return len(h)


def git(*a):
p = subprocess.run(["git", *a], capture_output=True, text=True)
if p.returncode:
print(f"ai-trace-commit-check: git {' '.join(a[:3])}: {p.stderr.strip()}", file=sys.stderr)
sys.exit(2)
return p.stdout


def selftest():
# built from fragments: this file must not itself carry an attribution line
co, by, ai = "Co-Authored-", "By: ", "Cl" + "aude"
sess = "Cl" + "aude-Session: https://cl" + "aude.ai/code/session_01"
fx = {
"co-authored-by": f"Fix x\n\n{co}{by}{ai} Opus <noreply@anthro" + "pic.com>\n",
"claude-session": f"Fix x\n\n{sess}\n",
"generated-footer": f"Fix x\n\n\U0001F916 Generated with [{ai} Code](https://{ai.lower()}.com/{ai.lower()}-code)\n",
"claude.ai/code": f"Fix x\n\nsee https://{ai.lower()}.ai/code/session_9\n",
}
clean = f"Fix x\n\nGenerated by the build; mentions a hidden directory and a {co}{by}Pau line.\n"
def run(markers):
bad = [k for k, v in fx.items() if not hits(v, markers)]
if hits(clean, markers):
bad.append("clean-flagged")
return bad
m = load()
first = run(m)
print("selftest:", "GREEN" if not first else f"RED {first}")
sab = run([x for x in m if x[0] not in ("co-authored-by", "ai-co-author", "anthropic-noreply", "claude-session")])
print("sabotage (the co-author, email and session patterns removed):", "RED as expected" if sab else "still GREEN: blind")
return 0 if not first and sab else 1


def main(a):
if a == ["--selftest"]:
return selftest()
markers = load()
if len(a) == 2 and a[0] == "--msg-file":
try:
text = open(a[1], encoding="utf-8", errors="replace").read()
except OSError as e:
print(f"ai-trace-commit-check: {e}", file=sys.stderr)
return 2
# lines git adds as comments are not part of the message
text = "\n".join(l for l in text.splitlines() if not l.startswith("#"))
return 1 if report("commit message", text, markers) else 0
if len(a) == 2 and a[0] == "--text-file":
try:
text = open(a[1], encoding="utf-8", errors="replace").read()
except OSError as e:
print(f"ai-trace-commit-check: {e}", file=sys.stderr)
return 2
return 1 if report("text", text, markers) else 0
if len(a) == 1 and not a[0].startswith("-"):
shas = git("rev-list", a[0]).split()
bad = 0
for sha in shas:
bad += report(sha[:12], git("log", "-1", "--format=%B", sha), markers)
print(f"ai-trace-commit-check: {len(shas)} commits, {bad} offending lines")
return 1 if bad else 0
print(__doc__ if False else "usage: ai-trace-commit-check.sh <range> | --msg-file FILE | --text-file FILE | --selftest", file=sys.stderr)
return 2


try:
rc = main(sys.argv[1:])
except Exception as e: # a bad or missing pattern file, a decode error: unreadable, never "a hit"
print(f"ai-trace-commit-check: {type(e).__name__}: {e}", file=sys.stderr)
rc = 2
sys.exit(rc)
PY
33 changes: 33 additions & 0 deletions tools/ai-trace-patterns.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$comment": "SPDX-License-Identifier: GPL-3.0-or-later. The AI-attribution patterns (Python re, flags IGNORECASE|MULTILINE) read by ai-trace-commit-check.sh. Edit here only.",
"markers": [
{
"name": "robot-footer",
"pattern": "^[^\\S\\r\\n]*[_*>]*[^\\S\\r\\n]*🤖[^\\r\\n]*?(?:Claude|claude\\.(?:ai|com))[^\\r\\n]*"
},
{
"name": "generated-footer",
"pattern": "(?:🤖[^\\S\\r\\n]*)?Generated[^\\S\\r\\n]+(?:with|by)[^\\S\\r\\n]+\\[?Claude[^\\S\\r\\n]+Code\\]?(?:\\([^)\\s]*\\))?"
},
{
"name": "co-authored-by",
"pattern": "Co-Authored-By:[^\\S\\r\\n]*Claude\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
},
{
"name": "ai-co-author",
"pattern": "Co-Authored-By:[^\\S\\r\\n]*(?:Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Codex|Cursor|Aider|Devin)\\b[^\\r\\n<`]*(?:<[^>\\r\\n]*>)?"
},
{
"name": "anthropic-noreply",
"pattern": "noreply@anthropic\\.com"
},
{
"name": "claude-session",
"pattern": "Claude-Session:[^\\S\\r\\n]*[^\\s`]*"
},
{
"name": "claude.ai/code",
"pattern": "\\[[^\\]\\r\\n]*\\]\\([^\\S\\r\\n]*<?https?://claude\\.ai/code[^)\\s]*>?[^\\S\\r\\n]*\\)|<?https?://claude\\.ai/code\\b[^\\s)>`]*>?"
}
]
}
Loading