GeezerVison is showing github.com — feat(ai): reject commits whose author is an AI tool (CC017) (#661) · commit-check/commit-check@dc83e63
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file 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 number | Diff line number | Diff line change |
|---|
@@ -77,7 +77,7 @@ The same `cchk.toml` drives all of them: | 77 | 77 | | Check | Flag | Rules | | 78 | 78 | |---|---|---| | 79 | 79 | | 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 | | 81 | 81 | | Author name and email | `--author-name` `--author-email` | CC101–CC102 | | 82 | 82 | | Branch name — Conventional Branch, rebased onto a target | `--branch` | CC201–CC202 | | 83 | 83 | | Force push | `--no-force-push` | CC301 | | @@ -130,7 +130,7 @@ Priority: CLI > env > TOML > default. Booleans accept `true/false`, `yes/no`, | 130 | 130 | | `commit.require_body` | `false` | `--require-body` | `CCHK_REQUIRE_BODY` | Require a commit body (CC011) | | 131 | 131 | | `commit.require_signed_off_by` | `false` | `--require-signed-off-by` | `CCHK_REQUIRE_SIGNED_OFF_BY` | Require a `Signed-off-by:` trailer (CC012) | | 132 | 132 | | `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) | | 134 | 134 | | `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 | | 135 | 135 | | `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 | | 136 | 136 | | `commit.author_email_pattern` | `"^.+@.+$"` | `--author-email-pattern` | `CCHK_AUTHOR_EMAIL_PATTERN` | Regex the author email must match (CC102, with `--author-email`) | | |
Collapse file Copy file name to clipboardExpand all lines: commit_check/ai_signatures.py +21-1Lines changed: 21 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 21 | 21 | from functools import lru_cache | 22 | 22 |
| 23 | 23 | 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 | 25 | 25 |
| 26 | 26 | # Re-export for convenience — consumers can import everything from | 27 | 27 | # 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]]: | 235 | 235 | return results | 236 | 236 |
| 237 | 237 |
| | 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 | + | | 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 | + | 238 | 258 | def has_ai_signature(message: str) -> bool: | 239 | 259 | """Return ``True`` if *message* contains any known AI signature. | 240 | 260 |
| |
Collapse file Copy file name to clipboardExpand all lines: commit_check/ai_signatures_data.py +24Lines changed: 24 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -130,6 +130,15 @@ def _identity(value_pattern: str, label: str) -> list[AiSignaturePattern]: | 130 | 130 | ] | 131 | 131 |
| 132 | 132 |
| | 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 | + | 133 | 142 | def _disclosure(key: str, description: str) -> AiSignaturePattern: | 134 | 143 | """A trailer that exists to disclose AI assistance, whatever its value.""" | 135 | 144 | return _trailer((key,), r"\S[^\n]*", ROLE_DISCLOSURE, description) | @@ -180,6 +189,8 @@ def _names(pattern: str) -> re.Pattern[str]: | 180 | 189 | # variants such as "Claude Opus 4.5 (1M context)" that the pattern | 181 | 190 | # above misses. | 182 | 191 | | | 192 | + # The Claude GitHub App, which commits as itself. | | 193 | + *_app_bot("claude"), | 183 | 194 | # Body marker: generated-with notice | 184 | 195 | _body_marker( | 185 | 196 | r"🤖\s*Generated\s+(?:with|by)\s+\[?Claude", | @@ -221,6 +232,8 @@ def _names(pattern: str) -> re.Pattern[str]: | 221 | 232 | name_pattern=_names(r"codex"), | 222 | 233 | patterns=[ | 223 | 234 | *_identity(r"Codex[ \t]*(?:<[^>\n]*>)?", "Codex"), | | 235 | + # Codex in the cloud commits through its GitHub App. | | 236 | + *_app_bot("chatgpt-codex-connector"), | 224 | 237 | ], | 225 | 238 | ) | 226 | 239 |
| @@ -242,6 +255,8 @@ def _names(pattern: str) -> re.Pattern[str]: | 242 | 255 | | 243 | 256 | # the address catches it under any display name. | 244 | 257 | | | 258 | + # Cursor's background agent commits through its GitHub App. | | 259 | + *_app_bot("cursor"), | 245 | 260 | # Cursor's own mark on commits it writes: | 246 | 261 | # https://forum.cursor.com/t/allow-disabling-made-with-cursor-commit-trailer/154494 | 247 | 262 | _trailer( | @@ -418,6 +433,14 @@ def _names(pattern: str) -> re.Pattern[str]: | 418 | 433 | ], | 419 | 434 | ) | 420 | 435 |
| | 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 | + | 421 | 444 | # --- Generic / catch-all AI patterns --- | 422 | 445 | GENERIC_AI = KnownAiTool( | 423 | 446 | name="Generic AI", | @@ -481,5 +504,6 @@ def _names(pattern: str) -> re.Pattern[str]: | 481 | 504 | CONTINUE, | 482 | 505 | MISTRAL_VIBE, | 483 | 506 | QWEN_CODE, | | 507 | + AMAZON_Q, | 484 | 508 | GENERIC_AI, | 485 | 509 | ] |
Collapse file Copy file name to clipboardExpand all lines: commit_check/engine.py +58-5Lines changed: 58 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 2 | 2 |
| 3 | 3 | from __future__ import annotations | 4 | 4 |
| | 5 | +import os | 5 | 6 | import re | 6 | 7 | import shlex | 7 | 8 | import subprocess | | 19 | 20 | ai_policy, | 20 | 21 | ) | 21 | 22 | from commit_check.ai_signatures import ( | | 23 | + detect_ai_author, | 22 | 24 | detect_ai_signatures, | 23 | 25 | ) | 24 | 26 | from commit_check.fixes import ( | | 43 | 45 | get_git_config_value, | 44 | 46 | get_git_remotes, | 45 | 47 | get_git_user_identity, | | 48 | + get_pending_author_identity, | 46 | 49 | get_push_commits, | 47 | 50 | get_tags_at, | 48 | 51 | get_upstream_branch, | @@ -74,6 +77,10 @@ def is_merge_subject(subject: str) -> bool: | 74 | 77 | _WIP_MARKER = re.compile(r"^[ \t]*(?:\[wip\]|wip\b)", re.IGNORECASE) | 75 | 78 |
| 76 | 79 |
| | 80 | +#: The git config key naming the person who will author the next commit. | | 81 | +_USER_NAME = "user.name" | | 82 | + | | 83 | + | 77 | 84 | class ValidationResult(IntEnum): | 78 | 85 | """Validation result codes. | 79 | 86 |
| @@ -265,8 +272,10 @@ def _resolve_current_author(context: ValidationContext) -> str: | 265 | 272 |
| 266 | 273 | *Prospective message* (``stdin_text`` or ``commit_file`` is set): | 267 | 274 | 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``. | 270 | 279 |
| 271 | 280 | *Existing commit* (no ``stdin_text``, no ``commit_file``): | 272 | 281 | the last commit is the one being validated. Use its own author | @@ -278,8 +287,12 @@ def _resolve_current_author(context: ValidationContext) -> str: | 278 | 287 | # fact about that commit, so the config never enters into it. | 279 | 288 | return get_commit_info("an", context.rev) | 280 | 289 | 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) | 283 | 296 |
| 284 | 297 | @staticmethod | 285 | 298 | def _message_was_supplied(context: ValidationContext) -> bool: | @@ -695,7 +708,7 @@ def _get_author_value(self, context: ValidationContext) -> str: | 695 | 708 | return supplied.strip() | 696 | 709 |
| 697 | 710 | git_config_map = { | 698 | | - "author_name": "user.name", | | 711 | + "author_name": _USER_NAME, | 699 | 712 | "author_email": "user.email", | 700 | 713 | } | 701 | 714 | git_log_map = { | @@ -1613,6 +1626,45 @@ def _judge_person_role( | 1613 | 1626 | return ValidationResult.FAIL | 1614 | 1627 |
| 1615 | 1628 |
| | 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 | + | 1616 | 1668 | class ValidationEngine: | 1617 | 1669 | """Main validation engine that orchestrates all validations.""" | 1618 | 1670 |
| @@ -1639,6 +1691,7 @@ class ValidationEngine: | 1639 | 1691 | "ai_disclosure": AiAttributionValidator, | 1640 | 1692 | "ai_co_author": AiAttributionValidator, | 1641 | 1693 | "ai_signoff": AiAttributionValidator, | | 1694 | + "ai_author": AiAuthorValidator, | 1642 | 1695 | "tag": TagValidator, | 1643 | 1696 | "file_size": FilesValidator, | 1644 | 1697 | "file_pattern": FilesValidator, | |
Collapse file Copy file name to clipboardExpand all lines: commit_check/rule_builder.py +5-4Lines changed: 5 additions & 4 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 35 | 35 |
| 36 | 36 | #: The checks each ``ai_attribution`` policy builds. ``forbid`` rejects every | 37 | 37 | #: 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). | 39 | 40 | _AI_CHECKS_BY_POLICY: dict[str, frozenset[str]] = { | 40 | 41 | "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"}), | 43 | 44 | } | 44 | 45 | _AI_CHECKS: frozenset[str] = frozenset().union(*_AI_CHECKS_BY_POLICY.values()) | 45 | 46 |
| @@ -661,7 +662,7 @@ def _build_ai_attribution_rule( | 661 | 662 | ) -> ValidationRule | None: | 662 | 663 | """Build one of the AI attribution rules, when the policy asks for it. | 663 | 664 |
| 664 | | - ``forbid`` builds CC013 alone. ``disclose`` builds CC014–CC016, each | | 665 | + ``forbid`` builds CC013 and CC017. ``disclose`` builds CC014–CC017, each | 665 | 666 | carrying the accepted trailers and the value pattern, so any one of | 666 | 667 | them can judge a message by itself. ``ignore`` builds nothing. | 667 | 668 | """ | |
Collapse file Copy file name to clipboardExpand all lines: commit_check/rules_catalog.py +10Lines changed: 10 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -218,6 +218,16 @@ def docs_url(self) -> str | None: | 218 | 218 | error="An AI tool signed off the commit", | 219 | 219 | 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)", | 220 | 220 | ), | | 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 | + ), | 221 | 231 | ] | 222 | 232 |
| 223 | 233 | # Push rules | |
Collapse file Copy file name to clipboardExpand all lines: commit_check/util.py +15Lines changed: 15 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -716,6 +716,21 @@ def get_git_user_identity() -> tuple[str, str]: | 716 | 716 | return identity["user.name"], identity["user.email"] | 717 | 717 |
| 718 | 718 |
| | 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 | + | 719 | 734 | def get_commit_author_identity(sha: str = "HEAD") -> tuple[str, str]: | 720 | 735 | """The author name and email of a commit, in one git call.""" | 721 | 736 | name, _, email = get_commit_info("an%x1f%ae", sha).partition("\x1f") | |
|