commitguardian 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1484 @@
1
+ # ruff: noqa: E501 - dataset literals are kept on one line so each case reads as a row.
2
+ """The labelled commit dataset used to measure detection.
3
+
4
+ Scope. CommitGuard detects *attribution evidence* in commit metadata (trailers,
5
+ author and committer identities, tool footers). It does not and cannot
6
+ determine how code was written. The dataset therefore labels metadata, and a
7
+ "positive" case is a commit whose metadata the built-in policy must not allow
8
+ silently (decision WARN or BLOCK).
9
+
10
+ Labels. Every case states:
11
+
12
+ * ``expected_decision`` - the decision the built-in policy must reach;
13
+ * ``required_rules`` - rules that must be reported;
14
+ * ``permitted_rules`` - rules that may additionally be reported without being
15
+ wrong (for example ``malformed_trailer`` next to ``ai_coauthor`` on a mangled
16
+ trailer).
17
+
18
+ Labels come from the documented detection semantics (docs/detection-engine.md)
19
+ and, for adversarial cases, from the security requirement (the attribution is
20
+ still attribution). They are **not** derived from the current implementation,
21
+ so a mismatch is a measured failure, not a relabelling opportunity.
22
+
23
+ Classes::
24
+
25
+ clean ordinary human commits, including near-misses (Claude Shannon,
26
+ Claudette, human employees of AI vendors, the word "AI")
27
+ violations canonical attribution for every built-in agent
28
+ variations casing, whitespace, line endings, duplicates, Unicode, layout
29
+ malformed broken trailers and identities
30
+ adversarial evasion attempts: separators, invisible characters, homoglyphs,
31
+ escape sequences, trailer floods, very long messages
32
+ generated seeded combinations to reach volume (clean, AI and bot mixes)
33
+
34
+ The dataset is fully deterministic: the same version always produces the same
35
+ cases and the same fingerprint. Versions only ever add cases; an older version
36
+ can always be rebuilt (``build_dataset(version="1.0.0")``).
37
+
38
+ Version history::
39
+
40
+ 1.0.0 initial dataset (9,115 cases)
41
+ 1.1.0 cases written after 1.0.0 exposed a parser bypass, before running them:
42
+ more leading-character and Unicode evasions, quoted and bulleted
43
+ human trailers, squash-merge messages (false-positive probes)
44
+ 1.2.0 cases written after the Phase 10 property-based fuzzer found that a Unicode
45
+ letter or number before a key (\u32acCo-authored-by:) hid attribution, before
46
+ running them: alphanumeric-symbol prefixes, numbered human trailers and
47
+ non-ASCII prose (false-positive probes)
48
+ 1.3.0 cases written after the same fuzzer found that default-ignorable characters
49
+ which are not control or format characters (variation selectors, U+034F,
50
+ Hangul fillers) hid a trailer key or an agent alias, before running them;
51
+ emoji variation selectors and Hangul names (false-positive probes)
52
+ """
53
+
54
+ import json
55
+ import random
56
+ from collections.abc import Iterable, Iterator, Sequence
57
+ from pathlib import Path
58
+ from typing import Literal
59
+
60
+ from pydantic import BaseModel, ConfigDict, Field
61
+
62
+ from commitguard.security.hashing import sha256_hex
63
+
64
+ DATASET_VERSION = "1.3.0"
65
+ DATASET_VERSIONS = ("1.0.0", "1.1.0", "1.2.0", "1.3.0")
66
+ DATASET_SEED = 20260917
67
+ GENERATED_CASES = 9_000
68
+
69
+ CaseClass = Literal["clean", "violations", "variations", "malformed", "adversarial", "generated"]
70
+ Decision = Literal["allow", "warn", "block"]
71
+ CLASSES: tuple[CaseClass, ...] = (
72
+ "clean",
73
+ "violations",
74
+ "variations",
75
+ "malformed",
76
+ "adversarial",
77
+ "generated",
78
+ )
79
+
80
+
81
+ class Person(BaseModel):
82
+ model_config = ConfigDict(frozen=True, extra="forbid")
83
+
84
+ name: str
85
+ email: str
86
+
87
+
88
+ class DatasetCase(BaseModel):
89
+ model_config = ConfigDict(frozen=True, extra="forbid")
90
+
91
+ id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{2,80}$")
92
+ case_class: CaseClass
93
+ category: str
94
+ description: str
95
+ message: str
96
+ author: Person
97
+ committer: Person
98
+ expected_decision: Decision
99
+ required_rules: tuple[str, ...] = ()
100
+ permitted_rules: tuple[str, ...] = ()
101
+
102
+ @property
103
+ def positive(self) -> bool:
104
+ return self.expected_decision != "allow"
105
+
106
+
107
+ # --------------------------------------------------------------------------- #
108
+ # Identities
109
+ # --------------------------------------------------------------------------- #
110
+ HUMANS = (
111
+ Person(name="Ada Lovelace", email="ada@example.com"),
112
+ Person(name="Grace Hopper", email="grace.hopper@example.org"),
113
+ Person(name="Alan Turing", email="alan@turing.example"),
114
+ Person(name="Chinwe Okafor", email="chinwe.okafor@example.ng"),
115
+ Person(name="Jos\u00e9 Mart\u00ednez", email="jose@example.es"),
116
+ Person(name="\u674e\u96f7", email="lilei@example.cn"),
117
+ Person(name="Sam O'Neil", email="sam.oneil@example.ie"),
118
+ Person(name="Priya Raman", email="priya@example.in"),
119
+ )
120
+ DEV = HUMANS[0]
121
+
122
+ # Canonical attribution for each built-in agent: (agent, trailer identity).
123
+ AGENT_COAUTHORS: tuple[tuple[str, str], ...] = (
124
+ ("claude", "Claude <noreply@anthropic.com>"),
125
+ ("claude-prefix", "Claude Opus 4.5 (1M context) <noreply@anthropic.com>"),
126
+ ("chatgpt", "ChatGPT <noreply@openai.com>"),
127
+ (
128
+ "openai-codex",
129
+ "chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>",
130
+ ),
131
+ ("copilot", "Copilot <198982749+Copilot@users.noreply.github.com>"),
132
+ ("cursor", "Cursor Agent <cursoragent@cursor.com>"),
133
+ ("gemini", "Gemini <gemini-code-assist@google.com>"),
134
+ ("windsurf", "Windsurf <noreply@windsurf.com>"),
135
+ ("codeium", "Codeium <noreply@codeium.com>"),
136
+ ("cline", "Cline <noreply@cline.bot>"),
137
+ ("roo-code", "Roo Code <noreply@roocode.com>"),
138
+ ("devin", "Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>"),
139
+ ("amazon-q", "Amazon Q Developer <208079219+amazon-q-developer[bot]@users.noreply.github.com>"),
140
+ ("jules", "google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>"),
141
+ ("aider", "aider <aider@aider.chat>"),
142
+ ("openhands", "openhands <openhands@all-hands.dev>"),
143
+ )
144
+
145
+ BOTS = (
146
+ Person(name="dependabot[bot]", email="49699333+dependabot[bot]@users.noreply.github.com"),
147
+ Person(
148
+ name="github-actions[bot]", email="41898282+github-actions[bot]@users.noreply.github.com"
149
+ ),
150
+ Person(name="renovate[bot]", email="29139614+renovate[bot]@users.noreply.github.com"),
151
+ )
152
+
153
+ SUBJECTS = (
154
+ "feat(auth): add session rotation on privilege change",
155
+ "fix(api): reject negative page sizes",
156
+ "docs: explain the retention settings",
157
+ "refactor(storage): extract migration helpers",
158
+ "test(hooks): cover detached HEAD pushes",
159
+ "chore(deps): update pydantic to 2.8",
160
+ "perf(scan): batch cat-file reads",
161
+ "ci: run the security suite on pull requests",
162
+ )
163
+
164
+
165
+ def _case(
166
+ case_id: str,
167
+ case_class: CaseClass,
168
+ category: str,
169
+ description: str,
170
+ message: str,
171
+ decision: Decision,
172
+ required: Sequence[str] = (),
173
+ permitted: Sequence[str] = (),
174
+ *,
175
+ author: Person = DEV,
176
+ committer: Person | None = None,
177
+ ) -> DatasetCase:
178
+ return DatasetCase(
179
+ id=case_id,
180
+ case_class=case_class,
181
+ category=category,
182
+ description=description,
183
+ message=message,
184
+ author=author,
185
+ committer=committer or author,
186
+ expected_decision=decision,
187
+ required_rules=tuple(sorted(required)),
188
+ permitted_rules=tuple(sorted(permitted)),
189
+ )
190
+
191
+
192
+ def _msg(subject: str, *trailers: str, body: str = "") -> str:
193
+ parts = [subject]
194
+ if body:
195
+ parts.append(body)
196
+ if trailers:
197
+ parts.append("\n".join(trailers))
198
+ return "\n\n".join(parts) + "\n"
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # Hand-written classes
203
+ # --------------------------------------------------------------------------- #
204
+ def _clean() -> Iterator[DatasetCase]:
205
+ c: CaseClass = "clean"
206
+ yield _case("clean-plain", c, "plain", "Subject only", "fix: typo in README\n", "allow")
207
+ yield _case(
208
+ "clean-body",
209
+ c,
210
+ "plain",
211
+ "Subject and wrapped body",
212
+ _msg(
213
+ SUBJECTS[0],
214
+ body="Rotate the session identifier whenever roles change so a\nstolen cookie cannot inherit new privileges.",
215
+ ),
216
+ "allow",
217
+ )
218
+ for index, human in enumerate(HUMANS[1:], start=1):
219
+ yield _case(
220
+ f"clean-human-coauthor-{index}",
221
+ c,
222
+ "human-coauthor",
223
+ f"Human co-author {human.name}",
224
+ _msg(SUBJECTS[index % len(SUBJECTS)], f"Co-authored-by: {human.name} <{human.email}>"),
225
+ "allow",
226
+ )
227
+ yield _case(
228
+ "clean-signoff",
229
+ c,
230
+ "human-trailers",
231
+ "Signed-off-by, Reviewed-by and Fixes from humans",
232
+ _msg(
233
+ SUBJECTS[1],
234
+ "Signed-off-by: Ada Lovelace <ada@example.com>",
235
+ "Reviewed-by: Grace Hopper <grace.hopper@example.org>",
236
+ "Fixes: #1284",
237
+ ),
238
+ "allow",
239
+ )
240
+ near_misses = (
241
+ (
242
+ "claude-shannon",
243
+ "Claude Shannon <claude.shannon@example.edu>",
244
+ "a human whose first name is Claude",
245
+ ),
246
+ ("claudette", "Claudette Colvin <claudette@example.org>", "a name containing 'Claude'"),
247
+ (
248
+ "claude-dupont",
249
+ "Claude Dupont <claude.dupont@example.fr>",
250
+ "a French human named Claude",
251
+ ),
252
+ ("anthropic-employee", "Jane Doe <jane@anthropic.com>", "a human at an AI vendor's domain"),
253
+ (
254
+ "openai-employee",
255
+ "Wojciech Kowalski <wojciech@openai.com>",
256
+ "a human at an AI vendor's domain",
257
+ ),
258
+ (
259
+ "devin-human",
260
+ "Devin Brown <devin.brown@example.com>",
261
+ "ambiguous alias used by a human, no vendor domain",
262
+ ),
263
+ ("copilot-surname", "Maria Copilotti <maria@example.it>", "a surname containing 'Copilot'"),
264
+ ("nimbus-agent", "Nimbus AI Agent <nimbus@example.com>", "an unlisted, AI-sounding name"),
265
+ )
266
+ for key, identity, description in near_misses:
267
+ yield _case(
268
+ f"clean-near-miss-{key}",
269
+ c,
270
+ "near-miss",
271
+ f"Co-author is {description}",
272
+ _msg(SUBJECTS[2], f"Co-authored-by: {identity}"),
273
+ "allow",
274
+ )
275
+ wording = (
276
+ "feat: use AI service for recommendations",
277
+ "docs: compare Claude, ChatGPT and Copilot for code review",
278
+ "fix: handle the 'Generated with' header in exported CSV",
279
+ "chore: remove Co-authored-by parsing from the changelog script",
280
+ )
281
+ for index, subject in enumerate(wording):
282
+ yield _case(
283
+ f"clean-wording-{index}",
284
+ c,
285
+ "ai-wording",
286
+ "Mentions AI only in prose",
287
+ subject + "\n",
288
+ "allow",
289
+ )
290
+ yield _case(
291
+ "clean-generated-protoc",
292
+ c,
293
+ "tool-trailer",
294
+ "Generated-by names a non-AI tool",
295
+ _msg("build: regenerate protobuf stubs", "Generated-by: protoc 25.1"),
296
+ "allow",
297
+ )
298
+ yield _case(
299
+ "clean-ai-assisted-no",
300
+ c,
301
+ "tool-trailer",
302
+ "AI-assisted: no is ignored",
303
+ _msg("fix: close file handles", "AI-assisted: no"),
304
+ "allow",
305
+ )
306
+ yield _case(
307
+ "clean-unicode-body",
308
+ c,
309
+ "unicode",
310
+ "Non-Latin text in subject and body",
311
+ _msg(
312
+ "docs: \u7ffb\u8bd1\u5b89\u88c5\u6307\u5357",
313
+ body="\u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d \u0440\u0430\u0437\u0434\u0435\u043b \u043e\u0431 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0435. \U0001f680",
314
+ ),
315
+ "allow",
316
+ author=HUMANS[5],
317
+ )
318
+ yield _case(
319
+ "clean-long-body",
320
+ c,
321
+ "size",
322
+ "A 64 KiB body of ordinary prose",
323
+ _msg(
324
+ "docs: import the design review notes",
325
+ body=("The storage layer keeps immutable versions. " * 1500).strip(),
326
+ ),
327
+ "allow",
328
+ )
329
+ yield _case(
330
+ "clean-merge-message",
331
+ c,
332
+ "merge",
333
+ "Default merge commit message",
334
+ "Merge branch 'feature/rotation' into main\n",
335
+ "allow",
336
+ )
337
+ yield _case(
338
+ "clean-revert",
339
+ c,
340
+ "revert",
341
+ "Revert message quoting a commit id",
342
+ _msg(
343
+ 'Revert "feat: add cache"',
344
+ body="This reverts commit 3a91f02e7d5c2b1f0a9e8d7c6b5a493827160f5e.",
345
+ ),
346
+ "allow",
347
+ )
348
+ yield _case(
349
+ "clean-url-trailer",
350
+ c,
351
+ "human-trailers",
352
+ "Link trailer with a URL and a colon",
353
+ _msg(SUBJECTS[3], "Link: https://example.com/issues/42#comment:3"),
354
+ "allow",
355
+ )
356
+
357
+
358
+ def _violations() -> Iterator[DatasetCase]:
359
+ c: CaseClass = "violations"
360
+ for agent, identity in AGENT_COAUTHORS:
361
+ yield _case(
362
+ f"violation-coauthor-{agent}",
363
+ c,
364
+ "ai-coauthor",
365
+ f"Canonical co-author trailer for {agent}",
366
+ _msg(SUBJECTS[0], f"Co-authored-by: {identity}"),
367
+ "block",
368
+ ["ai_coauthor"],
369
+ )
370
+ yield _case(
371
+ "violation-author-claude",
372
+ c,
373
+ "ai-identity",
374
+ "The commit author is an AI agent",
375
+ _msg(SUBJECTS[1]),
376
+ "block",
377
+ ["ai_identity"],
378
+ author=Person(name="Claude", email="noreply@anthropic.com"),
379
+ )
380
+ yield _case(
381
+ "violation-committer-copilot",
382
+ c,
383
+ "ai-identity",
384
+ "The committer is an AI agent, the author a human",
385
+ _msg(SUBJECTS[1]),
386
+ "block",
387
+ ["ai_identity"],
388
+ committer=Person(name="Copilot", email="198982749+Copilot@users.noreply.github.com"),
389
+ )
390
+ footers = (
391
+ (
392
+ "claude-code-footer",
393
+ "\U0001f916 Generated with [Claude Code](https://claude.com/claude-code)",
394
+ ),
395
+ ("claude-code-footer-plain", "Generated with Claude Code"),
396
+ )
397
+ for key, line in footers:
398
+ yield _case(
399
+ f"violation-{key}",
400
+ c,
401
+ "tool-footer",
402
+ f"Tool footer: {line}",
403
+ _msg(SUBJECTS[2], body=line),
404
+ "block",
405
+ ["ai_trailer"],
406
+ )
407
+ yield _case(
408
+ "violation-ai-generated-key",
409
+ c,
410
+ "ai-trailer",
411
+ "AI-Generated-By trailer (the key declares AI generation)",
412
+ _msg(SUBJECTS[3], "AI-Generated-By: internal tooling"),
413
+ "block",
414
+ ["ai_trailer"],
415
+ )
416
+ yield _case(
417
+ "violation-generated-by-agent",
418
+ c,
419
+ "ai-trailer",
420
+ "Generated-by names an AI agent",
421
+ _msg(SUBJECTS[3], "Generated-by: Claude Code"),
422
+ "block",
423
+ ["ai_trailer"],
424
+ )
425
+ yield _case(
426
+ "violation-reviewed-by-agent",
427
+ c,
428
+ "ai-trailer",
429
+ "Reviewed-by names an AI agent",
430
+ _msg(SUBJECTS[4], "Reviewed-by: Copilot <198982749+Copilot@users.noreply.github.com>"),
431
+ "block",
432
+ ["ai_trailer"],
433
+ )
434
+ for bot in BOTS:
435
+ yield _case(
436
+ f"violation-bot-{bot.name.split('[')[0]}",
437
+ c,
438
+ "bot-identity",
439
+ f"Automation account author {bot.name} (warn by default)",
440
+ _msg("chore(deps): bump actions/checkout"),
441
+ "warn",
442
+ ["bot_identity"],
443
+ author=bot,
444
+ )
445
+ yield _case(
446
+ "violation-multiple-agents",
447
+ c,
448
+ "multiple",
449
+ "Two AI co-authors and a human",
450
+ _msg(
451
+ SUBJECTS[0],
452
+ "Co-authored-by: Claude <noreply@anthropic.com>",
453
+ "Co-authored-by: Grace Hopper <grace.hopper@example.org>",
454
+ "Co-authored-by: Cursor Agent <cursoragent@cursor.com>",
455
+ ),
456
+ "block",
457
+ ["ai_coauthor"],
458
+ )
459
+
460
+
461
+ def _variations() -> Iterator[DatasetCase]:
462
+ c: CaseClass = "variations"
463
+ claude = "Claude <noreply@anthropic.com>"
464
+ keys = {
465
+ "lower": "co-authored-by",
466
+ "upper": "CO-AUTHORED-BY",
467
+ "title": "Co-Authored-By",
468
+ "mixed": "cO-aUtHoReD-bY",
469
+ }
470
+ for key_id, key in keys.items():
471
+ yield _case(
472
+ f"variation-case-{key_id}",
473
+ c,
474
+ "casing",
475
+ f"Key casing {key}",
476
+ _msg(SUBJECTS[0], f"{key}: {claude}"),
477
+ "block",
478
+ ["ai_coauthor"],
479
+ )
480
+ yield _case(
481
+ "variation-email-case",
482
+ c,
483
+ "casing",
484
+ "Upper-case email",
485
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <NOREPLY@ANTHROPIC.COM>"),
486
+ "block",
487
+ ["ai_coauthor"],
488
+ )
489
+ yield _case(
490
+ "variation-name-case",
491
+ c,
492
+ "casing",
493
+ "Lower-case name with vendor email",
494
+ _msg(SUBJECTS[0], "Co-authored-by: claude <noreply@anthropic.com>"),
495
+ "block",
496
+ ["ai_coauthor"],
497
+ )
498
+ yield _case(
499
+ "variation-extra-spaces",
500
+ c,
501
+ "whitespace",
502
+ "Several spaces inside the value",
503
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply@anthropic.com> "),
504
+ "block",
505
+ ["ai_coauthor"],
506
+ ["malformed_trailer"],
507
+ )
508
+ yield _case(
509
+ "variation-crlf",
510
+ c,
511
+ "line-endings",
512
+ "CRLF line endings",
513
+ _msg(SUBJECTS[0], f"Co-authored-by: {claude}").replace("\n", "\r\n"),
514
+ "block",
515
+ ["ai_coauthor"],
516
+ )
517
+ yield _case(
518
+ "variation-cr-only",
519
+ c,
520
+ "line-endings",
521
+ "Old Mac CR line endings",
522
+ _msg(SUBJECTS[0], f"Co-authored-by: {claude}").replace("\n", "\r"),
523
+ "block",
524
+ ["ai_coauthor"],
525
+ ["malformed_trailer"],
526
+ )
527
+ yield _case(
528
+ "variation-no-final-newline",
529
+ c,
530
+ "line-endings",
531
+ "No trailing newline",
532
+ f"{SUBJECTS[0]}\n\nCo-authored-by: {claude}",
533
+ "block",
534
+ ["ai_coauthor"],
535
+ )
536
+ yield _case(
537
+ "variation-duplicate",
538
+ c,
539
+ "duplicates",
540
+ "The same trailer three times",
541
+ _msg(SUBJECTS[0], *(f"Co-authored-by: {claude}",) * 3),
542
+ "block",
543
+ ["ai_coauthor"],
544
+ )
545
+ yield _case(
546
+ "variation-mixed-trailers",
547
+ c,
548
+ "layout",
549
+ "AI trailer among ten human trailers",
550
+ _msg(
551
+ SUBJECTS[0],
552
+ *(f"Signed-off-by: {h.name} <{h.email}>" for h in HUMANS),
553
+ f"Co-authored-by: {claude}",
554
+ "Refs: #7",
555
+ ),
556
+ "block",
557
+ ["ai_coauthor"],
558
+ )
559
+ yield _case(
560
+ "variation-outside-block",
561
+ c,
562
+ "layout",
563
+ "Trailer in the body, not in the final paragraph",
564
+ _msg(
565
+ SUBJECTS[0], "Refs: #9", body=f"Co-authored-by: {claude}\n\nMore explanation follows."
566
+ ),
567
+ "block",
568
+ ["ai_coauthor"],
569
+ )
570
+ yield _case(
571
+ "variation-subject-trailer",
572
+ c,
573
+ "layout",
574
+ "Attribution as the subject line",
575
+ f"Co-authored-by: {claude}\n",
576
+ "block",
577
+ ["ai_coauthor"],
578
+ ["malformed_trailer"],
579
+ )
580
+ yield _case(
581
+ "variation-indented",
582
+ c,
583
+ "layout",
584
+ "Indented trailer line",
585
+ _msg(
586
+ SUBJECTS[0],
587
+ "Signed-off-by: Ada Lovelace <ada@example.com>",
588
+ f" Co-authored-by: {claude}",
589
+ ),
590
+ "block",
591
+ ["ai_coauthor"],
592
+ ["malformed_trailer"],
593
+ )
594
+ yield _case(
595
+ "variation-multiline-message",
596
+ c,
597
+ "layout",
598
+ "Long multi-paragraph message",
599
+ _msg(
600
+ SUBJECTS[0],
601
+ f"Co-authored-by: {claude}",
602
+ body="\n\n".join(f"Paragraph {i}: details." for i in range(40)),
603
+ ),
604
+ "block",
605
+ ["ai_coauthor"],
606
+ )
607
+ yield _case(
608
+ "variation-unicode-name",
609
+ c,
610
+ "unicode",
611
+ "Full-width letters in the name",
612
+ _msg(
613
+ SUBJECTS[0],
614
+ "Co-authored-by: \uff23\uff4c\uff41\uff55\uff44\uff45 <noreply@anthropic.com>",
615
+ ),
616
+ "block",
617
+ ["ai_coauthor"],
618
+ )
619
+ yield _case(
620
+ "variation-github-login",
621
+ c,
622
+ "email-format",
623
+ "GitHub noreply login form",
624
+ _msg(SUBJECTS[0], "Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>"),
625
+ "block",
626
+ ["ai_coauthor"],
627
+ )
628
+ yield _case(
629
+ "variation-automation-local-part",
630
+ c,
631
+ "email-format",
632
+ "Automation local part at a vendor domain",
633
+ _msg(SUBJECTS[0], "Co-authored-by: Assistant <bot@anthropic.com>"),
634
+ "block",
635
+ ["ai_coauthor"],
636
+ )
637
+ yield _case(
638
+ "variation-coauthor-alias-key",
639
+ c,
640
+ "key-alias",
641
+ "Co-author key alias",
642
+ _msg(SUBJECTS[0], f"Co-author: {claude}"),
643
+ "block",
644
+ ["ai_coauthor"],
645
+ ["malformed_trailer"],
646
+ )
647
+ yield _case(
648
+ "variation-bot-coauthor",
649
+ c,
650
+ "bot-identity",
651
+ "Automation account as co-author",
652
+ _msg(
653
+ SUBJECTS[5],
654
+ "Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>",
655
+ ),
656
+ "warn",
657
+ ["bot_identity"],
658
+ )
659
+
660
+
661
+ def _malformed() -> Iterator[DatasetCase]:
662
+ c: CaseClass = "malformed"
663
+ yield _case(
664
+ "malformed-empty-value",
665
+ c,
666
+ "empty",
667
+ "Co-authored-by with no value",
668
+ _msg(SUBJECTS[0], "Co-authored-by:"),
669
+ "warn",
670
+ ["malformed_trailer"],
671
+ )
672
+ yield _case(
673
+ "malformed-human-no-email",
674
+ c,
675
+ "identity",
676
+ "Human co-author without an email",
677
+ _msg(SUBJECTS[0], "Co-authored-by: Ada Lovelace"),
678
+ "warn",
679
+ ["malformed_trailer"],
680
+ )
681
+ yield _case(
682
+ "malformed-invalid-email",
683
+ c,
684
+ "identity",
685
+ "Co-author with an invalid address",
686
+ _msg(SUBJECTS[0], "Co-authored-by: <invalid>"),
687
+ "warn",
688
+ ["malformed_trailer"],
689
+ )
690
+ yield _case(
691
+ "malformed-empty-brackets",
692
+ c,
693
+ "identity",
694
+ "Human co-author with empty brackets",
695
+ _msg(SUBJECTS[0], "Co-authored-by: Grace Hopper <>"),
696
+ "warn",
697
+ ["malformed_trailer"],
698
+ )
699
+ yield _case(
700
+ "malformed-unbalanced",
701
+ c,
702
+ "identity",
703
+ "Unbalanced angle brackets",
704
+ _msg(SUBJECTS[0], "Co-authored-by: Grace Hopper <grace.hopper@example.org"),
705
+ "warn",
706
+ ["malformed_trailer"],
707
+ )
708
+ yield _case(
709
+ "malformed-no-separator-human",
710
+ c,
711
+ "separator",
712
+ "Human co-author without a colon",
713
+ _msg(SUBJECTS[0], "Co-authored-by Grace Hopper <grace.hopper@example.org>"),
714
+ "warn",
715
+ ["malformed_trailer"],
716
+ )
717
+ yield _case(
718
+ "malformed-signoff-no-colon",
719
+ c,
720
+ "separator",
721
+ "Signed-off-by without a colon",
722
+ _msg(SUBJECTS[0], "Signed-off-by Ada Lovelace <ada@example.com>"),
723
+ "warn",
724
+ ["malformed_trailer"],
725
+ )
726
+ yield _case(
727
+ "malformed-claude-no-email",
728
+ c,
729
+ "identity",
730
+ "Co-author named exactly Claude, no email (medium confidence)",
731
+ _msg(SUBJECTS[0], "Co-authored-by: Claude"),
732
+ "block",
733
+ ["ai_coauthor"],
734
+ ["malformed_trailer"],
735
+ )
736
+ yield _case(
737
+ "malformed-claude-no-brackets",
738
+ c,
739
+ "separator",
740
+ "No colon and no brackets around an AI email",
741
+ _msg(SUBJECTS[0], "CO-AUTHORED-BY Claude noreply@anthropic.com"),
742
+ "block",
743
+ ["ai_coauthor"],
744
+ ["malformed_trailer"],
745
+ )
746
+
747
+
748
+ def _adversarial() -> Iterator[DatasetCase]:
749
+ c: CaseClass = "adversarial"
750
+ claude = "Claude <noreply@anthropic.com>"
751
+ separators = {
752
+ "space-before-colon": "Co-authored-by : ",
753
+ "tab-before-colon": "Co-authored-by\t: ",
754
+ "no-space-after-colon": "Co-authored-by:",
755
+ "spaces-in-key": "Co authored by: ",
756
+ "underscores-in-key": "Co_authored_by: ",
757
+ "double-colon": "Co-authored-by:: ",
758
+ }
759
+ for key_id, prefix in separators.items():
760
+ yield _case(
761
+ f"adversarial-separator-{key_id}",
762
+ c,
763
+ "separator",
764
+ f"Evasive separator {prefix!r}",
765
+ _msg(SUBJECTS[0], prefix + claude),
766
+ "block",
767
+ ["ai_coauthor"],
768
+ ["malformed_trailer"],
769
+ )
770
+ invisible = {
771
+ "zero-width-space-key": "Co-authored\u200b-by: " + claude,
772
+ "zero-width-joiner-name": "Co-authored-by: Cl\u200daude <noreply@anthropic.com>",
773
+ "bidi-override-name": "Co-authored-by: \u202eClaude <noreply@anthropic.com>",
774
+ "soft-hyphen-key": "Co-authored\u00ad-by: " + claude,
775
+ "nbsp-separator": "Co-authored-by:\u00a0" + claude,
776
+ }
777
+ for key_id, line in invisible.items():
778
+ yield _case(
779
+ f"adversarial-invisible-{key_id}",
780
+ c,
781
+ "invisible",
782
+ f"Invisible characters: {key_id}",
783
+ _msg(SUBJECTS[0], line),
784
+ "block",
785
+ ["ai_coauthor"],
786
+ ["malformed_trailer"],
787
+ )
788
+ homoglyphs = {
789
+ "cyrillic-a": "Co-authored-by: Cl\u0430ude <noreply@anthropic.com>",
790
+ "greek-omicron-email": "Co-authored-by: Claude <n\u03bfreply@anthropic.com>",
791
+ "cyrillic-o-key": "C\u043e-authored-by: " + claude,
792
+ }
793
+ for key_id, line in homoglyphs.items():
794
+ yield _case(
795
+ f"adversarial-homoglyph-{key_id}",
796
+ c,
797
+ "homoglyph",
798
+ f"Look-alike letters: {key_id}",
799
+ _msg(SUBJECTS[0], line),
800
+ "block",
801
+ ["ai_coauthor"],
802
+ ["malformed_trailer"],
803
+ )
804
+ yield _case(
805
+ "adversarial-unicode-line-separator",
806
+ c,
807
+ "line-separator",
808
+ "U+2028 line separator before the trailer",
809
+ SUBJECTS[0] + "\u2028\u2028Co-authored-by: " + claude,
810
+ "block",
811
+ ["ai_coauthor"],
812
+ ["malformed_trailer"],
813
+ )
814
+ yield _case(
815
+ "adversarial-escape-sequences",
816
+ c,
817
+ "terminal",
818
+ "ANSI escape codes after the address",
819
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply@anthropic.com>\x1b[1A\x1b[2K"),
820
+ "block",
821
+ ["ai_coauthor"],
822
+ ["malformed_trailer"],
823
+ )
824
+ yield _case(
825
+ "adversarial-folded-continuation",
826
+ c,
827
+ "layout",
828
+ "Trailer hidden as an indented continuation",
829
+ _msg(SUBJECTS[0], "Refs: #7", " Co-authored-by: " + claude),
830
+ "block",
831
+ ["ai_coauthor"],
832
+ ["malformed_trailer"],
833
+ )
834
+ yield _case(
835
+ "adversarial-trailer-flood",
836
+ c,
837
+ "flood",
838
+ "1,200 human trailers before the attribution (beyond the parser bound)",
839
+ _msg(
840
+ SUBJECTS[0],
841
+ *(f"Signed-off-by: Person {i} <p{i}@example.com>" for i in range(1200)),
842
+ "Co-authored-by: " + claude,
843
+ ),
844
+ "block",
845
+ [],
846
+ ["ai_coauthor", "ai_identity", "ai_trailer", "bot_identity", "malformed_trailer"],
847
+ )
848
+ yield _case(
849
+ "adversarial-very-long-message",
850
+ c,
851
+ "size",
852
+ "1 MiB of text before the attribution",
853
+ _msg(SUBJECTS[0], "Co-authored-by: " + claude, body="x" * (1024 * 1024)),
854
+ "block",
855
+ ["ai_coauthor"],
856
+ )
857
+ yield _case(
858
+ "adversarial-huge-name",
859
+ c,
860
+ "size",
861
+ "A 100 KiB co-author name",
862
+ _msg(SUBJECTS[0], "Co-authored-by: " + "A" * (100 * 1024) + " <noreply@anthropic.com>"),
863
+ "block",
864
+ ["ai_coauthor"],
865
+ ["malformed_trailer"],
866
+ )
867
+ yield _case(
868
+ "adversarial-replacement-characters",
869
+ c,
870
+ "encoding",
871
+ "U+FFFD from malformed UTF-8 around the trailer",
872
+ _msg(SUBJECTS[0], "\ufffdCo-authored-by: " + claude + "\ufffd"),
873
+ "block",
874
+ ["ai_coauthor"],
875
+ ["malformed_trailer"],
876
+ )
877
+ yield _case(
878
+ "adversarial-html-entities",
879
+ c,
880
+ "encoding",
881
+ "HTML-escaped brackets",
882
+ _msg(SUBJECTS[0], "Co-authored-by: Claude &lt;noreply@anthropic.com&gt;"),
883
+ "block",
884
+ ["ai_coauthor"],
885
+ ["malformed_trailer"],
886
+ )
887
+ yield _case(
888
+ "adversarial-mailto",
889
+ c,
890
+ "email-format",
891
+ "mailto: inside the brackets",
892
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <mailto:noreply@anthropic.com>"),
893
+ "block",
894
+ ["ai_coauthor"],
895
+ ["malformed_trailer"],
896
+ )
897
+ yield _case(
898
+ "adversarial-plus-address",
899
+ c,
900
+ "email-format",
901
+ "Plus-addressed vendor email",
902
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply+x@anthropic.com>"),
903
+ "block",
904
+ ["ai_coauthor"],
905
+ ["malformed_trailer"],
906
+ )
907
+ yield _case(
908
+ "adversarial-subdomain",
909
+ c,
910
+ "email-format",
911
+ "Vendor subdomain email",
912
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply@mail.anthropic.com>"),
913
+ "block",
914
+ ["ai_coauthor"],
915
+ ["malformed_trailer"],
916
+ )
917
+ yield _case(
918
+ "adversarial-name-only-prefix",
919
+ c,
920
+ "identity",
921
+ "Distinctive name prefix with a non-vendor email",
922
+ _msg(SUBJECTS[0], "Co-authored-by: Claude Sonnet 4.5 <x@example.com>"),
923
+ "block",
924
+ ["ai_coauthor"],
925
+ )
926
+ yield _case(
927
+ "adversarial-shell-metacharacters",
928
+ c,
929
+ "injection",
930
+ "Shell and workflow-command metacharacters in the value",
931
+ _msg(
932
+ "feat: $(touch /tmp/pwned) `id`",
933
+ "Co-authored-by: Claude <noreply@anthropic.com>; rm -rf / #",
934
+ ),
935
+ "block",
936
+ ["ai_coauthor"],
937
+ ["malformed_trailer"],
938
+ )
939
+ yield _case(
940
+ "adversarial-workflow-command",
941
+ c,
942
+ "injection",
943
+ "A GitHub Actions workflow command in the subject",
944
+ _msg("::add-mask::secret", "Co-authored-by: " + claude),
945
+ "block",
946
+ ["ai_coauthor"],
947
+ )
948
+ yield _case(
949
+ "adversarial-null-character",
950
+ c,
951
+ "encoding",
952
+ "A NUL character inside the message",
953
+ _msg(SUBJECTS[0], "Co-authored-by: Claude\x00 <noreply@anthropic.com>"),
954
+ "block",
955
+ ["ai_coauthor"],
956
+ ["malformed_trailer"],
957
+ )
958
+
959
+
960
+ def _adversarial_1_1() -> Iterator[DatasetCase]:
961
+ c: CaseClass = "adversarial"
962
+ claude = "Claude <noreply@anthropic.com>"
963
+ prefixes = {
964
+ "question-mark": "?",
965
+ "quote": "> ",
966
+ "bullet": "\u2022 ",
967
+ "asterisk": "* ",
968
+ "hash": "#",
969
+ "guillemet": "\u00bb",
970
+ "double-replacement": "\ufffd\ufffd",
971
+ }
972
+ for key_id, prefix in prefixes.items():
973
+ yield _case(
974
+ f"adversarial-prefix-{key_id}",
975
+ c,
976
+ "leading-characters",
977
+ f"Symbols before the key: {prefix!r}",
978
+ _msg(SUBJECTS[0], prefix + "Co-authored-by: " + claude),
979
+ "block",
980
+ ["ai_coauthor"],
981
+ ["malformed_trailer"],
982
+ )
983
+ yield _case(
984
+ "adversarial-fullwidth-colon",
985
+ c,
986
+ "separator",
987
+ "Full-width colon separator",
988
+ _msg(SUBJECTS[0], "Co-authored-by\uff1a" + claude),
989
+ "block",
990
+ ["ai_coauthor"],
991
+ ["malformed_trailer"],
992
+ )
993
+ yield _case(
994
+ "adversarial-fullwidth-brackets",
995
+ c,
996
+ "email-format",
997
+ "Full-width angle brackets",
998
+ _msg(SUBJECTS[0], "Co-authored-by: Claude \uff1cnoreply@anthropic.com\uff1e"),
999
+ "block",
1000
+ ["ai_coauthor"],
1001
+ ["malformed_trailer"],
1002
+ )
1003
+ yield _case(
1004
+ "adversarial-quoted-name",
1005
+ c,
1006
+ "identity",
1007
+ "Quoted display name",
1008
+ _msg(SUBJECTS[0], 'Co-authored-by: "Claude" <noreply@anthropic.com>'),
1009
+ "block",
1010
+ ["ai_coauthor"],
1011
+ ["malformed_trailer"],
1012
+ )
1013
+ yield _case(
1014
+ "adversarial-name-comment",
1015
+ c,
1016
+ "identity",
1017
+ "Parenthesised comment in the name",
1018
+ _msg(SUBJECTS[0], "Co-authored-by: Claude (Anthropic) <noreply@anthropic.com>"),
1019
+ "block",
1020
+ ["ai_coauthor"],
1021
+ ["malformed_trailer"],
1022
+ )
1023
+ yield _case(
1024
+ "adversarial-trailing-dot-domain",
1025
+ c,
1026
+ "email-format",
1027
+ "Fully qualified domain with a trailing dot",
1028
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply@anthropic.com.>"),
1029
+ "block",
1030
+ ["ai_coauthor"],
1031
+ ["malformed_trailer"],
1032
+ )
1033
+ yield _case(
1034
+ "adversarial-two-addresses",
1035
+ c,
1036
+ "identity",
1037
+ "A second address after the AI address",
1038
+ _msg(SUBJECTS[0], "Co-authored-by: Claude <noreply@anthropic.com> <ada@example.com>"),
1039
+ "block",
1040
+ ["ai_coauthor"],
1041
+ ["malformed_trailer"],
1042
+ )
1043
+ yield _case(
1044
+ "adversarial-split-continuation",
1045
+ c,
1046
+ "layout",
1047
+ "Address folded onto a continuation line",
1048
+ _msg(SUBJECTS[0], "Co-authored-by: Claude", " <noreply@anthropic.com>"),
1049
+ "block",
1050
+ ["ai_coauthor"],
1051
+ ["malformed_trailer"],
1052
+ )
1053
+ yield _case(
1054
+ "adversarial-homoglyph-key-many",
1055
+ c,
1056
+ "homoglyph",
1057
+ "Several Cyrillic look-alikes in the key",
1058
+ _msg(SUBJECTS[0], "\u0421\u043e-\u0430uth\u043er\u0435d-b\u0443: " + claude),
1059
+ "block",
1060
+ ["ai_coauthor"],
1061
+ ["malformed_trailer"],
1062
+ )
1063
+ yield _case(
1064
+ "adversarial-author-invisible-email",
1065
+ c,
1066
+ "invisible",
1067
+ "Zero-width space inside the author email",
1068
+ _msg(SUBJECTS[1]),
1069
+ "block",
1070
+ ["ai_identity"],
1071
+ author=Person(name="Claude", email="no\u200breply@anthropic.com"),
1072
+ )
1073
+ yield _case(
1074
+ "adversarial-committer-alias",
1075
+ c,
1076
+ "identity",
1077
+ "Committer name is an agent alias with a personal email",
1078
+ _msg(SUBJECTS[1]),
1079
+ "block",
1080
+ ["ai_identity"],
1081
+ committer=Person(name="Claude Code", email="ada@example.com"),
1082
+ )
1083
+ yield _case(
1084
+ "adversarial-signoff-agent",
1085
+ c,
1086
+ "ai-trailer",
1087
+ "Signed-off-by names an AI agent",
1088
+ _msg(SUBJECTS[2], "Signed-off-by: " + claude),
1089
+ "block",
1090
+ ["ai_trailer"],
1091
+ )
1092
+ yield _case(
1093
+ "adversarial-footer-emoji",
1094
+ c,
1095
+ "tool-footer",
1096
+ "Tool footer with a different leading emoji",
1097
+ _msg(SUBJECTS[2], body="\u2728 Generated with Claude Code"),
1098
+ "block",
1099
+ ["ai_trailer"],
1100
+ )
1101
+
1102
+
1103
+ def _clean_1_1() -> Iterator[DatasetCase]:
1104
+ c: CaseClass = "clean"
1105
+ yield _case(
1106
+ "clean-quoted-signoff",
1107
+ c,
1108
+ "quoted",
1109
+ "A human sign-off quoted in the body",
1110
+ _msg(
1111
+ SUBJECTS[3],
1112
+ body="From the mailing list:\n\n> Signed-off-by: Ada Lovelace <ada@example.com>\n\nApplied with minor changes.",
1113
+ ),
1114
+ "allow",
1115
+ )
1116
+ yield _case(
1117
+ "clean-bullet-reviewer",
1118
+ c,
1119
+ "bulleted",
1120
+ "Bulleted human reviewer",
1121
+ _msg(
1122
+ SUBJECTS[3], body="Reviewers:\n- Reviewed-by: Grace Hopper <grace.hopper@example.org>"
1123
+ ),
1124
+ "allow",
1125
+ )
1126
+ yield _case(
1127
+ "clean-bullet-human-coauthor",
1128
+ c,
1129
+ "bulleted",
1130
+ "Bulleted human co-author",
1131
+ _msg(SUBJECTS[3], body="Pairing:\n* Co-authored-by: Alan Turing <alan@turing.example>"),
1132
+ "allow",
1133
+ )
1134
+ yield _case(
1135
+ "clean-dash-human-coauthor",
1136
+ c,
1137
+ "bulleted",
1138
+ "Dash-listed human co-author",
1139
+ _msg(SUBJECTS[3], body="Pairing:\n- Co-authored-by: Alan Turing <alan@turing.example>"),
1140
+ "allow",
1141
+ )
1142
+ yield _case(
1143
+ "clean-squash-merge",
1144
+ c,
1145
+ "merge",
1146
+ "GitHub squash merge with a human co-author",
1147
+ "feat: add rotation (#12)\n\n* feat: rotate on role change\n\n* fix: keep old sessions valid until expiry\n\n---------\n\nCo-authored-by: Grace Hopper <grace.hopper@example.org>\n",
1148
+ "allow",
1149
+ )
1150
+ yield _case(
1151
+ "clean-claude-monet",
1152
+ c,
1153
+ "near-miss",
1154
+ "A human named Claude Monet",
1155
+ _msg(SUBJECTS[4], "Co-authored-by: Claude Monet <claude@monet.example>"),
1156
+ "allow",
1157
+ )
1158
+ yield _case(
1159
+ "clean-web-flow-committer",
1160
+ c,
1161
+ "near-miss",
1162
+ "GitHub web-flow committer",
1163
+ _msg(SUBJECTS[4]),
1164
+ "allow",
1165
+ committer=Person(name="GitHub", email="noreply@github.com"),
1166
+ )
1167
+ yield _case(
1168
+ "clean-markdown-quote",
1169
+ c,
1170
+ "quoted",
1171
+ "A quoted discussion without trailers",
1172
+ _msg(SUBJECTS[5], body="> Should we use an AI reviewer?\n\nNot for this change."),
1173
+ "allow",
1174
+ )
1175
+
1176
+
1177
+ # --------------------------------------------------------------------------- #
1178
+ # Generated volume
1179
+ # --------------------------------------------------------------------------- #
1180
+ KEY_FORMS = ("Co-authored-by", "co-authored-by", "Co-Authored-By", "CO-AUTHORED-BY")
1181
+
1182
+
1183
+ def _generated(count: int, seed: int) -> Iterator[DatasetCase]:
1184
+ rng = random.Random(seed) # noqa: S311 - deterministic dataset, not security randomness
1185
+ c: CaseClass = "generated"
1186
+ for index in range(count):
1187
+ kind = rng.choices(("clean", "ai", "bot"), weights=(60, 35, 5))[0]
1188
+ subject = rng.choice(SUBJECTS)
1189
+ humans = rng.sample(HUMANS, k=rng.randint(0, 3))
1190
+ human_trailers = [
1191
+ f"{rng.choice(('Co-authored-by', 'Signed-off-by', 'Reviewed-by'))}: {h.name} <{h.email}>"
1192
+ for h in humans
1193
+ ]
1194
+ body = " ".join(
1195
+ rng.choice(("Update tests.", "Handle errors.", "Refactor.", "Add docs."))
1196
+ for _ in range(rng.randint(0, 20))
1197
+ )
1198
+ author = rng.choice(HUMANS)
1199
+ if kind == "clean":
1200
+ yield _case(
1201
+ f"generated-{index:05d}",
1202
+ c,
1203
+ "clean",
1204
+ "Generated human commit",
1205
+ _msg(subject, *human_trailers, body=body),
1206
+ "allow",
1207
+ author=author,
1208
+ )
1209
+ elif kind == "ai":
1210
+ agent, identity = rng.choice(AGENT_COAUTHORS)
1211
+ trailers = [*human_trailers, f"{rng.choice(KEY_FORMS)}: {identity}"]
1212
+ rng.shuffle(trailers)
1213
+ yield _case(
1214
+ f"generated-{index:05d}",
1215
+ c,
1216
+ "ai-coauthor",
1217
+ f"Generated AI co-author ({agent})",
1218
+ _msg(subject, *trailers, body=body),
1219
+ "block",
1220
+ ["ai_coauthor"],
1221
+ author=author,
1222
+ )
1223
+ else:
1224
+ bot = rng.choice(BOTS)
1225
+ yield _case(
1226
+ f"generated-{index:05d}",
1227
+ c,
1228
+ "bot-identity",
1229
+ "Generated automation account author",
1230
+ _msg("chore(deps): bump a dependency", body=body),
1231
+ "warn",
1232
+ ["bot_identity"],
1233
+ author=bot,
1234
+ )
1235
+
1236
+
1237
+ def _adversarial_1_2() -> Iterator[DatasetCase]:
1238
+ c: CaseClass = "adversarial"
1239
+ claude = "Claude <noreply@anthropic.com>"
1240
+ # Characters that are letters or numbers to Unicode (str.isalnum) or become ASCII under
1241
+ # NFKC, written directly before the key. Found by the Phase 10 property-based fuzzer.
1242
+ prefixes = {
1243
+ "circled-ideograph": "\u32ac",
1244
+ "circled-latin-letter": "\u24de",
1245
+ "circled-digit": "\u2460",
1246
+ "roman-numeral": "\u216b",
1247
+ "superscript-two": "\u00b2",
1248
+ "arabic-indic-digit": "\u0663",
1249
+ "cjk-ideograph": "\u91d1",
1250
+ "fullwidth-letter": "\uff58",
1251
+ "mixed-fuzzer-prefix": "\u24de\u0830\u02e5\u00b6\u0fc1\u0839\u07f8\u0375\u02d4",
1252
+ }
1253
+ for key_id, prefix in prefixes.items():
1254
+ yield _case(
1255
+ f"adversarial-alnum-prefix-{key_id}",
1256
+ c,
1257
+ "leading-characters",
1258
+ f"Unicode letter or number before the key: {prefix!r}",
1259
+ _msg(SUBJECTS[0], prefix + "Co-authored-by: " + claude),
1260
+ "block",
1261
+ ["ai_coauthor"],
1262
+ ["malformed_trailer"],
1263
+ )
1264
+ yield _case(
1265
+ "adversarial-alnum-prefix-random-case",
1266
+ c,
1267
+ "leading-characters",
1268
+ "Unicode number before a randomly cased key without a space after the colon",
1269
+ _msg(SUBJECTS[0], "\u32acCo-AUThorEd-By:" + claude),
1270
+ "block",
1271
+ ["ai_coauthor"],
1272
+ ["malformed_trailer"],
1273
+ )
1274
+ yield _case(
1275
+ "adversarial-alnum-prefix-among-human-trailers",
1276
+ c,
1277
+ "leading-characters",
1278
+ "Prefixed AI trailer between human trailers",
1279
+ _msg(
1280
+ SUBJECTS[0],
1281
+ "Signed-off-by: Ada Lovelace <ada@example.com>",
1282
+ "\u2460Co-authored-by: " + claude,
1283
+ "Reviewed-by: Grace Hopper <grace.hopper@example.org>",
1284
+ ),
1285
+ "block",
1286
+ ["ai_coauthor"],
1287
+ ["malformed_trailer"],
1288
+ )
1289
+
1290
+
1291
+ def _clean_1_2() -> Iterator[DatasetCase]:
1292
+ c: CaseClass = "clean"
1293
+ yield _case(
1294
+ "clean-numbered-human-coauthor",
1295
+ c,
1296
+ "bulleted",
1297
+ "Circled-number list of human co-authors",
1298
+ _msg(
1299
+ SUBJECTS[3],
1300
+ body="Pairing:\n\u2460 Co-authored-by: Alan Turing <alan@turing.example>\n\u2461 Co-authored-by: Grace Hopper <grace.hopper@example.org>",
1301
+ ),
1302
+ "allow",
1303
+ )
1304
+ yield _case(
1305
+ "clean-roman-numeral-reviewer",
1306
+ c,
1307
+ "bulleted",
1308
+ "Roman-numeral list item naming a human reviewer",
1309
+ _msg(SUBJECTS[3], body="\u216b. Reviewed-by: Grace Hopper <grace.hopper@example.org>"),
1310
+ "allow",
1311
+ )
1312
+ yield _case(
1313
+ "clean-non-ascii-prose-with-colon",
1314
+ c,
1315
+ "prose",
1316
+ "Non-ASCII prose lines that contain colons",
1317
+ _msg(
1318
+ SUBJECTS[3],
1319
+ body="Gr\u00f6\u00dfe: 5 MB\n\u65e5\u672c\u8a9e: \u30c6\u30b9\u30c8\nCaf\u00e9: open until 5",
1320
+ ),
1321
+ "allow",
1322
+ )
1323
+ yield _case(
1324
+ "clean-non-ascii-human-trailers",
1325
+ c,
1326
+ "identity",
1327
+ "Human trailers with non-ASCII names",
1328
+ _msg(
1329
+ SUBJECTS[3],
1330
+ "Co-authored-by: Jos\u00e9 N\u00fa\u00f1ez <jose@example.com>",
1331
+ "Signed-off-by: \u5c71\u7530\u592a\u90ce <yamada@example.jp>",
1332
+ ),
1333
+ "allow",
1334
+ )
1335
+
1336
+
1337
+ def _adversarial_1_3() -> Iterator[DatasetCase]:
1338
+ c: CaseClass = "adversarial"
1339
+ # Default-ignorable code points that are not control or format characters: they render
1340
+ # as nothing but survived normalisation. Found by the Phase 10 property-based fuzzer.
1341
+ ignorable = {
1342
+ "combining-grapheme-joiner": "͏",
1343
+ "variation-selector-16": "️",
1344
+ "variation-selector-17": "\U000e0100",
1345
+ "mongolian-variation-selector": "᠋",
1346
+ "hangul-filler": "ㅤ",
1347
+ "halfwidth-hangul-filler": "ᅠ",
1348
+ "khmer-inherent-vowel": "឴",
1349
+ }
1350
+ for key_id, char in ignorable.items():
1351
+ yield _case(
1352
+ f"adversarial-ignorable-key-{key_id}",
1353
+ c,
1354
+ "invisible-characters",
1355
+ f"Default-ignorable {char!r} inside the trailer key",
1356
+ _msg(SUBJECTS[0], f"Co-authored{char}-by: Claude <noreply@anthropic.com>"),
1357
+ "block",
1358
+ ["ai_coauthor"],
1359
+ ["malformed_trailer"],
1360
+ )
1361
+ yield _case(
1362
+ f"adversarial-ignorable-name-{key_id}",
1363
+ c,
1364
+ "invisible-characters",
1365
+ f"Default-ignorable {char!r} inside an agent alias with a personal email",
1366
+ _msg(SUBJECTS[0], f"Co-authored-by: Clau{char}de Code <dev@example.com>"),
1367
+ "block",
1368
+ ["ai_coauthor"],
1369
+ ["malformed_trailer"],
1370
+ )
1371
+ yield _case(
1372
+ "adversarial-ignorable-author-alias",
1373
+ c,
1374
+ "invisible-characters",
1375
+ "Author name is an agent alias containing a variation selector",
1376
+ _msg(SUBJECTS[1]),
1377
+ "block",
1378
+ ["ai_identity"],
1379
+ author=Person(name="Clau️de Code", email="dev@example.com"),
1380
+ )
1381
+
1382
+
1383
+ def _clean_1_3() -> Iterator[DatasetCase]:
1384
+ c: CaseClass = "clean"
1385
+ yield _case(
1386
+ "clean-emoji-variation-selector",
1387
+ c,
1388
+ "unicode",
1389
+ "Human commit whose subject and co-author name contain emoji variation selectors",
1390
+ _msg(
1391
+ "docs: add ❤️ to the changelog",
1392
+ "Co-authored-by: Grace Hopper ⭐️ <grace.hopper@example.org>",
1393
+ ),
1394
+ "allow",
1395
+ )
1396
+ yield _case(
1397
+ "clean-hangul-name",
1398
+ c,
1399
+ "identity",
1400
+ "Human Korean co-author",
1401
+ _msg(SUBJECTS[3], "Co-authored-by: 김민준 <minjun@example.kr>"),
1402
+ "allow",
1403
+ )
1404
+
1405
+
1406
+ # --------------------------------------------------------------------------- #
1407
+ # Dataset
1408
+ # --------------------------------------------------------------------------- #
1409
+ def build_dataset(
1410
+ *, version: str = DATASET_VERSION, generated: int = GENERATED_CASES, seed: int = DATASET_SEED
1411
+ ) -> list[DatasetCase]:
1412
+ if version not in DATASET_VERSIONS:
1413
+ raise ValueError(f"unknown dataset version {version}; known: {', '.join(DATASET_VERSIONS)}")
1414
+ cases = [*_clean(), *_violations(), *_variations(), *_malformed(), *_adversarial()]
1415
+ if version >= "1.1.0":
1416
+ cases += [*_clean_1_1(), *_adversarial_1_1()]
1417
+ if version >= "1.2.0":
1418
+ cases += [*_clean_1_2(), *_adversarial_1_2()]
1419
+ if version >= "1.3.0":
1420
+ cases += [*_clean_1_3(), *_adversarial_1_3()]
1421
+ cases += list(_generated(generated, seed))
1422
+ ids = [case.id for case in cases]
1423
+ duplicates = sorted({i for i in ids if ids.count(i) > 1}) if len(set(ids)) != len(ids) else []
1424
+ if duplicates:
1425
+ raise ValueError(f"duplicate case ids: {', '.join(duplicates)}")
1426
+ return cases
1427
+
1428
+
1429
+ def fingerprint(cases: Iterable[DatasetCase]) -> str:
1430
+ lines = (
1431
+ json.dumps(case.model_dump(mode="json"), sort_keys=True, ensure_ascii=True)
1432
+ for case in cases
1433
+ )
1434
+ return sha256_hex("\n".join(lines).encode("utf-8"))
1435
+
1436
+
1437
+ def write_dataset(
1438
+ cases: Sequence[DatasetCase], directory: Path, *, version: str = DATASET_VERSION
1439
+ ) -> dict[str, object]:
1440
+ """Write one JSONL file per class plus a manifest. Returns the manifest."""
1441
+ directory.mkdir(parents=True, exist_ok=True)
1442
+ counts: dict[str, int] = {}
1443
+ for case_class in CLASSES:
1444
+ selected = [case for case in cases if case.case_class == case_class]
1445
+ counts[case_class] = len(selected)
1446
+ target = directory / case_class / "cases.jsonl"
1447
+ target.parent.mkdir(parents=True, exist_ok=True)
1448
+ with target.open("w", encoding="utf-8", newline="\n") as handle:
1449
+ for case in selected:
1450
+ handle.write(
1451
+ json.dumps(case.model_dump(mode="json"), sort_keys=True, ensure_ascii=True)
1452
+ + "\n"
1453
+ )
1454
+ manifest: dict[str, object] = {
1455
+ "dataset": "commitguard-attribution",
1456
+ "version": version,
1457
+ "seed": DATASET_SEED,
1458
+ "cases": len(cases),
1459
+ "classes": counts,
1460
+ "positive": sum(1 for case in cases if case.positive),
1461
+ "negative": sum(1 for case in cases if not case.positive),
1462
+ "fingerprint": fingerprint(cases),
1463
+ }
1464
+ (directory / "manifest.json").write_text(
1465
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
1466
+ )
1467
+ return manifest
1468
+
1469
+
1470
+ def load_dataset(directory: Path) -> list[DatasetCase]:
1471
+ """Load a dataset written by :func:`write_dataset` (or any compatible JSONL set)."""
1472
+ cases: list[DatasetCase] = []
1473
+ files = sorted(directory.glob("*/cases.jsonl")) or sorted(directory.glob("*.jsonl"))
1474
+ if not files:
1475
+ raise ValueError(f"no dataset files (*/cases.jsonl) in {directory}")
1476
+ for path in files:
1477
+ with path.open(encoding="utf-8") as handle:
1478
+ for number, line in enumerate(handle, start=1):
1479
+ if line.strip():
1480
+ try:
1481
+ cases.append(DatasetCase.model_validate_json(line))
1482
+ except ValueError as exc:
1483
+ raise ValueError(f"{path}:{number}: invalid case: {exc}") from None
1484
+ return cases