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,6 @@
1
+ """Core domain: scan context, findings, decisions and the detection engine.
2
+
3
+ The core knows nothing about the CLI, hooks, or how commits were obtained. It
4
+ receives a :class:`~commitguard.core.context.CommitContext`, runs registered
5
+ detectors, and returns a :class:`~commitguard.core.result.DetectionResult`.
6
+ """
@@ -0,0 +1,31 @@
1
+ """Commit context: everything a detector is allowed to see.
2
+
3
+ Detectors receive a context rather than a repository handle. This keeps them
4
+ pure (no I/O, cannot modify the repository) and trivially unit-testable.
5
+ """
6
+
7
+ from enum import StrEnum
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from commitguard.git.commit import Commit
12
+
13
+
14
+ class ScanTrigger(StrEnum):
15
+ """Why an analysis is running."""
16
+
17
+ MANUAL = "manual"
18
+ CHECK = "check"
19
+ PRE_COMMIT = "pre-commit"
20
+ COMMIT_MSG = "commit-msg"
21
+ PRE_PUSH = "pre-push"
22
+ CI = "ci"
23
+
24
+
25
+ class CommitContext(BaseModel):
26
+ """Immutable input to a single detection run over one commit."""
27
+
28
+ model_config = ConfigDict(frozen=True, extra="forbid")
29
+
30
+ commit: Commit
31
+ trigger: ScanTrigger = ScanTrigger.MANUAL
@@ -0,0 +1,58 @@
1
+ """Security decisions.
2
+
3
+ A :class:`Decision` is the policy engine's verdict over a :class:`DetectionResult`,
4
+ with an explanation for every finding and failure that contributed to it.
5
+ """
6
+
7
+ from enum import StrEnum
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from commitguard.core.result import DetectorFailure, Finding
12
+
13
+
14
+ class Action(StrEnum):
15
+ """What to do about a finding. Ordered from least to most restrictive."""
16
+
17
+ ALLOW = "allow"
18
+ WARN = "warn"
19
+ BLOCK = "block"
20
+
21
+ @property
22
+ def rank(self) -> int:
23
+ return _ACTION_RANK[self]
24
+
25
+ @classmethod
26
+ def most_restrictive(cls, actions: "list[Action]") -> "Action":
27
+ return max(actions, key=lambda action: action.rank, default=cls.ALLOW)
28
+
29
+
30
+ _ACTION_RANK = {action: index for index, action in enumerate(Action)}
31
+
32
+
33
+ class Explanation(BaseModel):
34
+ """Why a particular action was taken for one finding or failure."""
35
+
36
+ model_config = ConfigDict(frozen=True, extra="forbid")
37
+
38
+ action: Action
39
+ reason: str
40
+ policy_id: str | None = None
41
+ finding: Finding | None = None
42
+ failure: DetectorFailure | None = None
43
+
44
+
45
+ class Decision(BaseModel):
46
+ """Final verdict for one scan, with full explanations."""
47
+
48
+ model_config = ConfigDict(frozen=True, extra="forbid")
49
+
50
+ action: Action
51
+ explanations: tuple[Explanation, ...] = ()
52
+
53
+ @property
54
+ def blocked(self) -> bool:
55
+ return self.action is Action.BLOCK
56
+
57
+ def explanations_for(self, action: Action) -> tuple[Explanation, ...]:
58
+ return tuple(e for e in self.explanations if e.action is action)
@@ -0,0 +1,82 @@
1
+ """Detection engine: runs registered detectors over a commit context.
2
+
3
+ The engine is deliberately small and policy-agnostic:
4
+
5
+ * detectors run in a deterministic order (sorted by name);
6
+ * callers may pass the set of *enabled rule IDs*; a detector none of whose
7
+ rules are enabled is skipped (recorded in ``detectors_skipped``). The engine
8
+ never looks at actions - that remains the policy evaluator's job;
9
+ * a detector that raises is recorded as a :class:`DetectorFailure` rather than
10
+ crashing the scan - the policy evaluator then fails closed;
11
+ * output is validated: detectors must return :class:`Finding` objects for
12
+ rules they declared, attributed to themselves and to the scanned commit.
13
+ """
14
+
15
+ from collections.abc import Collection
16
+
17
+ from commitguard.core.context import CommitContext
18
+ from commitguard.core.result import DetectionResult, DetectorFailure, Finding
19
+ from commitguard.detectors.base import Detector
20
+ from commitguard.detectors.registry import DetectorRegistry
21
+ from commitguard.exceptions.detection import DetectionError
22
+ from commitguard.security.sanitization import sanitize_for_terminal
23
+
24
+
25
+ class DetectionEngine:
26
+ """Execute detectors from a registry against a :class:`CommitContext`."""
27
+
28
+ def __init__(self, registry: DetectorRegistry) -> None:
29
+ self._registry = registry
30
+
31
+ @property
32
+ def registry(self) -> DetectorRegistry:
33
+ return self._registry
34
+
35
+ def run(
36
+ self,
37
+ context: CommitContext,
38
+ *,
39
+ enabled_rules: Collection[str] | None = None,
40
+ ) -> DetectionResult:
41
+ findings: list[Finding] = []
42
+ failures: list[DetectorFailure] = []
43
+ ran: list[str] = []
44
+ skipped: list[str] = []
45
+
46
+ for detector in self._registry.all():
47
+ if enabled_rules is not None and not detector.rules & set(enabled_rules):
48
+ skipped.append(detector.name)
49
+ continue
50
+ ran.append(detector.name)
51
+ try:
52
+ findings.extend(self._run_detector(detector, context))
53
+ except Exception as exc: # noqa: BLE001 - any detector failure must fail closed
54
+ failures.append(
55
+ DetectorFailure(
56
+ detector=detector.name,
57
+ error_type=type(exc).__name__,
58
+ message=sanitize_for_terminal(str(exc) or type(exc).__name__),
59
+ )
60
+ )
61
+
62
+ return DetectionResult(
63
+ commit_sha=context.commit.sha,
64
+ detectors_run=tuple(ran),
65
+ detectors_skipped=tuple(skipped),
66
+ findings=tuple(findings),
67
+ failures=tuple(failures),
68
+ )
69
+
70
+ @staticmethod
71
+ def _run_detector(detector: Detector, context: CommitContext) -> list[Finding]:
72
+ produced = list(detector.detect(context))
73
+ for finding in produced:
74
+ if not isinstance(finding, Finding):
75
+ raise DetectionError(f"detector returned {type(finding).__name__}, not Finding")
76
+ if finding.detector != detector.name:
77
+ raise DetectionError(f"finding attributed to {finding.detector!r}")
78
+ if finding.rule_id not in detector.rules:
79
+ raise DetectionError(f"finding uses undeclared rule {finding.rule_id!r}")
80
+ if finding.commit_sha != context.commit.sha:
81
+ raise DetectionError("finding refers to a different commit")
82
+ return produced
@@ -0,0 +1,177 @@
1
+ """Findings and detection results.
2
+
3
+ A :class:`Finding` is a detector's structured, explainable claim that commit
4
+ *metadata* matched a rule. It never contains a decision (that is the policy's
5
+ job) and never claims that code was written by an AI - only that the commit
6
+ contains an identity or attribution associated with one.
7
+ """
8
+
9
+ from enum import StrEnum
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+
13
+ from commitguard.security.hashing import fingerprint
14
+ from commitguard.security.validation import validate_git_sha, validate_identifier
15
+
16
+ MAX_EVIDENCE_CHARS = 512
17
+ _TRUNCATION_MARKER = "...[truncated]"
18
+
19
+
20
+ class Severity(StrEnum):
21
+ """How serious a finding is, independent of the action a policy takes."""
22
+
23
+ INFO = "info"
24
+ LOW = "low"
25
+ MEDIUM = "medium"
26
+ HIGH = "high"
27
+ CRITICAL = "critical"
28
+
29
+ @property
30
+ def rank(self) -> int:
31
+ return list(Severity).index(self)
32
+
33
+
34
+ class Confidence(StrEnum):
35
+ """How strong the evidence behind a finding is."""
36
+
37
+ LOW = "low"
38
+ MEDIUM = "medium"
39
+ HIGH = "high"
40
+
41
+ @property
42
+ def rank(self) -> int:
43
+ return list(Confidence).index(self)
44
+
45
+
46
+ class MatchKind(StrEnum):
47
+ """Which piece of an identity matched a rule."""
48
+
49
+ NAME = "name" # full name equals a configured alias
50
+ NAME_PREFIX = "name_prefix" # name starts with a configured distinctive prefix
51
+ AMBIGUOUS_NAME = "ambiguous_name" # alias that is also a common human name
52
+ EMAIL = "email" # exact configured address
53
+ GITHUB_LOGIN = "github_login" # login from a GitHub noreply address or name
54
+ AUTOMATION_EMAIL = "automation_email" # automation local part at a vendor domain
55
+ VENDOR_DOMAIN = "vendor_domain" # vendor domain with a non-automation local part
56
+ TRAILER_KEY = "trailer_key" # trailer key configured as attribution
57
+ MESSAGE_MARKER = "message_marker" # exact attribution line inserted by a tool
58
+
59
+
60
+ class MatchReason(BaseModel):
61
+ """One reason a rule matched, with a reference to the rule that matched."""
62
+
63
+ model_config = ConfigDict(frozen=True, extra="forbid")
64
+
65
+ kind: MatchKind
66
+ value: str = Field(description="The normalised value that matched")
67
+ rule: str = Field(description="Rule reference, e.g. 'ai-identities.yaml#claude'")
68
+
69
+
70
+ class EvidenceSource(StrEnum):
71
+ COAUTHOR_TRAILER = "coauthor_trailer"
72
+ TRAILER = "trailer"
73
+ AUTHOR = "author"
74
+ COMMITTER = "committer"
75
+ MESSAGE = "message"
76
+
77
+ @property
78
+ def label(self) -> str:
79
+ return _SOURCE_LABELS[self]
80
+
81
+
82
+ _SOURCE_LABELS = {
83
+ EvidenceSource.COAUTHOR_TRAILER: "Co-authored-by trailer",
84
+ EvidenceSource.TRAILER: "commit trailer",
85
+ EvidenceSource.AUTHOR: "commit author",
86
+ EvidenceSource.COMMITTER: "commit committer",
87
+ EvidenceSource.MESSAGE: "commit message line",
88
+ }
89
+
90
+
91
+ class Evidence(BaseModel):
92
+ """The concise metadata that triggered a finding (never file contents).
93
+
94
+ ``value`` is untrusted raw data, truncated to :data:`MAX_EVIDENCE_CHARS`;
95
+ it must be sanitised before display.
96
+ """
97
+
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ source: EvidenceSource
101
+ value: str
102
+ line_number: int | None = Field(default=None, ge=1)
103
+ matched: tuple[MatchReason, ...] = ()
104
+ notes: tuple[str, ...] = Field(default=(), description="e.g. malformed-trailer issues")
105
+
106
+ @field_validator("value")
107
+ @classmethod
108
+ def _truncate(cls, value: str) -> str:
109
+ if len(value) > MAX_EVIDENCE_CHARS:
110
+ return value[: MAX_EVIDENCE_CHARS - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
111
+ return value
112
+
113
+
114
+ class Finding(BaseModel):
115
+ """A structured security finding produced by a detector."""
116
+
117
+ model_config = ConfigDict(frozen=True, extra="forbid")
118
+
119
+ detector: str
120
+ rule_id: str
121
+ severity: Severity
122
+ confidence: Confidence
123
+ title: str = Field(min_length=1)
124
+ message: str = Field(min_length=1)
125
+ evidence: tuple[Evidence, ...] = Field(min_length=1)
126
+ commit_sha: str | None = None
127
+ remediation: str = Field(min_length=1)
128
+
129
+ @field_validator("detector")
130
+ @classmethod
131
+ def _validate_detector(cls, value: str) -> str:
132
+ return validate_identifier(value, kind="detector name")
133
+
134
+ @field_validator("rule_id")
135
+ @classmethod
136
+ def _validate_rule(cls, value: str) -> str:
137
+ return validate_identifier(value, kind="rule id")
138
+
139
+ @field_validator("commit_sha")
140
+ @classmethod
141
+ def _validate_commit_sha(cls, value: str | None) -> str | None:
142
+ return None if value is None else validate_git_sha(value)
143
+
144
+ @property
145
+ def fingerprint(self) -> str:
146
+ """Stable ID: same detector, rule, commit and evidence => same fingerprint."""
147
+ parts = [self.detector, self.rule_id, self.commit_sha or ""]
148
+ for item in self.evidence:
149
+ parts.extend([item.source.value, item.value, str(item.line_number or "")])
150
+ return fingerprint(parts)
151
+
152
+
153
+ class DetectorFailure(BaseModel):
154
+ """A detector that raised or misbehaved. Evaluated fail-closed by policy."""
155
+
156
+ model_config = ConfigDict(frozen=True, extra="forbid")
157
+
158
+ detector: str
159
+ error_type: str
160
+ message: str
161
+
162
+
163
+ class DetectionResult(BaseModel):
164
+ """Everything the engine produced for one commit."""
165
+
166
+ model_config = ConfigDict(frozen=True, extra="forbid")
167
+
168
+ commit_sha: str | None
169
+ detectors_run: tuple[str, ...]
170
+ detectors_skipped: tuple[str, ...] = ()
171
+ findings: tuple[Finding, ...] = ()
172
+ failures: tuple[DetectorFailure, ...] = ()
173
+
174
+ @property
175
+ def complete(self) -> bool:
176
+ """True if every detector that ran completed successfully."""
177
+ return not self.failures
@@ -0,0 +1,6 @@
1
+ """Detectors: independent, pure analyses that turn a commit into findings.
2
+
3
+ A detector answers "does this commit match rule X, and what is the evidence?".
4
+ It never decides whether that is acceptable (see :mod:`commitguard.policies`)
5
+ and never performs I/O or modifies the repository.
6
+ """
@@ -0,0 +1,58 @@
1
+ """Detector interface.
2
+
3
+ Contract every detector must honour:
4
+
5
+ 1. **Pure.** Output depends only on the :class:`CommitContext` and the rule
6
+ data given at construction. No network, subprocesses, files or Git.
7
+ 2. **Deterministic.** The same context always yields the same findings in the
8
+ same order.
9
+ 3. **Declared rules.** Every finding's ``rule_id`` is listed in :attr:`rules`;
10
+ the engine rejects anything else.
11
+ 4. **Evidence.** Every finding carries the concise metadata that triggered it.
12
+ 5. **No decisions.** Detectors report; policies decide allow/warn/block.
13
+ 6. **Hostile input.** Commit data may be crafted to crash or evade a detector.
14
+ Raising is acceptable (the scan fails closed); silently skipping is not.
15
+ 7. **Honest wording.** Findings describe attribution/identity *evidence*;
16
+ they never claim that code was written by an AI.
17
+ """
18
+
19
+ from abc import ABC, abstractmethod
20
+ from collections.abc import Sequence
21
+ from typing import ClassVar
22
+
23
+ from commitguard.core.context import CommitContext
24
+ from commitguard.core.result import Finding
25
+ from commitguard.exceptions.detection import DetectionError
26
+ from commitguard.git.commit import Commit
27
+
28
+
29
+ class Detector(ABC):
30
+ """Base class for all detectors."""
31
+
32
+ #: Unique, snake_case detector name (used in findings and output).
33
+ name: ClassVar[str]
34
+ #: Rule IDs this detector may emit. Each must have a policy.
35
+ rules: ClassVar[frozenset[str]]
36
+ #: One-line human description.
37
+ description: ClassVar[str]
38
+
39
+ @abstractmethod
40
+ def detect(self, context: CommitContext) -> Sequence[Finding]:
41
+ """Analyse ``context`` and return zero or more findings."""
42
+
43
+
44
+ def require_complete_trailers(commit: Commit) -> None:
45
+ """Fail closed if the commit has more trailers than can be analysed.
46
+
47
+ Otherwise an attacker could hide attribution behind a flood of trailers.
48
+ """
49
+ if commit.trailers_truncated:
50
+ raise DetectionError("commit has too many trailers to analyse completely")
51
+
52
+
53
+ def group_by_rule[T](items: list[tuple[str, T]]) -> dict[str, list[T]]:
54
+ """Group ``(rule_id, item)`` pairs preserving first-seen order (deterministic)."""
55
+ grouped: dict[str, list[T]] = {}
56
+ for key, item in items:
57
+ grouped.setdefault(key, []).append(item)
58
+ return grouped
@@ -0,0 +1,87 @@
1
+ """Bot detector: explicitly configured automation identities.
2
+
3
+ Bots are not AI agents. Dependabot or a release bot produce ``bot_identity``
4
+ findings - never ``ai_*`` findings - so a repository can allow bots while
5
+ blocking AI attribution. Only identities listed in ``rules/bot-identities.yaml``
6
+ match; there is no generic ``[bot]`` heuristic.
7
+ """
8
+
9
+ from collections.abc import Sequence
10
+ from typing import ClassVar
11
+
12
+ from commitguard.core.context import CommitContext
13
+ from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
14
+ from commitguard.detectors.base import Detector, group_by_rule, require_complete_trailers
15
+ from commitguard.rules.matcher import CompiledRules, IdentityMatch
16
+
17
+ RULE_BOT_IDENTITY = "bot_identity"
18
+
19
+
20
+ class BotDetector(Detector):
21
+ name: ClassVar[str] = "bot"
22
+ rules: ClassVar[frozenset[str]] = frozenset({RULE_BOT_IDENTITY})
23
+ description: ClassVar[str] = (
24
+ "Configured automation/bot account as author, committer or co-author."
25
+ )
26
+
27
+ def __init__(self, rules: CompiledRules) -> None:
28
+ self._matcher = rules.bot_matcher
29
+ self._coauthor_keys = frozenset(rules.rules.patterns.coauthor_trailer_keys)
30
+
31
+ def detect(self, context: CommitContext) -> Sequence[Finding]:
32
+ commit = context.commit
33
+ require_complete_trailers(commit)
34
+ candidates: list[tuple[EvidenceSource, str, str | None, str | None, int | None]] = [
35
+ (
36
+ EvidenceSource.AUTHOR,
37
+ str(commit.author),
38
+ commit.author.name,
39
+ commit.author.email,
40
+ None,
41
+ ),
42
+ (
43
+ EvidenceSource.COMMITTER,
44
+ str(commit.committer),
45
+ commit.committer.name,
46
+ commit.committer.email,
47
+ None,
48
+ ),
49
+ ]
50
+ candidates.extend(
51
+ (EvidenceSource.COAUTHOR_TRAILER, t.value, t.name, t.email, t.line_number)
52
+ for t in commit.trailers
53
+ if t.normalized_key in self._coauthor_keys
54
+ )
55
+
56
+ matches: list[tuple[str, tuple[Evidence, IdentityMatch]]] = []
57
+ for source, value, name, email, line_number in candidates:
58
+ match = self._matcher.match(name, email)
59
+ if match is not None:
60
+ evidence = Evidence(
61
+ source=source, value=value, line_number=line_number, matched=match.reasons
62
+ )
63
+ matches.append((match.rule_id, (evidence, match)))
64
+
65
+ findings = []
66
+ for items in group_by_rule(matches).values():
67
+ match = max((m for _, m in items), key=lambda m: m.confidence.rank)
68
+ findings.append(
69
+ Finding(
70
+ detector=self.name,
71
+ rule_id=RULE_BOT_IDENTITY,
72
+ severity=Severity.LOW,
73
+ confidence=match.confidence,
74
+ title="Bot identity detected",
75
+ message=(
76
+ f"The commit involves the configured automation identity "
77
+ f"{match.display_name}. This is a bot finding, not an AI attribution."
78
+ ),
79
+ evidence=tuple(evidence for evidence, _ in items),
80
+ commit_sha=commit.sha,
81
+ remediation=(
82
+ "If this automation is expected, set `bot_identity` to `allow` in "
83
+ ".commitguard.yaml; otherwise investigate where the commit came from."
84
+ ),
85
+ )
86
+ )
87
+ return findings
@@ -0,0 +1,86 @@
1
+ """Co-author detector: AI agents listed in ``Co-authored-by`` style trailers.
2
+
3
+ Example that produces an ``ai_coauthor`` finding::
4
+
5
+ feat: implement authentication
6
+
7
+ Co-authored-by: Claude <noreply@anthropic.com>
8
+
9
+ Every co-author trailer is matched *independently* against the AI identity
10
+ rules, so human co-authors in the same commit never produce findings.
11
+ Trailers are considered even when malformed or outside Git's trailer block,
12
+ because hiding attribution that way is still attribution.
13
+ """
14
+
15
+ from collections.abc import Sequence
16
+ from typing import ClassVar
17
+
18
+ from commitguard.core.context import CommitContext
19
+ from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
20
+ from commitguard.detectors.base import Detector, require_complete_trailers
21
+ from commitguard.provenance.normalization import uses_disguising_characters
22
+ from commitguard.provenance.trailers import Trailer
23
+ from commitguard.rules.matcher import CompiledRules
24
+
25
+ RULE_AI_COAUTHOR = "ai_coauthor"
26
+
27
+
28
+ class CoauthorDetector(Detector):
29
+ name: ClassVar[str] = "coauthor"
30
+ rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_COAUTHOR})
31
+ description: ClassVar[str] = "AI agent identity listed as a co-author in commit trailers."
32
+
33
+ def __init__(self, rules: CompiledRules) -> None:
34
+ self._matcher = rules.ai_matcher
35
+ self._keys = frozenset(rules.rules.patterns.coauthor_trailer_keys)
36
+
37
+ def detect(self, context: CommitContext) -> Sequence[Finding]:
38
+ commit = context.commit
39
+ require_complete_trailers(commit)
40
+ findings: list[Finding] = []
41
+ for trailer in commit.trailers:
42
+ if trailer.normalized_key not in self._keys:
43
+ continue
44
+ match = self._matcher.match(trailer.name, trailer.email)
45
+ if match is None:
46
+ continue
47
+ findings.append(
48
+ Finding(
49
+ detector=self.name,
50
+ rule_id=RULE_AI_COAUTHOR,
51
+ severity=Severity.HIGH,
52
+ confidence=match.confidence,
53
+ title="AI coauthor detected",
54
+ message=(
55
+ f"A co-author trailer names an identity associated with the AI agent "
56
+ f"{match.display_name}."
57
+ ),
58
+ evidence=(
59
+ Evidence(
60
+ source=EvidenceSource.COAUTHOR_TRAILER,
61
+ value=trailer.value or trailer.raw,
62
+ line_number=trailer.line_number,
63
+ matched=match.reasons,
64
+ notes=_notes(trailer),
65
+ ),
66
+ ),
67
+ commit_sha=commit.sha,
68
+ remediation=(
69
+ "Remove the AI co-author attribution from the commit message before "
70
+ "pushing (for example with `git commit --amend`). CommitGuard never "
71
+ "rewrites commits itself."
72
+ ),
73
+ )
74
+ )
75
+ return findings
76
+
77
+
78
+ def _notes(trailer: Trailer) -> tuple[str, ...]:
79
+ notes: list[str] = []
80
+ if uses_disguising_characters(trailer.raw):
81
+ notes.append("contains look-alike or invisible Unicode characters")
82
+ if not trailer.in_trailer_block:
83
+ notes.append("outside the commit's trailer block")
84
+ notes.extend(f"trailer: {issue.value}" for issue in trailer.issues)
85
+ notes.extend(f"identity: {issue.value}" for issue in trailer.identity.issues)
86
+ return tuple(notes)
@@ -0,0 +1,76 @@
1
+ """Identity detector: AI agents recorded as the commit author or committer.
2
+
3
+ Some agents commit under their own identity instead of adding a trailer, e.g.
4
+ ``Copilot <175728472+Copilot@users.noreply.github.com>`` as author.
5
+
6
+ Only identities configured as AI agents match. Automation accounts that are
7
+ not AI agents (Dependabot, CI) are the bot detector's concern.
8
+ """
9
+
10
+ from collections.abc import Sequence
11
+ from typing import ClassVar
12
+
13
+ from commitguard.core.context import CommitContext
14
+ from commitguard.core.result import Evidence, EvidenceSource, Finding, Severity
15
+ from commitguard.detectors.base import Detector, group_by_rule
16
+ from commitguard.provenance.normalization import uses_disguising_characters
17
+ from commitguard.rules.matcher import CompiledRules, IdentityMatch
18
+
19
+ RULE_AI_IDENTITY = "ai_identity"
20
+
21
+
22
+ class IdentityDetector(Detector):
23
+ name: ClassVar[str] = "identity"
24
+ rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_IDENTITY})
25
+ description: ClassVar[str] = "AI agent identity recorded as the commit author or committer."
26
+
27
+ def __init__(self, rules: CompiledRules) -> None:
28
+ self._matcher = rules.ai_matcher
29
+
30
+ def detect(self, context: CommitContext) -> Sequence[Finding]:
31
+ commit = context.commit
32
+ matches: list[tuple[str, tuple[EvidenceSource, str, IdentityMatch]]] = []
33
+ for source, identity in (
34
+ (EvidenceSource.AUTHOR, commit.author),
35
+ (EvidenceSource.COMMITTER, commit.committer),
36
+ ):
37
+ match = self._matcher.match(identity.name, identity.email)
38
+ if match is not None:
39
+ matches.append((match.rule_id, (source, str(identity), match)))
40
+
41
+ findings = []
42
+ for items in group_by_rule(matches).values():
43
+ match = max((m for _, _, m in items), key=lambda m: m.confidence.rank)
44
+ roles = " and ".join(source.label for source, _, _ in items)
45
+ findings.append(
46
+ Finding(
47
+ detector=self.name,
48
+ rule_id=RULE_AI_IDENTITY,
49
+ severity=Severity.HIGH,
50
+ confidence=match.confidence,
51
+ title="AI agent identity detected",
52
+ message=(
53
+ f"The {roles} identity is associated with the AI agent "
54
+ f"{match.display_name}."
55
+ ),
56
+ evidence=tuple(
57
+ Evidence(
58
+ source=source,
59
+ value=value,
60
+ matched=m.reasons,
61
+ notes=(
62
+ ("contains look-alike or invisible Unicode characters",)
63
+ if uses_disguising_characters(value)
64
+ else ()
65
+ ),
66
+ )
67
+ for source, value, m in items
68
+ ),
69
+ commit_sha=commit.sha,
70
+ remediation=(
71
+ "Recreate the commit under the responsible human contributor's identity "
72
+ "before pushing. CommitGuard never rewrites commits itself."
73
+ ),
74
+ )
75
+ )
76
+ return findings