GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — feat(ai): reject commits whose author is an AI tool (CC017) (#661) · commit-check/commit-check@dc83e63

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • README.md
  • commit_check
    • ai_signatures.py
    • ai_signatures_data.py
    • engine.py
    • rule_builder.py
    • rules_catalog.py
    • util.py
  • tests
    • ai_signatures_test.py
    • engine_fixes_test.py
    • engine_test.py
    • main_test.py
    • rule_builder_test.py
    • util_test.py
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎README.md‎

Copy file name to clipboardExpand all lines: README.md
+2-2Lines changed: 2 additions & 2 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The same `cchk.toml` drives all of them:
7777
| Check | Flag | Rules |
7878
|---|---|---|
7979
| Commit message — Conventional Commits, subject length, case and mood, WIP and fixup commits, body, sign-off | `--message` | CC001–CC012 |
80-
| AI attribution — forbid it, or require an `Assisted-by:` disclosure | `--message` | CC013–CC016 |
80+
| AI attribution — forbid it, or require an `Assisted-by:` disclosure | `--message` | CC013–CC017 |
8181
| Author name and email | `--author-name` `--author-email` | CC101–CC102 |
8282
| Branch name — Conventional Branch, rebased onto a target | `--branch` | CC201–CC202 |
8383
| Force push | `--no-force-push` | CC301 |
@@ -130,7 +130,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`,
130130
| `commit.require_body` | `false` | `--require-body` | `CCHK_REQUIRE_BODY` | Require a commit body (CC011) |
131131
| `commit.require_signed_off_by` | `false` | `--require-signed-off-by` | `CCHK_REQUIRE_SIGNED_OFF_BY` | Require a `Signed-off-by:` trailer (CC012) |
132132
| `commit.ignore_authors` | `[]` | `--ignore-authors` | `CCHK_IGNORE_AUTHORS` | Authors and co-authors whose commits skip the commit checks |
133-
| `commit.ai_attribution` | `"ignore"` | `--ai-attribution` | `CCHK_AI_ATTRIBUTION` | `ignore`, `forbid` or `disclose`. `forbid` rejects commits carrying known AI tool signatures (CC013); `disclose` accepts AI assistance disclosed with one of `ai_disclosure_trailers` (CC014) and rejects the tool as a co-author (CC015) or as a sign-off (CC016) |
133+
| `commit.ai_attribution` | `"ignore"` | `--ai-attribution` | `CCHK_AI_ATTRIBUTION` | `ignore`, `forbid` or `disclose`. `forbid` rejects commits carrying known AI tool signatures (CC013); `disclose` accepts AI assistance disclosed with one of `ai_disclosure_trailers` (CC014) and rejects the tool as a co-author (CC015) or as a sign-off (CC016); both reject a commit whose author is an AI tool (CC017) |
134134
| `commit.ai_disclosure_trailers` | `Assisted-by, Generated-by` | `--ai-disclosure-trailers` | `CCHK_AI_DISCLOSURE_TRAILERS` | Trailers that disclose AI assistance under `disclose`; the first is the one a fix is written with. List `Co-authored-by` to accept the tool as a co-author |
135135
| `commit.ai_disclosure_pattern` | `""` | `--ai-disclosure-pattern` | `CCHK_AI_DISCLOSURE_PATTERN` | Regex the disclosure's value must match under `disclose`, e.g. `^\S+/\S+` for `agent/model`; empty accepts any value, but a trailer with no value at all is always reported |
136136
| `commit.author_email_pattern` | `"^.+@.+$"` | `--author-email-pattern` | `CCHK_AUTHOR_EMAIL_PATTERN` | Regex the author email must match (CC102, with `--author-email`) |
Collapse file

‎commit_check/ai_signatures.py‎

Copy file name to clipboardExpand all lines: commit_check/ai_signatures.py
+21-1Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from functools import lru_cache
2222

2323
from commit_check.ai_signatures_data import ALL_KNOWN_TOOLS as _ALL_KNOWN_TOOLS
24-
from commit_check.ai_signatures_data import GENERIC_AI, ROLE_DISCLOSURE
24+
from commit_check.ai_signatures_data import GENERIC_AI, ROLE_CO_AUTHOR, ROLE_DISCLOSURE
2525

2626
# Re-export for convenience — consumers can import everything from
2727
# commit_check.ai_signatures without knowing about the data/logic split.
@@ -235,6 +235,26 @@ def detect_ai_signatures(message: str) -> list[dict[str, str]]:
235235
return results
236236

237237

238+
def detect_ai_author(name: str, email: str) -> str | None:
239+
"""The AI tool a commit's author identity names, or ``None``.
240+
241+
An author is judged exactly as the same identity would be on a
242+
``Co-authored-by:`` line, so the two can never disagree about who is a
243+
tool: ``google-labs-jules[bot]``, ``openhands <[email protected]>``,
244+
a name ending in ``(aider)``, any name at Copilot's account address.
245+
"""
246+
name = " ".join(name.split())
247+
email = "".join(email.split())
248+
if not name and not email:
249+
return None
250+
identity = f"{name} <{email}>" if email else name
251+
signatures = detect_ai_signatures(f"x\n\nCo-authored-by: {identity}")
252+
return next(
253+
(s["tool"] for s in signatures if s["role"] == ROLE_CO_AUTHOR),
254+
None,
255+
)
256+
257+
238258
def has_ai_signature(message: str) -> bool:
239259
"""Return ``True`` if *message* contains any known AI signature.
240260
Collapse file

‎commit_check/ai_signatures_data.py‎

Copy file name to clipboardExpand all lines: commit_check/ai_signatures_data.py
+24Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@ def _identity(value_pattern: str, label: str) -> list[AiSignaturePattern]:
130130
]
131131

132132

133+
def _app_bot(login: str) -> list[AiSignaturePattern]:
134+
"""The identity of a tool's GitHub App: ``<login>[bot]``, with any address.
135+
136+
A GitHub App commits under its own ``[bot]`` account, and no person can
137+
hold that name, so the address needs no anchoring.
138+
"""
139+
return _identity(rf"{re.escape(login)}\[bot\][ \t]*(?:<[^>\n]*>)?", f"{login}[bot]")
140+
141+
133142
def _disclosure(key: str, description: str) -> AiSignaturePattern:
134143
"""A trailer that exists to disclose AI assistance, whatever its value."""
135144
return _trailer((key,), r"\S[^\n]*", ROLE_DISCLOSURE, description)
@@ -180,6 +189,8 @@ def _names(pattern: str) -> re.Pattern[str]:
180189
# variants such as "Claude Opus 4.5 (1M context)" that the pattern
181190
# above misses.
182191
*_identity(r"[^<\n]*<noreply@anthropic\.com>", "... <[email protected]>"),
192+
# The Claude GitHub App, which commits as itself.
193+
*_app_bot("claude"),
183194
# Body marker: generated-with notice
184195
_body_marker(
185196
r"🤖\s*Generated\s+(?:with|by)\s+\[?Claude",
@@ -221,6 +232,8 @@ def _names(pattern: str) -> re.Pattern[str]:
221232
name_pattern=_names(r"codex"),
222233
patterns=[
223234
*_identity(r"Codex[ \t]*(?:<[^>\n]*>)?", "Codex"),
235+
# Codex in the cloud commits through its GitHub App.
236+
*_app_bot("chatgpt-codex-connector"),
224237
],
225238
)
226239

@@ -242,6 +255,8 @@ def _names(pattern: str) -> re.Pattern[str]:
242255
# Cursor's agent signs as ``Cursor Agent <[email protected]>``;
243256
# the address catches it under any display name.
244257
*_identity(r"[^<\n]*<cursoragent@cursor\.com>", "... <[email protected]>"),
258+
# Cursor's background agent commits through its GitHub App.
259+
*_app_bot("cursor"),
245260
# Cursor's own mark on commits it writes:
246261
# https://forum.cursor.com/t/allow-disabling-made-with-cursor-commit-trailer/154494
247262
_trailer(
@@ -418,6 +433,14 @@ def _names(pattern: str) -> re.Pattern[str]:
418433
],
419434
)
420435

436+
# --- Amazon Q Developer ---
437+
# The Amazon Q Developer agent commits through its GitHub App.
438+
AMAZON_Q = KnownAiTool(
439+
name="Amazon Q Developer",
440+
name_pattern=_names(r"amazon[ \t]+q(?:[ \t]+developer)?|amazon-q-developer"),
441+
patterns=[*_app_bot("amazon-q-developer")],
442+
)
443+
421444
# --- Generic / catch-all AI patterns ---
422445
GENERIC_AI = KnownAiTool(
423446
name="Generic AI",
@@ -481,5 +504,6 @@ def _names(pattern: str) -> re.Pattern[str]:
481504
CONTINUE,
482505
MISTRAL_VIBE,
483506
QWEN_CODE,
507+
AMAZON_Q,
484508
GENERIC_AI,
485509
]
Collapse file

‎commit_check/engine.py‎

Copy file name to clipboardExpand all lines: commit_check/engine.py
+58-5Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import re
67
import shlex
78
import subprocess
@@ -19,6 +20,7 @@
1920
ai_policy,
2021
)
2122
from commit_check.ai_signatures import (
23+
detect_ai_author,
2224
detect_ai_signatures,
2325
)
2426
from commit_check.fixes import (
@@ -43,6 +45,7 @@
4345
get_git_config_value,
4446
get_git_remotes,
4547
get_git_user_identity,
48+
get_pending_author_identity,
4649
get_push_commits,
4750
get_tags_at,
4851
get_upstream_branch,
@@ -74,6 +77,10 @@ def is_merge_subject(subject: str) -> bool:
7477
_WIP_MARKER = re.compile(r"^[ \t]*(?:\[wip\]|wip\b)", re.IGNORECASE)
7578

7679

80+
#: The git config key naming the person who will author the next commit.
81+
_USER_NAME = "user.name"
82+
83+
7784
class ValidationResult(IntEnum):
7885
"""Validation result codes.
7986
@@ -265,8 +272,10 @@ def _resolve_current_author(context: ValidationContext) -> str:
265272
266273
*Prospective message* (``stdin_text`` or ``commit_file`` is set):
267274
the user is about to create a new commit. The last commit's author
268-
is unrelated — the relevant identity is the local git config
269-
(``user.name``), i.e. the person who will author the pending commit.
275+
is unrelated — the relevant identity is the one git would record:
276+
``GIT_AUTHOR_NAME`` when set (how a tool commits under its own name,
277+
and how the Action passes on the author of a commit it pipes in),
278+
else ``user.name``.
270279
271280
*Existing commit* (no ``stdin_text``, no ``commit_file``):
272281
the last commit is the one being validated. Use its own author
@@ -278,8 +287,12 @@ def _resolve_current_author(context: ValidationContext) -> str:
278287
# fact about that commit, so the config never enters into it.
279288
return get_commit_info("an", context.rev)
280289
if context.stdin_text is not None or context.commit_file is not None:
281-
return get_git_config_value("user.name") or get_commit_info("an")
282-
return get_commit_info("an") or get_git_config_value("user.name")
290+
return (
291+
os.environ.get("GIT_AUTHOR_NAME", "").strip()
292+
or get_git_config_value(_USER_NAME)
293+
or get_commit_info("an")
294+
)
295+
return get_commit_info("an") or get_git_config_value(_USER_NAME)
283296

284297
@staticmethod
285298
def _message_was_supplied(context: ValidationContext) -> bool:
@@ -695,7 +708,7 @@ def _get_author_value(self, context: ValidationContext) -> str:
695708
return supplied.strip()
696709

697710
git_config_map = {
698-
"author_name": "user.name",
711+
"author_name": _USER_NAME,
699712
"author_email": "user.email",
700713
}
701714
git_log_map = {
@@ -1613,6 +1626,45 @@ def _judge_person_role(
16131626
return ValidationResult.FAIL
16141627

16151628

1629+
class AiAuthorValidator(BaseValidator):
1630+
"""Rejects a commit whose author is an AI tool (CC017).
1631+
1632+
Under both ``forbid`` and ``disclose``: a tool in the author's place,
1633+
with the person behind it at most a ``Co-authored-by:`` line, is the
1634+
strongest attribution a commit can make. An existing commit (``--rev``,
1635+
or HEAD) is judged by its recorded author; a message not yet committed
1636+
by the author git would record for it, which honours ``GIT_AUTHOR_NAME``
1637+
and ``GIT_AUTHOR_EMAIL`` the way ``git commit`` does.
1638+
"""
1639+
1640+
def validate(self, context: ValidationContext) -> ValidationResult:
1641+
if self._should_skip_commit_validation(context):
1642+
return ValidationResult.SKIP
1643+
1644+
if context.rev is None and (
1645+
context.stdin_text is not None or context.commit_file is not None
1646+
):
1647+
name, email = get_pending_author_identity()
1648+
else:
1649+
name, email = get_commit_author_identity(context.rev or "HEAD")
1650+
identity = f"{name} <{email}>" if email else name
1651+
self._checked_value = identity
1652+
tool = detect_ai_author(name, email)
1653+
if tool is None:
1654+
return ValidationResult.PASS
1655+
1656+
suggest = "Author the commit yourself: git commit --amend --reset-author"
1657+
if self.rule.value == "disclose":
1658+
trailers = self.rule.allowed or list(DEFAULT_AI_DISCLOSURE_TRAILERS)
1659+
suggest += f', and disclose the tool with "{trailers[0]}: {tool}"'
1660+
self._print_failure(
1661+
identity,
1662+
error=f"An AI tool is the commit author: {tool}",
1663+
suggest=suggest,
1664+
)
1665+
return ValidationResult.FAIL
1666+
1667+
16161668
class ValidationEngine:
16171669
"""Main validation engine that orchestrates all validations."""
16181670

@@ -1639,6 +1691,7 @@ class ValidationEngine:
16391691
"ai_disclosure": AiAttributionValidator,
16401692
"ai_co_author": AiAttributionValidator,
16411693
"ai_signoff": AiAttributionValidator,
1694+
"ai_author": AiAuthorValidator,
16421695
"tag": TagValidator,
16431696
"file_size": FilesValidator,
16441697
"file_pattern": FilesValidator,
Collapse file

‎commit_check/rule_builder.py‎

Copy file name to clipboardExpand all lines: commit_check/rule_builder.py
+5-4Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@
3535

3636
#: The checks each ``ai_attribution`` policy builds. ``forbid`` rejects every
3737
#: AI signature under one rule; ``disclose`` asks for three things, one rule
38-
#: each, so a project can warn on one while enforcing the others.
38+
#: each, so a project can warn on one while enforcing the others. Both reject
39+
#: a tool as the commit's author (CC017).
3940
_AI_CHECKS_BY_POLICY: dict[str, frozenset[str]] = {
4041
"ignore": frozenset(),
41-
"forbid": frozenset({"ai_attribution"}),
42-
"disclose": frozenset({"ai_disclosure", "ai_co_author", "ai_signoff"}),
42+
"forbid": frozenset({"ai_attribution", "ai_author"}),
43+
"disclose": frozenset({"ai_disclosure", "ai_co_author", "ai_signoff", "ai_author"}),
4344
}
4445
_AI_CHECKS: frozenset[str] = frozenset().union(*_AI_CHECKS_BY_POLICY.values())
4546

@@ -661,7 +662,7 @@ def _build_ai_attribution_rule(
661662
) -> ValidationRule | None:
662663
"""Build one of the AI attribution rules, when the policy asks for it.
663664
664-
``forbid`` builds CC013 alone. ``disclose`` builds CC014–CC016, each
665+
``forbid`` builds CC013 and CC017. ``disclose`` builds CC014–CC017, each
665666
carrying the accepted trailers and the value pattern, so any one of
666667
them can judge a message by itself. ``ignore`` builds nothing.
667668
"""
Collapse file

‎commit_check/rules_catalog.py‎

Copy file name to clipboardExpand all lines: commit_check/rules_catalog.py
+10Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,16 @@ def docs_url(self) -> str | None:
218218
error="An AI tool signed off the commit",
219219
suggest="Only a person can certify the Developer Certificate of Origin: remove the AI Signed-off-by line and sign off yourself (git commit --signoff)",
220220
),
221+
# Under both forbid and disclose: a tool in the author's place is the
222+
# strongest attribution there is. Read from the commit, so it judges
223+
# existing commits (--rev, or HEAD) and passes a message not yet committed.
224+
RuleCatalogEntry(
225+
rule_id="CC017",
226+
check="ai_author",
227+
regex=None,
228+
error="An AI tool is the commit author",
229+
suggest="Author the commit yourself: git commit --amend --reset-author",
230+
),
221231
]
222232

223233
# Push rules
Collapse file

‎commit_check/util.py‎

Copy file name to clipboardExpand all lines: commit_check/util.py
+15Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,21 @@ def get_git_user_identity() -> tuple[str, str]:
716716
return identity["user.name"], identity["user.email"]
717717

718718

719+
def get_pending_author_identity() -> tuple[str, str]:
720+
"""The author name and email the next commit will carry, or two blanks.
721+
722+
``git var GIT_AUTHOR_IDENT`` answers as ``git commit`` would: from
723+
``GIT_AUTHOR_NAME`` / ``GIT_AUTHOR_EMAIL`` when they are set (a tool
724+
that commits under its own name sets them), else from ``user.name`` /
725+
``user.email``. With neither, git prints an error, which matches nothing.
726+
"""
727+
output = cmd_output(["git", "var", "GIT_AUTHOR_IDENT"]).strip()
728+
match = re.fullmatch(r"(.*) <([^<>\n]*)> \d+ [+-]\d{4}", output)
729+
if not match:
730+
return "", ""
731+
return match.group(1).strip(), match.group(2).strip()
732+
733+
719734
def get_commit_author_identity(sha: str = "HEAD") -> tuple[str, str]:
720735
"""The author name and email of a commit, in one git call."""
721736
name, _, email = get_commit_info("an%x1f%ae", sha).partition("\x1f")

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.