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,46 @@
1
+ """Policy models."""
2
+
3
+ from collections.abc import Iterator, Mapping
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+ from commitguard.core.decision import Action
8
+ from commitguard.security.validation import validate_identifier
9
+
10
+
11
+ class Policy(BaseModel):
12
+ """The effective policy for a single rule ID."""
13
+
14
+ model_config = ConfigDict(frozen=True, extra="forbid")
15
+
16
+ id: str
17
+ enabled: bool = True
18
+ action: Action
19
+ description: str = Field(min_length=1)
20
+
21
+ @field_validator("id")
22
+ @classmethod
23
+ def _validate_id(cls, value: str) -> str:
24
+ return validate_identifier(value, kind="policy id")
25
+
26
+
27
+ class PolicySet(Mapping[str, Policy]):
28
+ """An immutable mapping of rule ID to effective :class:`Policy`."""
29
+
30
+ def __init__(self, policies: Mapping[str, Policy]) -> None:
31
+ for key, policy in policies.items():
32
+ if key != policy.id:
33
+ raise ValueError(f"policy key {key!r} does not match policy id {policy.id!r}")
34
+ self._policies = dict(sorted(policies.items()))
35
+
36
+ def __getitem__(self, key: str) -> Policy:
37
+ return self._policies[key]
38
+
39
+ def __iter__(self) -> Iterator[str]:
40
+ return iter(self._policies)
41
+
42
+ def __len__(self) -> int:
43
+ return len(self._policies)
44
+
45
+ def __repr__(self) -> str:
46
+ return f"PolicySet({list(self._policies)})"
@@ -0,0 +1,9 @@
1
+ """Provenance layer: who (or what) contributed a commit, and how we know.
2
+
3
+ This layer models and (eventually) analyses the *claims* a commit makes about
4
+ its origin: author, committer, co-authors, trailers and signatures. It is kept
5
+ separate from detectors so that provenance analysis can grow (signature
6
+ verification, identity correlation) without detectors re-implementing parsing.
7
+
8
+ Nothing in this package performs I/O or talks to Git.
9
+ """
@@ -0,0 +1,146 @@
1
+ """Contributor identities.
2
+
3
+ An identity is a *claimed* ``name <email>`` pair. Git performs no verification
4
+ of these values, so they are untrusted evidence, never proof of authorship.
5
+
6
+ Two models exist on purpose:
7
+
8
+ * :class:`Identity` - an author/committer as recorded in a commit object,
9
+ where Git guarantees both fields exist (they may still be empty or odd);
10
+ * :class:`ParsedIdentity` - the best-effort parse of free text such as a
11
+ ``Co-authored-by`` value, where either part may be missing or malformed.
12
+ """
13
+
14
+ from enum import StrEnum
15
+
16
+ from pydantic import BaseModel, ConfigDict
17
+
18
+ from commitguard.provenance.normalization import normalize_email
19
+
20
+ GITHUB_NOREPLY_DOMAIN = "users.noreply.github.com"
21
+
22
+
23
+ class Identity(BaseModel):
24
+ """A Git identity recorded in a commit (author or committer)."""
25
+
26
+ model_config = ConfigDict(frozen=True, extra="forbid")
27
+
28
+ name: str
29
+ email: str
30
+
31
+ def __str__(self) -> str:
32
+ return f"{self.name} <{self.email}>"
33
+
34
+
35
+ class IdentityIssue(StrEnum):
36
+ """Why a free-text identity is not a well-formed ``Name <email>``."""
37
+
38
+ EMPTY = "empty"
39
+ MISSING_NAME = "missing_name"
40
+ MISSING_EMAIL = "missing_email"
41
+ MISSING_BRACKETS = "missing_brackets"
42
+ UNBALANCED_BRACKETS = "unbalanced_brackets"
43
+ INVALID_EMAIL = "invalid_email"
44
+ TRAILING_CONTENT = "trailing_content"
45
+
46
+
47
+ class ParsedIdentity(BaseModel):
48
+ """Best-effort parse of an identity string. Never raises on bad input."""
49
+
50
+ model_config = ConfigDict(frozen=True, extra="forbid")
51
+
52
+ name: str | None
53
+ email: str | None
54
+ issues: tuple[IdentityIssue, ...] = ()
55
+
56
+ @property
57
+ def well_formed(self) -> bool:
58
+ return not self.issues
59
+
60
+ def __str__(self) -> str:
61
+ if self.email is None:
62
+ return self.name or ""
63
+ return f"{self.name or ''} <{self.email}>".strip()
64
+
65
+
66
+ def is_plausible_email(value: str) -> bool:
67
+ """Structural email check: one ``@``, non-empty local part and dotted domain.
68
+
69
+ Intentionally not RFC 5322: the goal is to classify evidence, not to
70
+ validate deliverability.
71
+ """
72
+ local, sep, domain = value.rpartition("@")
73
+ return (
74
+ bool(sep)
75
+ and bool(local)
76
+ and "@" not in local
77
+ and "." in domain.strip(".")
78
+ and not any(ch.isspace() or ch in "<>" for ch in value)
79
+ )
80
+
81
+
82
+ def parse_identity(value: str) -> ParsedIdentity:
83
+ """Parse ``Name <email>`` leniently, recording every deviation as an issue.
84
+
85
+ Examples::
86
+
87
+ "Claude <noreply@anthropic.com>" -> name, email, no issues
88
+ "Claude" -> name only, MISSING_EMAIL
89
+ "Claude noreply@anthropic.com" -> name, email, MISSING_BRACKETS
90
+ "<invalid>" -> email "invalid", MISSING_NAME, INVALID_EMAIL
91
+ "Claude <>" -> name only, MISSING_EMAIL
92
+ """
93
+ text = value.strip()
94
+ if not text:
95
+ return ParsedIdentity(name=None, email=None, issues=(IdentityIssue.EMPTY,))
96
+
97
+ issues: list[IdentityIssue] = []
98
+ open_index = text.find("<")
99
+ close_index = text.find(">", open_index + 1) if open_index >= 0 else -1
100
+
101
+ if open_index >= 0 and close_index > open_index:
102
+ name = text[:open_index].strip() or None
103
+ email = text[open_index + 1 : close_index].strip() or None
104
+ trailing = text[close_index + 1 :].strip()
105
+ if trailing:
106
+ issues.append(IdentityIssue.TRAILING_CONTENT)
107
+ if name is not None and (">" in name or "<" in name):
108
+ issues.append(IdentityIssue.UNBALANCED_BRACKETS)
109
+ elif open_index >= 0 or ">" in text:
110
+ issues.append(IdentityIssue.UNBALANCED_BRACKETS)
111
+ stripped = text.replace("<", " ").replace(">", " ")
112
+ name, email = _split_bare_email(stripped)
113
+ else:
114
+ name, email = _split_bare_email(text)
115
+ if email is not None:
116
+ issues.append(IdentityIssue.MISSING_BRACKETS)
117
+
118
+ if name is None:
119
+ issues.append(IdentityIssue.MISSING_NAME)
120
+ if email is None:
121
+ issues.append(IdentityIssue.MISSING_EMAIL)
122
+ elif not is_plausible_email(email):
123
+ issues.append(IdentityIssue.INVALID_EMAIL)
124
+
125
+ return ParsedIdentity(name=name, email=email, issues=tuple(dict.fromkeys(issues)))
126
+
127
+
128
+ def _split_bare_email(text: str) -> tuple[str | None, str | None]:
129
+ """Split ``Name user@host`` on the last whitespace token containing ``@``."""
130
+ tokens = text.split()
131
+ for index in range(len(tokens) - 1, -1, -1):
132
+ if "@" in tokens[index]:
133
+ name = " ".join(tokens[:index] + tokens[index + 1 :]) or None
134
+ return name, tokens[index]
135
+ return (" ".join(tokens) or None), None
136
+
137
+
138
+ def github_login(email: str) -> str | None:
139
+ """Return the GitHub login from a ``[id+]login@users.noreply.github.com`` address."""
140
+ local, sep, domain = normalize_email(email).rpartition("@")
141
+ if not sep or domain != GITHUB_NOREPLY_DOMAIN or not local:
142
+ return None
143
+ user_id, plus, login = local.partition("+")
144
+ if plus:
145
+ return login if user_id.isdigit() and login else None
146
+ return local
@@ -0,0 +1,16 @@
1
+ """Committer provenance analysis.
2
+
3
+ The committer is the identity that *created the commit object*, which may
4
+ differ from the author (rebases, cherry-picks, patches applied by maintainers,
5
+ or web UIs such as GitHub's ``web-flow``). Automation frequently reveals itself
6
+ here rather than in the author field.
7
+
8
+ TODO(phase-5): analyse author/committer relationships, e.g.
9
+ * committer is a known automation identity while author is a human;
10
+ * committer identity differs from the signing key identity;
11
+ * implausible author/committer timestamp gaps.
12
+ """
13
+
14
+ from commitguard.provenance.author import Identity
15
+
16
+ __all__ = ["Identity"]
@@ -0,0 +1,158 @@
1
+ """Deterministic normalisation of untrusted identity text for *matching*.
2
+
3
+ Normalised values are only ever used as comparison keys. Evidence shown to
4
+ users always keeps the original (sanitised) text.
5
+
6
+ What is normalised, and why:
7
+
8
+ * Unicode NFKC - fullwidth / mathematical bold letters fold to ASCII;
9
+ * control (Cc) and format (Cf) characters removed - zero-width spaces, bidi
10
+ overrides and escape codes cannot split a name to evade matching;
11
+ * every other Unicode default-ignorable code point removed - variation
12
+ selectors, U+034F COMBINING GRAPHEME JOINER and the Hangul fillers render as
13
+ nothing too (found by property-based fuzzing: ``Clau\u034fde`` and
14
+ ``Co-authored\ufe0f-by`` evaded detection);
15
+ * case folding and whitespace collapsing;
16
+ * a *small, explicit* map of Cyrillic/Greek letters that are visually
17
+ identical to Latin letters (``Claude`` spelled with a Cyrillic ``a``, U+0430).
18
+
19
+ What is deliberately **not** done: removing punctuation, stemming, substring
20
+ or fuzzy matching. ``Claude`` must never match ``Claudette``.
21
+ """
22
+
23
+ import unicodedata
24
+
25
+ # Visually identical to Latin letters in common fonts. Lowercase only: input is
26
+ # case-folded first. Keep this list conservative; every entry is a potential
27
+ # false-positive source for legitimate non-Latin names.
28
+ # Code points (not literal characters) so the source contains no look-alikes.
29
+ _HOMOGLYPHS: dict[int, str] = {
30
+ 0x0430: "a", # CYRILLIC SMALL LETTER A
31
+ 0x0432: "b", # CYRILLIC SMALL LETTER VE
32
+ 0x0435: "e", # CYRILLIC SMALL LETTER IE
33
+ 0x04BB: "h", # CYRILLIC SMALL LETTER SHHA
34
+ 0x0456: "i", # CYRILLIC SMALL LETTER BYELORUSSIAN-UKRAINIAN I
35
+ 0x0458: "j", # CYRILLIC SMALL LETTER JE
36
+ 0x043A: "k", # CYRILLIC SMALL LETTER KA
37
+ 0x043C: "m", # CYRILLIC SMALL LETTER EM
38
+ 0x043E: "o", # CYRILLIC SMALL LETTER O
39
+ 0x0440: "p", # CYRILLIC SMALL LETTER ER
40
+ 0x0441: "c", # CYRILLIC SMALL LETTER ES
41
+ 0x0455: "s", # CYRILLIC SMALL LETTER DZE
42
+ 0x0442: "t", # CYRILLIC SMALL LETTER TE
43
+ 0x0443: "y", # CYRILLIC SMALL LETTER U
44
+ 0x0445: "x", # CYRILLIC SMALL LETTER HA
45
+ 0x0501: "d", # CYRILLIC SMALL LETTER KOMI DE
46
+ 0x051B: "q", # CYRILLIC SMALL LETTER QA
47
+ 0x051D: "w", # CYRILLIC SMALL LETTER WE
48
+ 0x03B1: "a", # GREEK SMALL LETTER ALPHA
49
+ 0x03B5: "e", # GREEK SMALL LETTER EPSILON
50
+ 0x03B9: "i", # GREEK SMALL LETTER IOTA
51
+ 0x03BA: "k", # GREEK SMALL LETTER KAPPA
52
+ 0x03BD: "v", # GREEK SMALL LETTER NU
53
+ 0x03BF: "o", # GREEK SMALL LETTER OMICRON
54
+ 0x03C1: "p", # GREEK SMALL LETTER RHO
55
+ 0x03C4: "t", # GREEK SMALL LETTER TAU
56
+ 0x03C5: "u", # GREEK SMALL LETTER UPSILON
57
+ 0x03C7: "x", # GREEK SMALL LETTER CHI
58
+ 0x0131: "i", # LATIN SMALL LETTER DOTLESS I
59
+ 0x0237: "j", # LATIN SMALL LETTER DOTLESS J
60
+ }
61
+
62
+
63
+ # ASCII control characters that are not whitespace: the only Cc/Cf characters ASCII has.
64
+ _ASCII_INVISIBLE = {code: None for code in (*range(0x00, 0x20), 0x7F) if not chr(code).isspace()}
65
+
66
+
67
+ # Unicode Default_Ignorable_Code_Point (DerivedCoreProperties.txt) that are not Cc or Cf:
68
+ # they render as nothing in normal text, like zero-width spaces, but NFKC keeps them.
69
+ _DEFAULT_IGNORABLE_RANGES = (
70
+ (0x034F, 0x034F), # COMBINING GRAPHEME JOINER
71
+ (0x115F, 0x1160), # HANGUL CHOSEONG / JUNGSEONG FILLER
72
+ (0x17B4, 0x17B5), # KHMER VOWEL INHERENT AQ / AA
73
+ (0x180B, 0x180F), # MONGOLIAN FREE VARIATION SELECTORS, VOWEL SEPARATOR
74
+ (0x2065, 0x2065), # unassigned
75
+ (0x3164, 0x3164), # HANGUL FILLER
76
+ (0xFE00, 0xFE0F), # VARIATION SELECTOR-1..16
77
+ (0xFFA0, 0xFFA0), # HALFWIDTH HANGUL FILLER
78
+ (0xFFF0, 0xFFF8), # unassigned
79
+ (0xE0000, 0xE0FFF), # tags, VARIATION SELECTOR-17..256, unassigned
80
+ )
81
+ _DEFAULT_IGNORABLE = frozenset(
82
+ code for first, last in _DEFAULT_IGNORABLE_RANGES for code in range(first, last + 1)
83
+ )
84
+
85
+
86
+ def _visible(ch: str) -> bool:
87
+ if ch.isspace():
88
+ return True
89
+ return unicodedata.category(ch) not in ("Cc", "Cf") and ord(ch) not in _DEFAULT_IGNORABLE
90
+
91
+
92
+ def _strip_invisible(text: str) -> str:
93
+ # Whitespace controls (tab, newline, ...) are kept so they still separate words.
94
+ if text.isascii():
95
+ # Fast path, same result: ASCII has no format (Cf) or default-ignorable characters.
96
+ # Measured by the performance benchmark: per-character categorisation dominated
97
+ # large messages.
98
+ return text if text.isprintable() else text.translate(_ASCII_INVISIBLE)
99
+ return "".join(ch for ch in text if _visible(ch))
100
+
101
+
102
+ def normalize_text(text: str) -> str:
103
+ """Return a case-, width- and whitespace-insensitive comparison key."""
104
+ if text.isascii():
105
+ text = _strip_invisible(text) # NFKC leaves ASCII unchanged
106
+ else:
107
+ text = _strip_invisible(unicodedata.normalize("NFKC", _strip_invisible(text)))
108
+ text = text.casefold().translate(_HOMOGLYPHS)
109
+ return " ".join(text.split())
110
+
111
+
112
+ def is_latin_lookalike(char: str) -> bool:
113
+ """True if ``char`` is a non-ASCII look-alike that :func:`normalize_text` folds to Latin."""
114
+ folded = char.casefold()
115
+ return not char.isascii() and folded.translate(_HOMOGLYPHS) != folded
116
+
117
+
118
+ def normalize_name(name: str) -> str:
119
+ """Normalise a person/agent display name for exact alias comparison."""
120
+ return normalize_text(name)
121
+
122
+
123
+ def name_tokens(name: str) -> tuple[str, ...]:
124
+ """Whitespace tokens of a normalised name (for leading-token prefix rules)."""
125
+ return tuple(normalize_name(name).split())
126
+
127
+
128
+ def normalize_email(email: str) -> str:
129
+ """Normalise an email address for comparison.
130
+
131
+ Case-folded as a whole (local parts are case-sensitive in theory, never in
132
+ practice for the providers relevant here); a trailing dot on the domain is
133
+ removed. No other rewriting (e.g. dot removal) is performed.
134
+ """
135
+ email = normalize_text(email).replace(" ", "")
136
+ local, sep, domain = email.rpartition("@")
137
+ if not sep:
138
+ return email
139
+ return f"{local}@{domain.rstrip('.')}"
140
+
141
+
142
+ def normalize_domain(domain: str) -> str:
143
+ return normalize_text(domain).replace(" ", "").strip(".")
144
+
145
+
146
+ def normalize_trailer_key(key: str) -> str:
147
+ """Canonical trailer key: ``Co_Authored By`` -> ``co-authored-by``."""
148
+ text = normalize_text(key).replace("_", " ").replace("-", " ")
149
+ return "-".join(text.split())
150
+
151
+
152
+ def uses_disguising_characters(text: str) -> bool:
153
+ """True if matching ``text`` relied on folding look-alike or invisible characters.
154
+
155
+ Plain case and whitespace differences do not count; NFKC compatibility
156
+ forms, control/format and other default-ignorable characters and homoglyphs do.
157
+ """
158
+ return normalize_text(text) != " ".join(text.casefold().split())
@@ -0,0 +1,34 @@
1
+ """Commit signature provenance.
2
+
3
+ TODO(phase-5): read signature status via Git (``%G?``, ``%GK``, ``%GS``) for
4
+ GPG, SSH and X.509 signatures, and let policies require verified signatures.
5
+ Verification depends on the local keyring / allowed-signers configuration,
6
+ which is itself a trust decision and must be explicit in configuration.
7
+ """
8
+
9
+ from enum import StrEnum
10
+
11
+ from pydantic import BaseModel, ConfigDict
12
+
13
+
14
+ class SignatureStatus(StrEnum):
15
+ """Signature state of a commit, mirroring Git's ``%G?`` codes."""
16
+
17
+ GOOD = "good" # G
18
+ BAD = "bad" # B
19
+ UNKNOWN_VALIDITY = "unknown_validity" # U
20
+ EXPIRED = "expired" # X
21
+ EXPIRED_KEY = "expired_key" # Y
22
+ REVOKED_KEY = "revoked_key" # R
23
+ CANNOT_CHECK = "cannot_check" # E
24
+ UNSIGNED = "unsigned" # N
25
+
26
+
27
+ class SignatureInfo(BaseModel):
28
+ """Signature metadata for a commit. Not populated yet (security intelligence phase)."""
29
+
30
+ model_config = ConfigDict(frozen=True, extra="forbid")
31
+
32
+ status: SignatureStatus
33
+ key_id: str | None = None
34
+ signer: str | None = None
@@ -0,0 +1,256 @@
1
+ """Commit message trailer parsing.
2
+
3
+ Trailers are ``Key: value`` lines, conventionally in the last paragraph of a
4
+ commit message. They are the primary place AI agents record attribution::
5
+
6
+ feat: implement authentication
7
+
8
+ Co-authored-by: Claude <noreply@anthropic.com>
9
+
10
+ Parsing is lenient and never raises. Because attribution can be hidden or
11
+ mangled deliberately, the parser also records:
12
+
13
+ * trailer-formatted lines **outside** the final paragraph
14
+ (``in_trailer_block=False``) - Git ignores them, a reader does not;
15
+ * ``*-by`` keys written without a colon (``Co-authored-by Claude ...``) or with
16
+ spaces/underscores/invisible characters in the key;
17
+ * indented lines that are themselves trailers, instead of folding them into
18
+ the previous value as a continuation (which would hide them);
19
+ * every Unicode line separator (``\\u2028``, ``\\r``...), not only ``\\n``;
20
+ * keys preceded by symbols or punctuation (``\\ufffdCo-authored-by:``,
21
+ ``> Co-authored-by:``, ``• Co-authored-by:``): the prefix is ignored and the
22
+ trailer is recorded with ``LEADING_CHARACTERS`` (found by the detection
23
+ benchmark: a replacement character from malformed UTF-8 hid attribution);
24
+ the same applies to non-ASCII letters and numbers (``\\u32acCo-authored-by:``,
25
+ found by property-based fuzzing), because keys are ASCII.
26
+
27
+ Work is linear in the message size and bounded to :data:`MAX_TRAILERS`
28
+ trailers; anything beyond sets ``truncated`` so callers can fail closed.
29
+ No regular expressions are used.
30
+ """
31
+
32
+ import unicodedata
33
+ from dataclasses import dataclass, field
34
+ from enum import StrEnum
35
+
36
+ from pydantic import BaseModel, ConfigDict, Field
37
+
38
+ from commitguard.provenance.author import ParsedIdentity, parse_identity
39
+ from commitguard.provenance.normalization import is_latin_lookalike, normalize_trailer_key
40
+
41
+ COAUTHOR_TRAILER_KEY = "co-authored-by"
42
+ MAX_TRAILERS = 1000
43
+ MAX_KEY_LENGTH = 64
44
+ #: At most this many leading symbol/punctuation characters are skipped before a key.
45
+ MAX_LEADING_CHARACTERS = 16
46
+
47
+
48
+ class TrailerIssue(StrEnum):
49
+ """Structural problems with a trailer line."""
50
+
51
+ MISSING_SEPARATOR = "missing_separator"
52
+ NONSTANDARD_KEY = "nonstandard_key"
53
+ EMPTY_VALUE = "empty_value"
54
+ LEADING_CHARACTERS = "leading_characters"
55
+
56
+
57
+ class Trailer(BaseModel):
58
+ """A trailer (or trailer-like line) found in a commit message."""
59
+
60
+ model_config = ConfigDict(frozen=True, extra="forbid")
61
+
62
+ key: str = Field(description="Key as written (after Unicode NFKC)")
63
+ value: str
64
+ raw: str = Field(description="Original line text, for evidence")
65
+ line_number: int = Field(ge=1, description="1-based line number in the message")
66
+ in_trailer_block: bool = Field(description="True if in Git's trailer block (final paragraph)")
67
+ issues: tuple[TrailerIssue, ...] = ()
68
+ identity: ParsedIdentity
69
+
70
+ @property
71
+ def normalized_key(self) -> str:
72
+ return normalize_trailer_key(self.key)
73
+
74
+ @property
75
+ def name(self) -> str | None:
76
+ return self.identity.name
77
+
78
+ @property
79
+ def email(self) -> str | None:
80
+ return self.identity.email
81
+
82
+ @property
83
+ def well_formed(self) -> bool:
84
+ return not self.issues
85
+
86
+
87
+ class ParsedTrailers(BaseModel):
88
+ model_config = ConfigDict(frozen=True, extra="forbid")
89
+
90
+ trailers: tuple[Trailer, ...] = ()
91
+ truncated: bool = False
92
+
93
+
94
+ @dataclass
95
+ class _Draft:
96
+ key: str
97
+ value_parts: list[str]
98
+ raw_parts: list[str]
99
+ line_number: int
100
+ last_line: int
101
+ paragraph: int
102
+ issues: list[TrailerIssue] = field(default_factory=list)
103
+
104
+
105
+ def _is_standard_key(key: str) -> bool:
106
+ return (
107
+ 0 < len(key) <= MAX_KEY_LENGTH
108
+ and key[0].isascii()
109
+ and key[0].isalnum()
110
+ and all((ch.isascii() and ch.isalnum()) or ch == "-" for ch in key)
111
+ )
112
+
113
+
114
+ def _is_by_key(key: str) -> bool:
115
+ """A ``*-by`` key possibly written with spaces, underscores or invisible chars."""
116
+ if not 0 < len(key) <= MAX_KEY_LENGTH:
117
+ return False
118
+ normalized = normalize_trailer_key(key)
119
+ return (
120
+ normalized.endswith("-by")
121
+ and len(normalized) > 3
122
+ and all((ch.isascii() and ch.isalnum()) or ch == "-" for ch in normalized)
123
+ )
124
+
125
+
126
+ def _is_ascii_alnum(char: str) -> bool:
127
+ return char.isascii() and char.isalnum()
128
+
129
+
130
+ def _starts_key(char: str) -> bool:
131
+ # Look-alike letters are part of a disguised key (U+0421 in "Co-authored-by"), not a prefix.
132
+ return _is_ascii_alnum(char) or is_latin_lookalike(char)
133
+
134
+
135
+ def _skip_leading(text: str) -> str:
136
+ start = 0
137
+ while start < len(text) and start < MAX_LEADING_CHARACTERS and not _starts_key(text[start]):
138
+ start += 1
139
+ return text[start:].strip()
140
+
141
+
142
+ def _parse_line(line: str) -> tuple[str, str, list[TrailerIssue]] | None:
143
+ text = unicodedata.normalize("NFKC", line).strip()
144
+ if not text:
145
+ return None
146
+ stripped = line.strip()
147
+ if _is_ascii_alnum(stripped[0]):
148
+ # The common case, including every well-formed trailer: no prefix to consider.
149
+ return _parse_text(text) if _is_ascii_alnum(text[0]) else None
150
+ # Characters before the key are not part of it: symbols and punctuation ("> ", "- ",
151
+ # U+FFFD) and non-ASCII letters or numbers (U+32AC, U+2460) must neither hide a
152
+ # trailer nor turn a quoted or listed line into a malformed key. Keys are ASCII, so the
153
+ # prefix is whatever precedes the first ASCII letter or digit, judged both after NFKC
154
+ # (U+32AC becomes a CJK ideograph) and before it (U+2460 becomes "1", U+24DE a plain
155
+ # "o"). When the readings disagree, the shortest key wins: it attributes the fewest
156
+ # prefix characters to the key.
157
+ readings = [
158
+ _skip_leading(text),
159
+ unicodedata.normalize("NFKC", _skip_leading(stripped)).strip(),
160
+ ]
161
+ if _is_ascii_alnum(text[0]):
162
+ readings.append(text)
163
+ best: tuple[str, str, list[TrailerIssue]] | None = None
164
+ for candidate in dict.fromkeys(readings):
165
+ if not candidate or not _starts_key(candidate[0]):
166
+ continue
167
+ parsed = _parse_text(candidate)
168
+ if parsed is None or (best is not None and len(parsed[0]) >= len(best[0])):
169
+ continue
170
+ key, value, issues = parsed
171
+ prefixed = candidate != text
172
+ best = (key, value, [*issues, TrailerIssue.LEADING_CHARACTERS] if prefixed else issues)
173
+ return best
174
+
175
+
176
+ def _parse_text(text: str) -> tuple[str, str, list[TrailerIssue]] | None:
177
+
178
+ colon = text.find(":")
179
+ if 0 < colon <= MAX_KEY_LENGTH + 8:
180
+ written_key = text[:colon]
181
+ key = written_key.rstrip()
182
+ value = text[colon + 1 :].strip()
183
+ if _is_standard_key(key) and not value.startswith("//"): # skip "https://..."
184
+ issues = [] if key == written_key else [TrailerIssue.NONSTANDARD_KEY]
185
+ return key, value, issues
186
+ if _is_by_key(key):
187
+ return key, value, [TrailerIssue.NONSTANDARD_KEY]
188
+
189
+ # "Co-authored-by Claude <...>" / "Co-authored-by=Claude"
190
+ head = text.split(None, 1)[0]
191
+ key, eq, after_eq = head.partition("=")
192
+ if _is_by_key(key):
193
+ rest = (after_eq + text[len(head) :]) if eq else text[len(head) :]
194
+ value = rest.strip().lstrip("=").strip()
195
+ if value:
196
+ return key, value, [TrailerIssue.MISSING_SEPARATOR]
197
+ return None
198
+
199
+
200
+ def parse_trailers(message: str) -> ParsedTrailers:
201
+ """Extract trailers from a raw commit message. Never raises."""
202
+ drafts: list[_Draft] = []
203
+ truncated = False
204
+ paragraph = 0
205
+ previous_blank = True
206
+ last_content_paragraph = 0
207
+
208
+ for line_number, line in enumerate(message.splitlines(), start=1):
209
+ if not line.strip():
210
+ if not previous_blank:
211
+ paragraph += 1
212
+ previous_blank = True
213
+ continue
214
+ previous_blank = False
215
+ last_content_paragraph = paragraph
216
+
217
+ parsed = _parse_line(line)
218
+ if parsed is not None and line_number == 1 and not _is_by_key(parsed[0]):
219
+ parsed = None # the subject ("feat: ...") is not a trailer
220
+ if parsed is None:
221
+ continuation = line[:1].isspace()
222
+ if continuation and drafts and drafts[-1].last_line == line_number - 1:
223
+ # Collected as parts and joined once: repeated string concatenation
224
+ # would be quadratic on messages with many continuation lines.
225
+ drafts[-1].value_parts.append(line.strip())
226
+ drafts[-1].raw_parts.append(line.strip())
227
+ drafts[-1].last_line = line_number
228
+ continue
229
+
230
+ if len(drafts) >= MAX_TRAILERS:
231
+ truncated = True
232
+ break
233
+ key, value, issues = parsed
234
+ drafts.append(
235
+ _Draft(key, [value], [line.strip()], line_number, line_number, paragraph, issues)
236
+ )
237
+
238
+ trailers = []
239
+ for draft in drafts:
240
+ value = " ".join(part for part in draft.value_parts if part)
241
+ issues = list(draft.issues)
242
+ if not value:
243
+ issues.append(TrailerIssue.EMPTY_VALUE)
244
+ trailers.append(
245
+ Trailer(
246
+ key=draft.key,
247
+ value=value,
248
+ raw=" ".join(draft.raw_parts),
249
+ line_number=draft.line_number,
250
+ in_trailer_block=draft.paragraph == last_content_paragraph
251
+ and last_content_paragraph > 0,
252
+ issues=tuple(issues),
253
+ identity=parse_identity(value),
254
+ )
255
+ )
256
+ return ParsedTrailers(trailers=tuple(trailers), truncated=truncated)