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,367 @@
1
+ """Rendering of :class:`ScanReport` objects as text or JSON.
2
+
3
+ * ``scan`` text: detailed, human-oriented explanation of every finding.
4
+ * ``check`` text: one tab-separated line per finding plus a ``result=`` line.
5
+ * JSON: the full report model, ASCII-only (all non-ASCII and control
6
+ characters escaped, so it is terminal-safe), schema_version 1.
7
+ """
8
+
9
+ import json
10
+
11
+ from commitguard.cli.output import supports_unicode
12
+ from commitguard.core.decision import Action
13
+ from commitguard.core.result import Evidence, MatchReason
14
+ from commitguard.security.sanitization import sanitize_for_terminal
15
+ from commitguard.services.reports import CommitReport, EvaluatedFinding, ScanReport
16
+
17
+
18
+ def _s(text: str, limit: int = 300) -> str:
19
+ return sanitize_for_terminal(text, max_length=limit)
20
+
21
+
22
+ def render_json(report: ScanReport) -> str:
23
+ return json.dumps(report.model_dump(mode="json"), indent=2, ensure_ascii=True, sort_keys=False)
24
+
25
+
26
+ def _symbols() -> tuple[str, str, str]:
27
+ return ("✓", "✗", "!") if supports_unicode() else ("OK", "X", "!")
28
+
29
+
30
+ def _reason(reason: MatchReason) -> str:
31
+ return f'{reason.kind.value} "{_s(reason.value, 120)}" ({_s(reason.rule, 120)})'
32
+
33
+
34
+ def _evidence_lines(evidence: Evidence) -> list[str]:
35
+ source = evidence.source.label
36
+ if evidence.line_number is not None:
37
+ source += f", line {evidence.line_number}"
38
+ lines = [f" Evidence: {_s(evidence.value)}", f" Source: {source}"]
39
+ for index, reason in enumerate(evidence.matched):
40
+ lines.append(f" {'Matched:' if index == 0 else '':<12} {_reason(reason)}")
41
+ if evidence.notes:
42
+ lines.append(f" Notes: {_s('; '.join(evidence.notes))}")
43
+ return lines
44
+
45
+
46
+ def _finding_block(item: EvaluatedFinding, commit: CommitReport) -> list[str]:
47
+ finding = item.finding
48
+ policy = f" (policy {item.policy_id})" if item.policy_id else ""
49
+ lines = [
50
+ _s(finding.title, 120),
51
+ f" Commit: {commit.short_sha}",
52
+ f" Detector: {finding.detector}",
53
+ f" Rule: {finding.rule_id}",
54
+ f" Severity: {finding.severity.value}",
55
+ f" Confidence: {finding.confidence.value}",
56
+ f" Action: {item.action.value}{policy}",
57
+ f" Message: {_s(finding.message)}",
58
+ ]
59
+ for evidence in finding.evidence:
60
+ lines.extend(_evidence_lines(evidence))
61
+ lines.append(f" Remediation: {_s(finding.remediation)}")
62
+ return lines
63
+
64
+
65
+ def render_scan_text(report: ScanReport) -> str:
66
+ ok, cross, bang = _symbols()
67
+ lines = ["CommitGuard"]
68
+ if report.repository:
69
+ lines.append(f"Repository: {_s(report.repository)}")
70
+ count = len(report.commits)
71
+ lines.append(
72
+ f"Target: {_s(report.target, 120)} ({count} commit{'s' if count != 1 else ''})"
73
+ )
74
+ lines.append("Config: " + " < ".join(_s(source, 200) for source in report.config_sources))
75
+ lines.append("")
76
+
77
+ ordered = [(item, commit) for commit in report.commits for item in commit.findings]
78
+ ordered.sort(key=lambda pair: -pair[0].action.rank) # stable: commit order kept per action
79
+ failures = [(failure, commit) for commit in report.commits for failure in commit.failures]
80
+
81
+ if not ordered and not failures:
82
+ lines.append(f"{ok} Repository scanned")
83
+ lines.append(f"{ok} No policy violations detected")
84
+ else:
85
+ headline = {
86
+ Action.BLOCK: f"{cross} BLOCKED: policy violation detected",
87
+ Action.WARN: f"{bang} WARNING: policy warnings detected",
88
+ Action.ALLOW: f"{ok} Findings present, all allowed by policy",
89
+ }[report.action]
90
+ lines.append(headline)
91
+ for failure, commit in failures:
92
+ lines.append("")
93
+ lines.append(f"{cross} Detector failure (scan incomplete)")
94
+ lines.append(f" Commit: {commit.short_sha}")
95
+ lines.append(f" Detector: {failure.failure.detector}")
96
+ lines.append(
97
+ f" Error: {_s(failure.failure.error_type)}: {_s(failure.failure.message)}"
98
+ )
99
+ lines.append(f" Action: {failure.action.value} ({_s(failure.reason)})")
100
+ for item, commit in ordered:
101
+ lines.append("")
102
+ lines.extend(_finding_block(item, commit))
103
+
104
+ summary = report.summary
105
+ lines.append("")
106
+ lines.append(
107
+ f"Summary: {summary['commits']} commit(s), {summary['block']} blocking, "
108
+ f"{summary['warn']} warning(s), {summary['allow']} allowed finding(s)"
109
+ )
110
+ lines.append(f"Result: {report.action.value.upper()}")
111
+ return "\n".join(lines)
112
+
113
+
114
+ def render_check_text(report: ScanReport) -> str:
115
+ """Stable, grep/cut-friendly output: ``ACTION<TAB>sha<TAB>detector<TAB>rule<TAB>evidence``."""
116
+ lines = []
117
+ for commit in report.commits:
118
+ for failure in commit.failures:
119
+ lines.append(
120
+ "\t".join(
121
+ [
122
+ failure.action.value.upper(),
123
+ commit.short_sha,
124
+ failure.failure.detector,
125
+ "detector_failure",
126
+ _s(failure.failure.message, 200),
127
+ ]
128
+ )
129
+ )
130
+ for item in commit.findings:
131
+ evidence = item.finding.evidence[0].value
132
+ lines.append(
133
+ "\t".join(
134
+ [
135
+ item.action.value.upper(),
136
+ commit.short_sha,
137
+ item.finding.detector,
138
+ item.finding.rule_id,
139
+ _s(evidence, 200),
140
+ ]
141
+ )
142
+ )
143
+ summary = report.summary
144
+ lines.append(
145
+ f"result={report.action.value.upper()} commits={summary['commits']} "
146
+ f"block={summary['block']} warn={summary['warn']} allow={summary['allow']}"
147
+ )
148
+ return "\n".join(lines)
149
+
150
+
151
+ # --------------------------------------------------------------------------- #
152
+ # Hook output (written to stderr by the hook commands)
153
+ # --------------------------------------------------------------------------- #
154
+ _PUSH_REMEDIATION = [
155
+ "How to fix:",
156
+ " CommitGuard never modifies commits. Rewrite the blocked commits so they no",
157
+ " longer carry the attribution, then push again:",
158
+ " - latest commit only: git commit --amend",
159
+ " (replaces that one local commit with an edited copy)",
160
+ " - older commits: git rebase -i <commit>^ and mark them 'reword'",
161
+ " (recreates the selected commits and every commit after them)",
162
+ " Only rewrite commits that have not already been shared with others.",
163
+ ]
164
+
165
+
166
+ def _short_finding_lines(item: EvaluatedFinding) -> list[str]:
167
+ finding = item.finding
168
+ evidence = finding.evidence[0]
169
+ where = evidence.source.label + (
170
+ f", line {evidence.line_number}" if evidence.line_number is not None else ""
171
+ )
172
+ return [
173
+ f" {_s(finding.title, 120)} [{finding.rule_id}, {finding.severity.value}]",
174
+ f" Evidence: {_s(evidence.value, 160)} ({where})",
175
+ ]
176
+
177
+
178
+ def _commit_marker(action: Action) -> str:
179
+ ok, cross, bang = _symbols()
180
+ return {Action.BLOCK: cross, Action.WARN: bang, Action.ALLOW: ok}[action]
181
+
182
+
183
+ def render_push_text(report: ScanReport, *, remote: str | None, verbose: bool) -> str:
184
+ ok, cross, bang = _symbols()
185
+ commits = report.commits
186
+ counts = {action: sum(1 for c in commits if c.action is action) for action in Action}
187
+ lines = ["CommitGuard"]
188
+ if not commits:
189
+ lines.append(f"{ok} No new commits to check for this push.")
190
+ return "\n".join(lines)
191
+
192
+ if report.action is Action.ALLOW and not any(c.findings or c.failures for c in commits):
193
+ count = len(commits)
194
+ return (
195
+ f"CommitGuard: {ok} {count} outgoing commit{'s' if count != 1 else ''} checked, "
196
+ "no policy violations"
197
+ )
198
+ if report.action is Action.BLOCK:
199
+ lines.append(f"{cross} PUSH BLOCKED")
200
+ elif report.action is Action.WARN:
201
+ lines.append(f"{bang} PUSH ALLOWED WITH WARNINGS")
202
+ else:
203
+ lines.append(f"{ok} Push allowed: no policy violations")
204
+ lines += [
205
+ f"Remote: {_s(remote or '?', 200)}",
206
+ f"Commits checked: {len(commits)}",
207
+ f"Violations: {counts[Action.BLOCK]}",
208
+ f"Warnings: {counts[Action.WARN]}",
209
+ f"Allowed: {counts[Action.ALLOW]}",
210
+ ]
211
+
212
+ flagged = [c for c in commits if c.findings or c.failures]
213
+ for commit in sorted(flagged, key=lambda c: -c.action.rank):
214
+ lines.append("")
215
+ lines.append(
216
+ f"{_commit_marker(commit.action)} {commit.short_sha} {_s(commit.subject, 72)}"
217
+ )
218
+ for failure in commit.failures:
219
+ lines.append(
220
+ f" Detector failure: {failure.failure.detector} "
221
+ f"({_s(failure.failure.message, 160)}); analysis incomplete"
222
+ )
223
+ for item in commit.findings:
224
+ if verbose:
225
+ lines.extend(" " + line for line in _finding_block(item, commit))
226
+ else:
227
+ lines.extend(_short_finding_lines(item))
228
+ if item.action is not Action.BLOCK:
229
+ lines.append(f" Action: {item.action.value}")
230
+
231
+ if report.action is Action.BLOCK:
232
+ lines += ["", *_PUSH_REMEDIATION, "", "No changes were pushed to the remote repository."]
233
+ if not verbose:
234
+ lines.append("Full evidence: commitguard scan <sha> (for each blocked commit)")
235
+ return "\n".join(lines)
236
+
237
+
238
+ def render_commit_hook_text(report: ScanReport, *, stage: str, verbose: bool) -> str:
239
+ ok, cross, bang = _symbols()
240
+ (commit,) = report.commits
241
+ if not commit.findings and not commit.failures:
242
+ return ""
243
+ lines = ["CommitGuard"]
244
+ if report.action is Action.BLOCK:
245
+ lines.append(f"{cross} COMMIT BLOCKED ({stage})")
246
+ elif report.action is Action.WARN:
247
+ lines.append(f"{bang} Commit allowed with warnings ({stage})")
248
+ else:
249
+ lines.append(f"{ok} Findings allowed by policy ({stage})")
250
+ for failure in commit.failures:
251
+ lines.append(
252
+ f" Detector failure: {failure.failure.detector} ({_s(failure.failure.message, 160)})"
253
+ )
254
+ for item in commit.findings:
255
+ lines.append("")
256
+ if verbose:
257
+ lines.extend(_finding_block(item, commit))
258
+ else:
259
+ lines.extend(line[2:] for line in _short_finding_lines(item))
260
+ lines.append(f" Action: {item.action.value}")
261
+ if report.action is Action.BLOCK:
262
+ lines.append("")
263
+ lines.append("Nothing was committed. Your staged changes are unchanged.")
264
+ if stage == "commit-msg":
265
+ lines.append("Remove the attribution from the message and commit again.")
266
+ lines.append(
267
+ "Your message is still in .git/COMMIT_EDITMSG; to reuse it: "
268
+ "git commit -e -F .git/COMMIT_EDITMSG"
269
+ )
270
+ else:
271
+ lines.append(
272
+ "Commit under the responsible human contributor's identity "
273
+ "(git config user.name / user.email, or --author)."
274
+ )
275
+ return "\n".join(lines)
276
+
277
+
278
+ # --------------------------------------------------------------------------- #
279
+ # CI output
280
+ # --------------------------------------------------------------------------- #
281
+ MAX_CI_FINDINGS = 50
282
+ _CI_REMEDIATION = (
283
+ "Please update the listed commits so that they comply with the repository's "
284
+ "contribution policy, then push the updated branch. CommitGuard does not "
285
+ "automatically rewrite Git history."
286
+ )
287
+
288
+
289
+ def render_ci_text(report: ScanReport, *, failed: bool) -> str:
290
+ ok, cross, bang = _symbols()
291
+ rule = "━" * 40 if supports_unicode() else "-" * 40
292
+ ci = report.ci
293
+ commits = report.commits
294
+ counts = {action: sum(1 for c in commits if c.action is action) for action in Action}
295
+ lines = ["CommitGuard", rule]
296
+ if ci is not None:
297
+ if ci.repository:
298
+ lines.append(f"Repository: {_s(ci.repository, 200)}")
299
+ event = _s(ci.event, 60)
300
+ if ci.pull_request_number:
301
+ event += f" (PR #{ci.pull_request_number}{', from a fork' if ci.from_fork else ''})"
302
+ lines.append(f"Event: {event}")
303
+ if ci.head_sha:
304
+ base = ci.base_sha[:12] if ci.base_sha else "(none)"
305
+ lines.append(f"Range: {base}..{ci.head_sha[:12]}")
306
+ lines.append(f"Policy: {_s(ci.policy_source, 200)}")
307
+ lines += [
308
+ f"{ok} policy loaded",
309
+ f"{ok} commits scanned: {len(commits)}",
310
+ f"{ok if not any(c.failures for c in commits) else cross} detection completed",
311
+ ]
312
+ by_rule: dict[str, int] = {}
313
+ for commit in commits:
314
+ for item in commit.findings:
315
+ by_rule[item.finding.rule_id] = by_rule.get(item.finding.rule_id, 0) + 1
316
+ if by_rule:
317
+ lines.append("Findings:")
318
+ lines.extend(f" {rule_id}: {count}" for rule_id, count in sorted(by_rule.items()))
319
+ else:
320
+ lines.append("Findings: 0")
321
+ lines += [
322
+ f"Violations: {counts[Action.BLOCK]}",
323
+ f"Warnings: {counts[Action.WARN]}",
324
+ f"Allowed: {counts[Action.ALLOW]}",
325
+ ]
326
+ if ci is not None:
327
+ for notice in ci.notices:
328
+ lines.append(f"{bang} {_s(notice, 400)}")
329
+
330
+ shown = 0
331
+ omitted = 0
332
+ for commit in sorted(commits, key=lambda c: -c.action.rank):
333
+ for failure in commit.failures:
334
+ lines += [
335
+ "",
336
+ f"{cross} Detector failure (analysis incomplete)",
337
+ f" Commit: {commit.short_sha}",
338
+ f" Detector: {failure.failure.detector}",
339
+ f" Error: {_s(failure.failure.message, 200)}",
340
+ ]
341
+ for item in commit.findings:
342
+ if shown >= MAX_CI_FINDINGS:
343
+ omitted += 1
344
+ continue
345
+ shown += 1
346
+ finding = item.finding
347
+ evidence = finding.evidence[0]
348
+ lines += [
349
+ "",
350
+ f"{_commit_marker(item.action)} {_s(finding.title, 120)}",
351
+ f" Commit: {commit.short_sha} {_s(commit.subject, 72)}",
352
+ f" Evidence: {_s(evidence.value, 200)} ({evidence.source.label})",
353
+ f" Rule: {finding.rule_id}",
354
+ f" Severity: {finding.severity.value}",
355
+ f" Action: {item.action.value}",
356
+ ]
357
+ if omitted:
358
+ lines.append(f"\n... {omitted} more finding(s) omitted; use --format json for all")
359
+
360
+ if failed:
361
+ result = "BLOCK" if report.action is Action.BLOCK else "FAILED (fail-on: warn)"
362
+ else:
363
+ result = "PASS (with warnings)" if report.action is Action.WARN else "PASS"
364
+ lines += ["", f"Result: {result}"]
365
+ if report.action is Action.BLOCK:
366
+ lines += ["", f"Remediation: {_CI_REMEDIATION}"]
367
+ return "\n".join(lines)
@@ -0,0 +1,6 @@
1
+ """Repository configuration (``.commitguard.yaml``).
2
+
3
+ Configuration is security-relevant, so it is validated strictly: unknown keys,
4
+ unknown policy IDs, wrong types, duplicate YAML keys and YAML tags that
5
+ construct Python objects are all rejected rather than ignored.
6
+ """
@@ -0,0 +1,53 @@
1
+ """Configuration file locations, limits and the ``commitguard init`` template."""
2
+
3
+ CONFIG_FILENAMES: tuple[str, ...] = (".commitguard.yaml", ".commitguard.yml")
4
+ DEFAULT_CONFIG_FILENAME = CONFIG_FILENAMES[0]
5
+ GLOBAL_CONFIG_DIRNAME = "commitguard"
6
+ GLOBAL_CONFIG_FILENAME = "config.yaml"
7
+ CURRENT_CONFIG_VERSION = 1
8
+
9
+ #: Refuse to parse configuration files larger than this (defence against
10
+ #: resource exhaustion via YAML alias expansion or huge files).
11
+ MAX_CONFIG_BYTES = 64 * 1024
12
+
13
+ DEFAULT_CONFIG_TEMPLATE = """\
14
+ # CommitGuard repository configuration.
15
+ # Docs: docs/configuration.md
16
+ #
17
+ # Policies not listed here keep their built-in secure defaults.
18
+ # Valid actions: allow | warn | block
19
+
20
+ version: 1
21
+
22
+ policies:
23
+ ai_coauthor:
24
+ enabled: true
25
+ action: block
26
+ ai_identity:
27
+ enabled: true
28
+ action: block
29
+ ai_trailer:
30
+ enabled: true
31
+ action: block
32
+ malformed_trailer:
33
+ enabled: true
34
+ action: warn
35
+ bot_identity:
36
+ enabled: true
37
+ action: warn
38
+
39
+ # Which Git hooks enforce the policies above (after `commitguard install`).
40
+ # Disabling a hook is visible in `commitguard doctor`.
41
+ enforcement:
42
+ pre_commit: true
43
+ commit_msg: true
44
+ pre_push: true
45
+
46
+ # What to do about a violation. By default CommitGuard blocks and leaves the
47
+ # message to you. Set auto_remove: true and the commit-msg hook deletes the
48
+ # offending lines instead, reports what it removed, and lets the commit through.
49
+ # Attribution in the author or committer identity still blocks: no edit to the
50
+ # message can fix that. `commitguard doctor` shows when this is on.
51
+ # remediation:
52
+ # auto_remove: true
53
+ """
@@ -0,0 +1,53 @@
1
+ """Effective hook enforcement and remediation settings, merged across layers."""
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from commitguard.config.schema import CommitGuardConfig
6
+
7
+ DEFAULT_MAX_PUSH_COMMITS = 10_000
8
+
9
+
10
+ class Enforcement(BaseModel):
11
+ """Resolved enforcement settings. Secure default: every hook enforces."""
12
+
13
+ model_config = ConfigDict(frozen=True, extra="forbid")
14
+
15
+ pre_commit: bool = True
16
+ commit_msg: bool = True
17
+ pre_push: bool = True
18
+ max_push_commits: int = DEFAULT_MAX_PUSH_COMMITS
19
+
20
+ def enabled(self, hook: str) -> bool:
21
+ return bool(getattr(self, hook.replace("-", "_")))
22
+
23
+ @property
24
+ def complete(self) -> bool:
25
+ return self.pre_commit and self.commit_msg and self.pre_push
26
+
27
+
28
+ def build_enforcement(*configs: CommitGuardConfig) -> Enforcement:
29
+ """Merge enforcement settings from configuration layers (lowest first)."""
30
+ effective = Enforcement()
31
+ for config in configs:
32
+ updates = config.enforcement.model_dump(exclude_unset=True)
33
+ if updates:
34
+ effective = effective.model_copy(update=updates)
35
+ return effective
36
+
37
+
38
+ class Remediation(BaseModel):
39
+ """Resolved remediation settings. Secure default: change nothing, just block."""
40
+
41
+ model_config = ConfigDict(frozen=True, extra="forbid")
42
+
43
+ auto_remove: bool = False
44
+
45
+
46
+ def build_remediation(*configs: CommitGuardConfig) -> Remediation:
47
+ """Merge remediation settings from configuration layers (lowest first)."""
48
+ effective = Remediation()
49
+ for config in configs:
50
+ updates = config.remediation.model_dump(exclude_unset=True)
51
+ if updates:
52
+ effective = effective.model_copy(update=updates)
53
+ return effective
@@ -0,0 +1,174 @@
1
+ """Locate, parse, validate and layer configuration files.
2
+
3
+ Precedence (later layers override earlier ones, field by field)::
4
+
5
+ 1. built-in defaults commitguard.policies.defaults
6
+ 2. global configuration $XDG_CONFIG_HOME/commitguard/config.yaml
7
+ (default ~/.config/commitguard/config.yaml)
8
+ 3. repository configuration <repo root>/.commitguard.yaml (or .yml)
9
+ 4. explicit configuration --config PATH
10
+
11
+ A layer only overrides the policy fields it sets; omitted policies and fields
12
+ keep the value from the layer below, ultimately the secure built-in default.
13
+
14
+ Security properties:
15
+
16
+ * strict safe YAML (no object construction, no duplicate keys, no aliases);
17
+ * files are size-limited and must be regular files;
18
+ * repository configuration is read only from the repository root - never from
19
+ parent directories, which may be controlled by someone else.
20
+ """
21
+
22
+ import os
23
+ from enum import StrEnum
24
+ from pathlib import Path
25
+
26
+ import yaml
27
+ from pydantic import BaseModel, ConfigDict, ValidationError
28
+
29
+ from commitguard.config.defaults import (
30
+ CONFIG_FILENAMES,
31
+ GLOBAL_CONFIG_DIRNAME,
32
+ GLOBAL_CONFIG_FILENAME,
33
+ MAX_CONFIG_BYTES,
34
+ )
35
+ from commitguard.config.schema import CommitGuardConfig
36
+ from commitguard.exceptions.base import UnsafeInputError
37
+ from commitguard.exceptions.configuration import ConfigurationError
38
+ from commitguard.security.safe_yaml import load_yaml
39
+ from commitguard.utils.filesystem import read_text_limited
40
+
41
+
42
+ class ConfigLayer(StrEnum):
43
+ BUILTIN = "builtin"
44
+ GLOBAL = "global"
45
+ REPOSITORY = "repository"
46
+ EXPLICIT = "explicit"
47
+
48
+
49
+ class ConfigSource(BaseModel):
50
+ """Where one configuration layer came from."""
51
+
52
+ model_config = ConfigDict(frozen=True, extra="forbid")
53
+
54
+ layer: ConfigLayer
55
+ path: Path | None = None
56
+ revision: str | None = None # set when the file was read from a commit, not the work tree
57
+
58
+ def __str__(self) -> str:
59
+ if self.path is None:
60
+ return self.layer.value
61
+ if self.revision is not None:
62
+ return f"{self.layer.value}: {self.path.as_posix()} @ {self.revision[:12]}"
63
+ return f"{self.layer.value}: {self.path}"
64
+
65
+
66
+ class LoadedConfig(BaseModel):
67
+ """All configuration layers in precedence order (lowest first)."""
68
+
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ layers: tuple[tuple[ConfigSource, CommitGuardConfig], ...]
72
+
73
+ @property
74
+ def configs(self) -> tuple[CommitGuardConfig, ...]:
75
+ return tuple(config for _, config in self.layers)
76
+
77
+ @property
78
+ def sources(self) -> tuple[ConfigSource, ...]:
79
+ return tuple(source for source, _ in self.layers)
80
+
81
+
82
+ def find_config(repository_root: Path) -> Path | None:
83
+ """Return the configuration file at ``repository_root``, if any.
84
+
85
+ Having both ``.commitguard.yaml`` and ``.commitguard.yml`` is ambiguous and
86
+ rejected.
87
+ """
88
+ candidates = [repository_root / name for name in CONFIG_FILENAMES]
89
+ present = [path for path in candidates if path.exists() or path.is_symlink()]
90
+ if len(present) > 1:
91
+ raise ConfigurationError(
92
+ "multiple configuration files found: " + ", ".join(p.name for p in present)
93
+ )
94
+ return present[0] if present else None
95
+
96
+
97
+ def global_config_path() -> Path:
98
+ """Path of the per-user global configuration file (may not exist)."""
99
+ base = os.environ.get("XDG_CONFIG_HOME")
100
+ root = Path(base) if base and Path(base).is_absolute() else Path.home() / ".config"
101
+ return root / GLOBAL_CONFIG_DIRNAME / GLOBAL_CONFIG_FILENAME
102
+
103
+
104
+ def parse_config(text: str, *, path: Path | None = None) -> CommitGuardConfig:
105
+ """Parse and validate configuration from YAML text."""
106
+ try:
107
+ document = load_yaml(text)
108
+ except yaml.YAMLError as exc:
109
+ raise ConfigurationError(f"invalid YAML: {exc}", path=path) from exc
110
+
111
+ if not isinstance(document, dict):
112
+ raise ConfigurationError("configuration must be a YAML mapping", path=path)
113
+
114
+ try:
115
+ return CommitGuardConfig.model_validate(document)
116
+ except ValidationError as exc:
117
+ raise ConfigurationError(_format_validation_error(exc), path=path) from exc
118
+
119
+
120
+ def load_config(path: Path) -> CommitGuardConfig:
121
+ """Read, parse and validate a configuration file."""
122
+ try:
123
+ text = read_text_limited(path, max_bytes=MAX_CONFIG_BYTES)
124
+ except FileNotFoundError as exc:
125
+ raise ConfigurationError("configuration file not found", path=path) from exc
126
+ except (OSError, UnsafeInputError) as exc:
127
+ raise ConfigurationError(str(exc), path=path) from exc
128
+ return parse_config(text, path=path)
129
+
130
+
131
+ def load_repository_config(repository_root: Path) -> tuple[CommitGuardConfig, Path | None]:
132
+ """Load only the repository layer, or an empty config if it has none."""
133
+ path = find_config(repository_root)
134
+ if path is None:
135
+ return CommitGuardConfig(version=1), None
136
+ return load_config(path), path
137
+
138
+
139
+ def load_effective_config(
140
+ repository_root: Path | None,
141
+ *,
142
+ explicit_path: Path | None = None,
143
+ include_global: bool = True,
144
+ ) -> LoadedConfig:
145
+ """Load every applicable configuration layer in precedence order."""
146
+ layers: list[tuple[ConfigSource, CommitGuardConfig]] = [
147
+ (ConfigSource(layer=ConfigLayer.BUILTIN), CommitGuardConfig(version=1))
148
+ ]
149
+ if include_global:
150
+ path = global_config_path()
151
+ if path.exists() or path.is_symlink():
152
+ layers.append((ConfigSource(layer=ConfigLayer.GLOBAL, path=path), load_config(path)))
153
+ if repository_root is not None:
154
+ repo_path = find_config(repository_root)
155
+ if repo_path is not None:
156
+ layers.append(
157
+ (ConfigSource(layer=ConfigLayer.REPOSITORY, path=repo_path), load_config(repo_path))
158
+ )
159
+ if explicit_path is not None:
160
+ layers.append(
161
+ (
162
+ ConfigSource(layer=ConfigLayer.EXPLICIT, path=explicit_path),
163
+ load_config(explicit_path),
164
+ )
165
+ )
166
+ return LoadedConfig(layers=tuple(layers))
167
+
168
+
169
+ def _format_validation_error(exc: ValidationError) -> str:
170
+ lines = ["invalid configuration:"]
171
+ for error in exc.errors(include_url=False, include_input=False):
172
+ location = ".".join(str(part) for part in error["loc"]) or "<root>"
173
+ lines.append(f" - {location}: {error['msg']}")
174
+ return "\n".join(lines)