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,53 @@
1
+ # Automation / bot identities. Bots are NOT AI agents.
2
+ #
3
+ # Only identities listed here produce bot_identity findings: there is no
4
+ # generic "[bot]" heuristic, because AI agent accounts use that suffix too and
5
+ # must be classified by ai-identities.yaml instead.
6
+ #
7
+ # Same matching semantics as ai-identities.yaml (names, emails, github_logins).
8
+
9
+ schema_version: 1
10
+
11
+ bots:
12
+ - id: dependabot
13
+ display_name: Dependabot
14
+ vendor: GitHub
15
+ names: ["dependabot", "dependabot[bot]", "dependabot-preview[bot]"]
16
+ github_logins: ["dependabot[bot]", "dependabot-preview[bot]"]
17
+ verified: true
18
+ reference: "49699333+dependabot[bot]@users.noreply.github.com"
19
+
20
+ - id: github_actions
21
+ display_name: GitHub Actions
22
+ vendor: GitHub
23
+ names: ["github-actions", "github-actions[bot]"]
24
+ github_logins: ["github-actions[bot]"]
25
+ verified: true
26
+ reference: "41898282+github-actions[bot]@users.noreply.github.com"
27
+
28
+ - id: renovate
29
+ display_name: Renovate
30
+ vendor: Mend
31
+ names: ["renovate", "renovate[bot]", "Renovate Bot"]
32
+ github_logins: ["renovate[bot]"]
33
+ emails: ["bot@renovateapp.com"]
34
+ verified: false
35
+
36
+ - id: pre_commit_ci
37
+ display_name: pre-commit.ci
38
+ vendor: pre-commit.ci
39
+ names: ["pre-commit-ci[bot]"]
40
+ github_logins: ["pre-commit-ci[bot]"]
41
+ verified: false
42
+
43
+ - id: release_bot
44
+ display_name: Release bot
45
+ names: ["release-bot", "semantic-release-bot"]
46
+ verified: false
47
+
48
+ - id: mergify
49
+ display_name: Mergify
50
+ vendor: Mergify
51
+ names: ["mergify[bot]"]
52
+ github_logins: ["mergify[bot]"]
53
+ verified: false
@@ -0,0 +1,52 @@
1
+ # Trailer and message patterns.
2
+ #
3
+ # All keys are canonical trailer keys (lowercase, hyphenated). Written keys are
4
+ # normalised before comparison, so "Co-Authored-By", "co_authored by" and
5
+ # keys containing zero-width characters all compare equal.
6
+ #
7
+ # There are no regular expressions here on purpose (no ReDoS on hostile commit
8
+ # messages) and no free-text heuristics: ordinary wording such as
9
+ # "feat: use AI service for recommendations" is never evidence.
10
+
11
+ schema_version: 1
12
+
13
+ # Trailers whose values are co-author identities (CoauthorDetector -> ai_coauthor).
14
+ coauthor_trailer_keys:
15
+ - co-authored-by
16
+ - coauthored-by
17
+ - co-author
18
+ - co-authored
19
+
20
+ # Other trailers that can carry AI attribution (TrailerDetector -> ai_trailer).
21
+ attribution_trailers:
22
+ - id: ai_generated_key
23
+ keys: [ai-generated-by, ai-assisted-by, ai-generated, ai-assisted, ai-co-authored-by]
24
+ match: key_present
25
+ description: The trailer key itself declares AI generation or assistance.
26
+
27
+ - id: generated_by_ai
28
+ keys: [generated-by, generated-with, assisted-by, created-by, made-with, co-developed-by]
29
+ match: ai_identity
30
+ description: A generation/assistance trailer names an AI agent.
31
+
32
+ - id: ai_reviewer_or_signoff
33
+ keys: [reviewed-by, signed-off-by, acked-by, tested-by, suggested-by, helped-by]
34
+ match: ai_identity
35
+ description: An AI agent is named in a review, sign-off or credit trailer.
36
+
37
+ # Trailers checked for malformed values (TrailerDetector -> malformed_trailer).
38
+ malformed_trailer_checks:
39
+ - keys: [co-authored-by, coauthored-by, co-author, co-authored]
40
+ require_identity: true # value must be "Name <email>"
41
+ - keys: [signed-off-by, reviewed-by, generated-by, assisted-by, ai-generated-by]
42
+ require_identity: false # only structural problems (missing colon, odd key)
43
+
44
+ # Exact, whole-line attribution footers inserted by tools (TrailerDetector ->
45
+ # ai_trailer). Compared after normalisation with leading emoji/symbols removed.
46
+ message_markers:
47
+ - id: claude_code_footer
48
+ agent: claude
49
+ lines:
50
+ - "Generated with [Claude Code](https://claude.com/claude-code)"
51
+ - "Generated with [Claude Code](https://claude.ai/code)"
52
+ - "Generated with Claude Code"
@@ -0,0 +1,102 @@
1
+ """Load and validate rule files.
2
+
3
+ Rule files are data, parsed with the strict safe YAML loader and validated
4
+ against :mod:`commitguard.rules.models`. Built-in rules ship inside the wheel
5
+ (``commitguard/rules/data``); in a source checkout they are read from the
6
+ repository's top-level ``rules/`` directory.
7
+ """
8
+
9
+ import hashlib
10
+ from functools import cache
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+ from pydantic import BaseModel, ValidationError
15
+
16
+ from commitguard.exceptions.base import UnsafeInputError
17
+ from commitguard.exceptions.configuration import RulesError
18
+ from commitguard.rules.matcher import CompiledRules
19
+ from commitguard.rules.models import (
20
+ AI_DOMAINS_FILE,
21
+ AI_IDENTITIES_FILE,
22
+ BOT_IDENTITIES_FILE,
23
+ PATTERNS_FILE,
24
+ AIDomainRules,
25
+ AIIdentityRules,
26
+ BotIdentityRules,
27
+ PatternRules,
28
+ RuleSet,
29
+ )
30
+ from commitguard.security.safe_yaml import load_yaml
31
+ from commitguard.utils.filesystem import read_text_limited
32
+
33
+ MAX_RULE_FILE_BYTES = 1024 * 1024
34
+
35
+
36
+ def builtin_rules_dir() -> Path:
37
+ """Directory containing the built-in rule files."""
38
+ packaged = Path(__file__).resolve().parent / "data"
39
+ if packaged.is_dir():
40
+ return packaged
41
+ source_checkout = Path(__file__).resolve().parents[3] / "rules"
42
+ if (source_checkout / AI_IDENTITIES_FILE).is_file():
43
+ return source_checkout
44
+ raise RulesError("built-in rule files not found (broken installation?)")
45
+
46
+
47
+ def _load_file[M: BaseModel](directory: Path, filename: str, model: type[M]) -> M:
48
+ path = directory / filename
49
+ try:
50
+ text = read_text_limited(path, max_bytes=MAX_RULE_FILE_BYTES)
51
+ document = load_yaml(text)
52
+ return model.model_validate(document)
53
+ except FileNotFoundError as exc:
54
+ raise RulesError("rule file not found", path=path) from exc
55
+ except (OSError, UnsafeInputError) as exc:
56
+ raise RulesError(str(exc), path=path) from exc
57
+ except yaml.YAMLError as exc:
58
+ raise RulesError(f"invalid YAML: {exc}", path=path) from exc
59
+ except ValidationError as exc:
60
+ raise RulesError(_format(exc), path=path) from exc
61
+
62
+
63
+ def load_rules(directory: Path) -> CompiledRules:
64
+ """Load, validate and cross-check every rule file in ``directory``."""
65
+ try:
66
+ rules = RuleSet(
67
+ ai_identities=_load_file(directory, AI_IDENTITIES_FILE, AIIdentityRules),
68
+ ai_domains=_load_file(directory, AI_DOMAINS_FILE, AIDomainRules),
69
+ bots=_load_file(directory, BOT_IDENTITIES_FILE, BotIdentityRules),
70
+ patterns=_load_file(directory, PATTERNS_FILE, PatternRules),
71
+ )
72
+ return CompiledRules(rules)
73
+ except ValidationError as exc:
74
+ raise RulesError(_format(exc), path=directory) from exc
75
+ except ValueError as exc: # conflicting aliases detected while compiling
76
+ raise RulesError(str(exc), path=directory) from exc
77
+
78
+
79
+ @cache
80
+ def builtin_rules_fingerprint() -> str:
81
+ """SHA-256 over the built-in rule files: the rules version recorded with each scan."""
82
+ directory = builtin_rules_dir()
83
+ digest = hashlib.sha256()
84
+ for name in sorted((AI_IDENTITIES_FILE, AI_DOMAINS_FILE, BOT_IDENTITIES_FILE, PATTERNS_FILE)):
85
+ data = read_text_limited(directory / name, max_bytes=MAX_RULE_FILE_BYTES).encode("utf-8")
86
+ digest.update(f"{name}\0{len(data)}\0".encode())
87
+ digest.update(data)
88
+ return digest.hexdigest()
89
+
90
+
91
+ @cache
92
+ def load_builtin_rules() -> CompiledRules:
93
+ """Load the built-in rules once per process."""
94
+ return load_rules(builtin_rules_dir())
95
+
96
+
97
+ def _format(exc: ValidationError) -> str:
98
+ lines = ["invalid rules:"]
99
+ for error in exc.errors(include_url=False, include_input=False):
100
+ location = ".".join(str(part) for part in error["loc"]) or "<root>"
101
+ lines.append(f" - {location}: {error['msg']}")
102
+ return "\n".join(lines)
@@ -0,0 +1,212 @@
1
+ """Deterministic identity matching against rule data.
2
+
3
+ Matching is exact on normalised values (see
4
+ :mod:`commitguard.provenance.normalization`) - never substring or fuzzy - and
5
+ combines evidence deliberately:
6
+
7
+ ========================================== ============ ==================
8
+ Evidence Alone Confidence
9
+ ========================================== ============ ==================
10
+ exact configured email match high
11
+ GitHub login (noreply address or name) match high
12
+ distinctive name prefix (``Claude Opus``) match high
13
+ automation email at vendor domain match high
14
+ full-name alias (``Claude``) match medium
15
+ full-name alias + vendor domain match high
16
+ ambiguous alias (``Devin``) + vendor domain match medium
17
+ ambiguous alias alone no match -
18
+ vendor domain alone (``jane@anthropic.com``) no match -
19
+ ========================================== ============ ==================
20
+ """
21
+
22
+ from collections import defaultdict
23
+ from collections.abc import Callable, Iterable, Sequence
24
+
25
+ from pydantic import BaseModel, ConfigDict
26
+
27
+ from commitguard.core.result import Confidence, MatchKind, MatchReason
28
+ from commitguard.provenance.author import github_login
29
+ from commitguard.provenance.normalization import (
30
+ name_tokens,
31
+ normalize_domain,
32
+ normalize_email,
33
+ normalize_name,
34
+ )
35
+ from commitguard.rules.models import (
36
+ AI_DOMAINS_FILE,
37
+ AI_IDENTITIES_FILE,
38
+ BOT_IDENTITIES_FILE,
39
+ AIDomainRules,
40
+ DomainRule,
41
+ IdentityRule,
42
+ RuleSet,
43
+ )
44
+
45
+ _STRONG = frozenset(
46
+ {
47
+ MatchKind.EMAIL,
48
+ MatchKind.GITHUB_LOGIN,
49
+ MatchKind.NAME_PREFIX,
50
+ MatchKind.AUTOMATION_EMAIL,
51
+ }
52
+ )
53
+
54
+
55
+ class IdentityMatch(BaseModel):
56
+ """The rule an identity matched, how confidently, and why."""
57
+
58
+ model_config = ConfigDict(frozen=True, extra="forbid")
59
+
60
+ rule_id: str
61
+ display_name: str
62
+ vendor: str
63
+ confidence: Confidence
64
+ reasons: tuple[MatchReason, ...]
65
+
66
+
67
+ class IdentityMatcher:
68
+ """Match ``(name, email)`` pairs against identity rules (and optional domains)."""
69
+
70
+ def __init__(
71
+ self,
72
+ rules: Sequence[IdentityRule],
73
+ *,
74
+ source: str,
75
+ domains: AIDomainRules | None = None,
76
+ domain_source: str = "",
77
+ ) -> None:
78
+ self._rules = {rule.id: rule for rule in rules}
79
+ self._source = source
80
+ self._domain_source = domain_source
81
+ self._names = _index(rules, lambda r: r.names, normalize_name)
82
+ self._ambiguous = _index(rules, lambda r: r.ambiguous_names, normalize_name)
83
+ self._emails = _index(rules, lambda r: r.emails, normalize_email)
84
+ self._logins = _index(rules, lambda r: r.github_logins, normalize_name)
85
+ self._prefixes = sorted(
86
+ (name_tokens(prefix), rule.id) for rule in rules for prefix in rule.name_prefixes
87
+ )
88
+ self._automation_local_parts = frozenset(domains.automation_local_parts if domains else ())
89
+ self._domains: dict[str, DomainRule] = {
90
+ d.domain: d for d in (domains.domains if domains else ()) if d.agent in self._rules
91
+ }
92
+
93
+ def match(self, name: str | None, email: str | None) -> IdentityMatch | None:
94
+ reasons: dict[str, list[MatchReason]] = defaultdict(list)
95
+
96
+ if name:
97
+ normalized = normalize_name(name)
98
+ self._lookup(reasons, self._names, normalized, MatchKind.NAME)
99
+ self._lookup(reasons, self._ambiguous, normalized, MatchKind.AMBIGUOUS_NAME)
100
+ if normalized.endswith("[bot]"): # a GitHub App account name used verbatim
101
+ self._lookup(reasons, self._logins, normalized, MatchKind.GITHUB_LOGIN)
102
+ tokens = tuple(normalized.split())
103
+ for prefix, rule_id in self._prefixes:
104
+ if tokens[: len(prefix)] == prefix and len(tokens) >= len(prefix):
105
+ reasons[rule_id].append(
106
+ self._reason(MatchKind.NAME_PREFIX, " ".join(prefix), rule_id)
107
+ )
108
+
109
+ if email:
110
+ normalized_email = normalize_email(email)
111
+ self._lookup(reasons, self._emails, normalized_email, MatchKind.EMAIL)
112
+ login = github_login(normalized_email)
113
+ if login is not None:
114
+ self._lookup(reasons, self._logins, normalize_name(login), MatchKind.GITHUB_LOGIN)
115
+ self._match_domain(reasons, normalized_email)
116
+
117
+ candidates = [
118
+ match
119
+ for rule_id, rule_reasons in reasons.items()
120
+ if (match := self._evaluate(rule_id, rule_reasons)) is not None
121
+ ]
122
+ if not candidates:
123
+ return None
124
+ # Deterministic: strongest confidence, then most reasons, then rule id.
125
+ return min(candidates, key=lambda m: (-m.confidence.rank, -len(m.reasons), m.rule_id))
126
+
127
+ # ------------------------------------------------------------------ #
128
+ def _reason(self, kind: MatchKind, value: str, rule_id: str) -> MatchReason:
129
+ return MatchReason(kind=kind, value=value, rule=f"{self._source}#{rule_id}")
130
+
131
+ def _lookup(
132
+ self,
133
+ reasons: dict[str, list[MatchReason]],
134
+ index: dict[str, str],
135
+ value: str,
136
+ kind: MatchKind,
137
+ ) -> None:
138
+ rule_id = index.get(value)
139
+ if rule_id is not None:
140
+ reasons[rule_id].append(self._reason(kind, value, rule_id))
141
+
142
+ def _match_domain(self, reasons: dict[str, list[MatchReason]], email: str) -> None:
143
+ local, _, domain = email.rpartition("@")
144
+ domain = normalize_domain(domain)
145
+ rule = self._domains.get(domain)
146
+ if rule is None:
147
+ rule = next(
148
+ (
149
+ d
150
+ for d in self._domains.values()
151
+ if d.include_subdomains and domain.endswith("." + d.domain)
152
+ ),
153
+ None,
154
+ )
155
+ if rule is None or not local:
156
+ return
157
+ base_local = local.split("+", 1)[0]
158
+ if rule.local_parts == "any" or base_local in self._automation_local_parts:
159
+ kind, value = MatchKind.AUTOMATION_EMAIL, email
160
+ else:
161
+ kind, value = MatchKind.VENDOR_DOMAIN, domain
162
+ reference = f"{self._domain_source}#{rule.domain}"
163
+ reasons[rule.agent].append(MatchReason(kind=kind, value=value, rule=reference))
164
+
165
+ def _evaluate(self, rule_id: str, rule_reasons: list[MatchReason]) -> IdentityMatch | None:
166
+ kinds = {reason.kind for reason in rule_reasons}
167
+ if kinds & _STRONG or {MatchKind.NAME, MatchKind.VENDOR_DOMAIN} <= kinds:
168
+ confidence = Confidence.HIGH
169
+ elif (
170
+ MatchKind.NAME in kinds or {MatchKind.AMBIGUOUS_NAME, MatchKind.VENDOR_DOMAIN} <= kinds
171
+ ):
172
+ confidence = Confidence.MEDIUM
173
+ else:
174
+ return None
175
+ rule = self._rules[rule_id]
176
+ return IdentityMatch(
177
+ rule_id=rule.id,
178
+ display_name=rule.display_name,
179
+ vendor=rule.vendor,
180
+ confidence=confidence,
181
+ reasons=tuple(dict.fromkeys(rule_reasons)),
182
+ )
183
+
184
+
185
+ def _index(
186
+ rules: Iterable[IdentityRule],
187
+ values: Callable[[IdentityRule], tuple[str, ...]],
188
+ normalize: Callable[[str], str],
189
+ ) -> dict[str, str]:
190
+ index: dict[str, str] = {}
191
+ for rule in rules:
192
+ for value in values(rule):
193
+ key = normalize(value)
194
+ existing = index.setdefault(key, rule.id)
195
+ if existing != rule.id:
196
+ raise ValueError(f"{value!r} is claimed by both {existing!r} and {rule.id!r}")
197
+ return index
198
+
199
+
200
+ class CompiledRules:
201
+ """A validated :class:`RuleSet` plus the matchers built from it (pure, no I/O)."""
202
+
203
+ def __init__(self, rules: RuleSet) -> None:
204
+ self.rules = rules
205
+ self.ai_matcher = IdentityMatcher(
206
+ rules.ai_identities.agents,
207
+ source=AI_IDENTITIES_FILE,
208
+ domains=rules.ai_domains,
209
+ domain_source=AI_DOMAINS_FILE,
210
+ )
211
+ self.bot_matcher = IdentityMatcher(rules.bots.bots, source=BOT_IDENTITIES_FILE)
212
+ self.agents = {agent.id: agent for agent in rules.ai_identities.agents}
@@ -0,0 +1,269 @@
1
+ """Schemas for rule files. Every model forbids unknown keys."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ from commitguard.provenance.author import is_plausible_email
8
+ from commitguard.provenance.normalization import (
9
+ normalize_domain,
10
+ normalize_email,
11
+ normalize_name,
12
+ normalize_text,
13
+ normalize_trailer_key,
14
+ )
15
+ from commitguard.security.validation import validate_identifier
16
+
17
+ _STRICT = ConfigDict(frozen=True, extra="forbid")
18
+
19
+ AI_IDENTITIES_FILE = "ai-identities.yaml"
20
+ AI_DOMAINS_FILE = "ai-domains.yaml"
21
+ BOT_IDENTITIES_FILE = "bot-identities.yaml"
22
+ PATTERNS_FILE = "patterns.yaml"
23
+
24
+
25
+ def _non_empty_after_normalization(values: tuple[str, ...], kind: str) -> tuple[str, ...]:
26
+ for value in values:
27
+ if not normalize_text(value):
28
+ raise ValueError(f"{kind} entries must not be empty")
29
+ return values
30
+
31
+
32
+ class IdentityRule(BaseModel):
33
+ """An identity (AI agent or bot) described by aliases and addresses.
34
+
35
+ * ``names`` - full-name aliases; a match alone is evidence (medium confidence).
36
+ * ``ambiguous_names`` - aliases that are also common human names; only count
37
+ together with an address/domain of the same agent.
38
+ * ``name_prefixes`` - distinctive leading words (``Claude Opus`` matches
39
+ ``Claude Opus 4.5``); a match alone is strong evidence.
40
+ * ``emails`` / ``github_logins`` - exact identifiers; strong evidence.
41
+ """
42
+
43
+ model_config = _STRICT
44
+
45
+ id: str
46
+ display_name: str = Field(min_length=1)
47
+ vendor: str = ""
48
+ names: tuple[str, ...] = ()
49
+ ambiguous_names: tuple[str, ...] = ()
50
+ name_prefixes: tuple[str, ...] = ()
51
+ emails: tuple[str, ...] = ()
52
+ github_logins: tuple[str, ...] = ()
53
+ verified: bool = False
54
+ reference: str = ""
55
+
56
+ @field_validator("id")
57
+ @classmethod
58
+ def _valid_id(cls, value: str) -> str:
59
+ return validate_identifier(value, kind="rule id")
60
+
61
+ @field_validator("names", "ambiguous_names", "name_prefixes", "github_logins")
62
+ @classmethod
63
+ def _non_empty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
64
+ return _non_empty_after_normalization(value, "name/login")
65
+
66
+ @field_validator("emails")
67
+ @classmethod
68
+ def _valid_emails(cls, value: tuple[str, ...]) -> tuple[str, ...]:
69
+ for email in value:
70
+ if not is_plausible_email(normalize_email(email)):
71
+ raise ValueError(f"invalid email {email!r}")
72
+ return value
73
+
74
+ @model_validator(mode="after")
75
+ def _has_identifiers(self) -> "IdentityRule":
76
+ if not (self.names or self.name_prefixes or self.emails or self.github_logins):
77
+ raise ValueError(f"rule {self.id!r} needs names, name_prefixes, emails or logins")
78
+ return self
79
+
80
+
81
+ class AIIdentityRules(BaseModel):
82
+ model_config = _STRICT
83
+
84
+ schema_version: Literal[1]
85
+ agents: tuple[IdentityRule, ...] = Field(min_length=1)
86
+
87
+
88
+ class DomainRule(BaseModel):
89
+ """A vendor email domain associated with one agent.
90
+
91
+ A domain alone is never enough: ``local_parts: automation`` (the default)
92
+ means only automation-style local parts (``noreply@``) are strong evidence;
93
+ other addresses at the domain only corroborate a name match.
94
+ """
95
+
96
+ model_config = _STRICT
97
+
98
+ domain: str
99
+ agent: str
100
+ local_parts: Literal["automation", "any"] = "automation"
101
+ include_subdomains: bool = False
102
+
103
+ @field_validator("domain")
104
+ @classmethod
105
+ def _valid_domain(cls, value: str) -> str:
106
+ normalized = normalize_domain(value)
107
+ if "." not in normalized or normalized != value:
108
+ raise ValueError(f"domain {value!r} must be lowercase and fully qualified")
109
+ return value
110
+
111
+
112
+ class AIDomainRules(BaseModel):
113
+ model_config = _STRICT
114
+
115
+ schema_version: Literal[1]
116
+ automation_local_parts: tuple[str, ...] = Field(min_length=1)
117
+ domains: tuple[DomainRule, ...] = ()
118
+
119
+ @field_validator("automation_local_parts")
120
+ @classmethod
121
+ def _normalized_local_parts(cls, value: tuple[str, ...]) -> tuple[str, ...]:
122
+ for part in value:
123
+ if not part or part != normalize_text(part) or "@" in part or " " in part:
124
+ raise ValueError(f"invalid automation local part {part!r}")
125
+ return value
126
+
127
+
128
+ class BotIdentityRules(BaseModel):
129
+ model_config = _STRICT
130
+
131
+ schema_version: Literal[1]
132
+ bots: tuple[IdentityRule, ...] = ()
133
+
134
+
135
+ def _canonical_keys(keys: tuple[str, ...]) -> tuple[str, ...]:
136
+ for key in keys:
137
+ if key != normalize_trailer_key(key) or not key:
138
+ raise ValueError(f"trailer key {key!r} must be canonical (e.g. 'co-authored-by')")
139
+ return keys
140
+
141
+
142
+ class TrailerRule(BaseModel):
143
+ """A trailer that indicates AI attribution.
144
+
145
+ ``match: key_present`` - the key itself is attribution (``AI-generated-by``).
146
+ ``match: ai_identity`` - only when the value matches an AI identity rule
147
+ (``Generated-by: protoc`` is fine, ``Generated-by: Claude Code`` is not).
148
+ """
149
+
150
+ model_config = _STRICT
151
+
152
+ id: str
153
+ keys: tuple[str, ...] = Field(min_length=1)
154
+ match: Literal["key_present", "ai_identity"]
155
+ description: str = Field(min_length=1)
156
+ ignore_values: tuple[str, ...] = Field(
157
+ default=("false", "no", "none", "n/a", "0"),
158
+ description="Values (normalised) that negate the key, e.g. 'AI-assisted: no'",
159
+ )
160
+
161
+ @field_validator("id")
162
+ @classmethod
163
+ def _valid_id(cls, value: str) -> str:
164
+ return validate_identifier(value, kind="trailer rule id")
165
+
166
+ @field_validator("keys")
167
+ @classmethod
168
+ def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
169
+ return _canonical_keys(value)
170
+
171
+
172
+ class MalformedTrailerCheck(BaseModel):
173
+ """Which trailer keys are checked for malformed structure/identities."""
174
+
175
+ model_config = _STRICT
176
+
177
+ keys: tuple[str, ...] = Field(min_length=1)
178
+ require_identity: bool
179
+
180
+ @field_validator("keys")
181
+ @classmethod
182
+ def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
183
+ return _canonical_keys(value)
184
+
185
+
186
+ class MessageMarkerRule(BaseModel):
187
+ """Exact attribution lines inserted by tools (compared after normalisation).
188
+
189
+ Lines must match *entirely*; there is no substring search, so ordinary
190
+ wording such as "use AI service for recommendations" never matches.
191
+ """
192
+
193
+ model_config = _STRICT
194
+
195
+ id: str
196
+ agent: str
197
+ lines: tuple[str, ...] = Field(min_length=1)
198
+
199
+ @field_validator("id")
200
+ @classmethod
201
+ def _valid_id(cls, value: str) -> str:
202
+ return validate_identifier(value, kind="marker id")
203
+
204
+
205
+ class PatternRules(BaseModel):
206
+ model_config = _STRICT
207
+
208
+ schema_version: Literal[1]
209
+ coauthor_trailer_keys: tuple[str, ...] = Field(min_length=1)
210
+ attribution_trailers: tuple[TrailerRule, ...] = ()
211
+ malformed_trailer_checks: tuple[MalformedTrailerCheck, ...] = ()
212
+ message_markers: tuple[MessageMarkerRule, ...] = ()
213
+
214
+ @field_validator("coauthor_trailer_keys")
215
+ @classmethod
216
+ def _canonical(cls, value: tuple[str, ...]) -> tuple[str, ...]:
217
+ return _canonical_keys(value)
218
+
219
+
220
+ class RuleSet(BaseModel):
221
+ """All rule files, cross-validated."""
222
+
223
+ model_config = _STRICT
224
+
225
+ ai_identities: AIIdentityRules
226
+ ai_domains: AIDomainRules
227
+ bots: BotIdentityRules
228
+ patterns: PatternRules
229
+
230
+ @model_validator(mode="after")
231
+ def _cross_references(self) -> "RuleSet":
232
+ agent_ids = [agent.id for agent in self.ai_identities.agents]
233
+ _require_unique(agent_ids, "agent id")
234
+ _require_unique([bot.id for bot in self.bots.bots], "bot id")
235
+ known = set(agent_ids)
236
+ for domain in self.ai_domains.domains:
237
+ if domain.agent not in known:
238
+ raise ValueError(f"domain {domain.domain!r} references unknown agent")
239
+ _require_unique([d.domain for d in self.ai_domains.domains], "domain")
240
+ for marker in self.patterns.message_markers:
241
+ if marker.agent not in known:
242
+ raise ValueError(f"message marker {marker.id!r} references unknown agent")
243
+ _require_unique([r.id for r in self.patterns.attribution_trailers], "trailer rule id")
244
+ _require_unique(
245
+ [key for rule in self.patterns.attribution_trailers for key in rule.keys],
246
+ "attribution trailer key",
247
+ )
248
+ coauthor = set(self.patterns.coauthor_trailer_keys)
249
+ for rule in self.patterns.attribution_trailers:
250
+ if coauthor & set(rule.keys):
251
+ raise ValueError("co-author keys are handled by the coauthor detector")
252
+ # Aliases shared between agents would make attribution ambiguous.
253
+ _require_unique(
254
+ [
255
+ normalize_name(alias)
256
+ for agent in self.ai_identities.agents
257
+ for alias in (*agent.names, *agent.ambiguous_names, *agent.name_prefixes)
258
+ ],
259
+ "AI agent alias",
260
+ )
261
+ return self
262
+
263
+
264
+ def _require_unique(values: list[str], kind: str) -> None:
265
+ seen: set[str] = set()
266
+ for value in values:
267
+ if value in seen:
268
+ raise ValueError(f"duplicate {kind} {value!r}")
269
+ seen.add(value)
@@ -0,0 +1,5 @@
1
+ """Security primitives: input validation, output sanitisation, and hashing.
2
+
3
+ Everything that originates from a commit (names, emails, messages, trailers)
4
+ or from a configuration file is treated as untrusted.
5
+ """