diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..2758408 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Pau Aliagas +# +# 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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb37e12..9093718 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/tools/ai-trace-commit-check.sh b/tools/ai-trace-commit-check.sh new file mode 100755 index 0000000..b8cea33 --- /dev/null +++ b/tools/ai-trace-commit-check.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2026 Pau Aliagas +# +# ai-trace-commit-check.sh: fail when a commit message carries an AI-attribution line. +# +# ai-trace-commit-check.sh 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 \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 | --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 diff --git a/tools/ai-trace-patterns.json b/tools/ai-trace-patterns.json new file mode 100644 index 0000000..b77a0ee --- /dev/null +++ b/tools/ai-trace-patterns.json @@ -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]*?[^\\S\\r\\n]*\\)|`]*>?" + } + ] +}