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,533 @@
1
+ """``commitguard doctor``: diagnose installation, configuration and enforcement.
2
+
3
+ Every check reports honestly. Output is grouped into sections and ends with an
4
+ overall status:
5
+
6
+ * HEALTHY - everything required for local enforcement is in place;
7
+ * DEGRADED - CommitGuard works but enforcement is incomplete or needs attention;
8
+ * UNHEALTHY - a check failed; hooks will block operations (exit code 2).
9
+ """
10
+
11
+ import importlib.util
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+ from typing import Annotated
17
+
18
+ import typer
19
+
20
+ from commitguard import __version__
21
+ from commitguard.cli.output import ExitCode, info, supports_unicode
22
+ from commitguard.config.enforcement import build_enforcement, build_remediation
23
+ from commitguard.config.loader import LoadedConfig, load_effective_config
24
+ from commitguard.core.decision import Action
25
+ from commitguard.exceptions.base import CommitGuardError
26
+ from commitguard.git.commands import MINIMUM_GIT_VERSION, git_executable, git_version
27
+ from commitguard.git.hooks import (
28
+ CHAINED_SUFFIX,
29
+ HookState,
30
+ HookType,
31
+ default_python,
32
+ hook_status,
33
+ repository_hooks_dir,
34
+ )
35
+ from commitguard.git.repository import Repository
36
+ from commitguard.github.workflow import WorkflowIssueLevel, inspect_repository_workflows
37
+ from commitguard.policies.loader import build_policy_set
38
+ from commitguard.provenance.author import Identity
39
+ from commitguard.rules.loader import builtin_rules_dir, load_builtin_rules
40
+ from commitguard.security.sanitization import sanitize_for_terminal
41
+ from commitguard.services.analysis import Analyzer, pending_commit
42
+ from commitguard.utils.platform import MINIMUM_PYTHON, python_version, python_version_supported
43
+ from commitguard.utils.subprocess import run_command
44
+
45
+ #: Environment variables that configure a GitHub App service on this machine.
46
+ _APP_ENVIRONMENT = (
47
+ "COMMITGUARD_GITHUB_APP_ID",
48
+ "COMMITGUARD_GITHUB_WEBHOOK_SECRET",
49
+ "COMMITGUARD_APP_DATA_DIR",
50
+ )
51
+
52
+
53
+ class Status(StrEnum):
54
+ OK = "ok"
55
+ INFO = "info" # neutral fact; never changes the overall status
56
+ NOT_CONFIGURED = "not_configured" # a capability that is simply not set up here
57
+ WARN = "warn"
58
+ FAIL = "fail"
59
+
60
+
61
+ #: What each status is called in the output. NOT CONFIGURED is deliberately not a
62
+ #: pass: an unconfigured integration enforces nothing.
63
+ LABELS = {
64
+ Status.OK: "PASS",
65
+ Status.INFO: "INFO",
66
+ Status.NOT_CONFIGURED: "NOT CONFIGURED",
67
+ Status.WARN: "WARNING",
68
+ Status.FAIL: "FAIL",
69
+ }
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class Check:
74
+ section: str
75
+ status: Status
76
+ detail: str
77
+ remediation: str = ""
78
+
79
+
80
+ def _python_runs_commitguard(python: str) -> bool:
81
+ try:
82
+ result = run_command([python, "-P", "-m", "commitguard", "--version"], timeout=30)
83
+ except (OSError, ValueError):
84
+ return False
85
+ return result.ok and result.stdout.startswith(b"commitguard ")
86
+
87
+
88
+ def _hook_checks(repository: Repository, loaded: LoadedConfig | None) -> list[Check]:
89
+ checks: list[Check] = []
90
+ try:
91
+ hooks_dir = repository_hooks_dir(repository)
92
+ except CommitGuardError as exc:
93
+ return [
94
+ Check("Hooks", Status.FAIL, str(exc), "set core.hooksPath deliberately or unset it")
95
+ ]
96
+ hooks_path_setting = repository.config_get("core.hooksPath")
97
+ if hooks_path_setting is not None:
98
+ checks.append(Check("Hooks", Status.WARN, f"core.hooksPath is set to {hooks_path_setting}"))
99
+ if not hooks_dir.is_dir():
100
+ checks.append(
101
+ Check(
102
+ "Hooks", Status.FAIL, f"hooks directory missing: {hooks_dir}", "commitguard install"
103
+ )
104
+ )
105
+ return checks
106
+ checks.append(Check("Hooks", Status.OK, f"hooks directory: {hooks_dir}"))
107
+
108
+ enforcement = build_enforcement(*loaded.configs) if loaded else None
109
+ interpreters: dict[str, bool] = {}
110
+ for hook in HookType:
111
+ status = hook_status(hooks_dir, hook, expected_python=default_python())
112
+ name = hook.value
113
+ disabled = enforcement is not None and not enforcement.enabled(name)
114
+ chained = f" (chained: {status.chained.name})" if status.chained else ""
115
+ if status.state is HookState.INSTALLED:
116
+ checks.append(Check("Hooks", Status.OK, f"{name} installed{chained}"))
117
+ elif status.state is HookState.OUTDATED:
118
+ checks.append(
119
+ Check(
120
+ "Hooks",
121
+ Status.WARN,
122
+ f"{name} installed for a different interpreter ({status.python})",
123
+ "commitguard install (to use the current installation)",
124
+ )
125
+ )
126
+ elif status.state is HookState.MISSING:
127
+ checks.append(
128
+ Check("Hooks", Status.FAIL, f"{name} hook missing", "commitguard install")
129
+ )
130
+ elif status.state is HookState.FOREIGN:
131
+ checks.append(
132
+ Check(
133
+ "Hooks",
134
+ Status.FAIL,
135
+ f"{name} hook exists but is not managed by CommitGuard",
136
+ f"commitguard install (preserves it as {name}{CHAINED_SUFFIX})",
137
+ )
138
+ )
139
+ elif status.state is HookState.MODIFIED:
140
+ checks.append(
141
+ Check(
142
+ "Hooks",
143
+ Status.WARN,
144
+ f"{name} hook appears to have been modified",
145
+ "commitguard install (restores the managed block)",
146
+ )
147
+ )
148
+ elif status.state is HookState.CORRUPT:
149
+ checks.append(
150
+ Check("Hooks", Status.FAIL, f"{name} hook markers are corrupt", "inspect the file")
151
+ )
152
+ elif status.state is HookState.NOT_EXECUTABLE:
153
+ checks.append(
154
+ Check(
155
+ "Hooks",
156
+ Status.FAIL,
157
+ f"{name} hook is not executable; Git will skip it",
158
+ "commitguard install",
159
+ )
160
+ )
161
+ if status.python and status.state is not HookState.MISSING:
162
+ if status.python not in interpreters:
163
+ interpreters[status.python] = bool(status.python_available) and (
164
+ _python_runs_commitguard(status.python)
165
+ )
166
+ if not interpreters[status.python]:
167
+ checks.append(
168
+ Check(
169
+ "Hooks",
170
+ Status.WARN,
171
+ f"{name} hook interpreter cannot run CommitGuard ({status.python}); "
172
+ "the hook will fall back to `commitguard` on PATH or block",
173
+ "commitguard install",
174
+ )
175
+ )
176
+ if disabled:
177
+ checks.append(
178
+ Check(
179
+ "Enforcement",
180
+ Status.WARN,
181
+ f"{name} enforcement disabled in configuration",
182
+ "set enforcement." + name.replace("-", "_") + ": true",
183
+ )
184
+ )
185
+ if loaded is not None and build_remediation(*loaded.configs).auto_remove:
186
+ # This turns a blocked commit into a commit. It must never be invisible.
187
+ checks.append(
188
+ Check(
189
+ "Enforcement",
190
+ Status.WARN,
191
+ "remediation.auto_remove is on: prohibited attribution is deleted from the "
192
+ "commit message instead of blocking the commit (identity findings still block)",
193
+ "set remediation.auto_remove: false to block instead",
194
+ )
195
+ )
196
+ return checks
197
+
198
+
199
+ def _run_checks() -> list[Check]:
200
+ checks: list[Check] = []
201
+ minimum_python = ".".join(map(str, MINIMUM_PYTHON))
202
+ checks.append(
203
+ Check(
204
+ "CommitGuard",
205
+ Status.OK if python_version_supported() else Status.FAIL,
206
+ f"commitguard {__version__} on Python {python_version()} "
207
+ f"(requires >= {minimum_python})",
208
+ )
209
+ )
210
+ checks.append(Check("CommitGuard", Status.OK, f"interpreter: {default_python()}"))
211
+ checks.extend(_dependency_checks())
212
+
213
+ try:
214
+ executable = git_executable()
215
+ version = git_version()
216
+ except CommitGuardError as exc:
217
+ checks.append(Check("Git", Status.FAIL, str(exc), "install Git >= 2.31"))
218
+ return checks
219
+ minimum_git = ".".join(map(str, MINIMUM_GIT_VERSION))
220
+ checks.append(
221
+ Check(
222
+ "Git",
223
+ Status.OK if version[:2] >= MINIMUM_GIT_VERSION else Status.FAIL,
224
+ f"Git {'.'.join(map(str, version))} at {executable} (requires >= {minimum_git})",
225
+ )
226
+ )
227
+
228
+ try:
229
+ repository = Repository.discover()
230
+ except CommitGuardError:
231
+ checks.append(
232
+ Check("Repository", Status.WARN, "not inside a Git work tree", "cd into a repository")
233
+ )
234
+ return checks
235
+ checks.append(Check("Repository", Status.OK, f"repository: {repository.root}"))
236
+ checks.append(Check("Repository", Status.OK, f"Git directory: {repository.git_dir}"))
237
+
238
+ loaded: LoadedConfig | None = None
239
+ try:
240
+ loaded = load_effective_config(repository.root)
241
+ except CommitGuardError as exc:
242
+ checks.append(
243
+ Check("Configuration", Status.FAIL, str(exc), "fix the file; hooks block until then")
244
+ )
245
+ else:
246
+ if not any(source.path for source in loaded.sources):
247
+ checks.append(
248
+ Check(
249
+ "Configuration",
250
+ Status.NOT_CONFIGURED,
251
+ "no configuration files; built-in defaults apply",
252
+ "commitguard init",
253
+ )
254
+ )
255
+ else:
256
+ layers = ", ".join(str(source) for source in loaded.sources)
257
+ checks.append(Check("Configuration", Status.OK, f"configuration valid ({layers})"))
258
+
259
+ try:
260
+ policies = build_policy_set(*(loaded.configs if loaded else ()))
261
+ analyzer = Analyzer.create(policies)
262
+ probe = pending_commit(
263
+ "probe\n\nCo-authored-by: Claude <noreply@anthropic.com>\n",
264
+ Identity(name="Doctor", email="doctor@example.com"),
265
+ Identity(name="Doctor", email="doctor@example.com"),
266
+ )
267
+ report = analyzer.analyze(probe)
268
+ except CommitGuardError as exc:
269
+ checks.append(Check("Detection engine", Status.FAIL, str(exc), "reinstall CommitGuard"))
270
+ else:
271
+ if report.failures or not any(f.finding.rule_id == "ai_coauthor" for f in report.findings):
272
+ checks.append(
273
+ Check(
274
+ "Detection engine", Status.FAIL, "self-test did not detect a known AI co-author"
275
+ )
276
+ )
277
+ else:
278
+ checks.append(Check("Detection engine", Status.OK, "available (self-test passed)"))
279
+ enabled = [p for p in policies.values() if p.enabled]
280
+ blocking = [p for p in enabled if p.action is Action.BLOCK]
281
+ checks.append(
282
+ Check(
283
+ "Policies",
284
+ Status.OK,
285
+ f"{len(enabled)} of {len(policies)} policies enabled, {len(blocking)} blocking",
286
+ )
287
+ )
288
+ if not blocking:
289
+ checks.append(Check("Policies", Status.WARN, "no policy is set to block"))
290
+
291
+ checks.extend(_rule_checks(repository))
292
+ checks.extend(_hook_checks(repository, loaded))
293
+ checks.extend(_github_checks(repository))
294
+ checks.extend(_app_checks())
295
+ return checks
296
+
297
+
298
+ def _dependency_checks() -> list[Check]:
299
+ """Runtime dependencies: the ones missing at run time, not at install time."""
300
+ checks = []
301
+ for module, purpose in (("yaml", "configuration and rules"), ("pydantic", "data models")):
302
+ if importlib.util.find_spec(module) is not None:
303
+ checks.append(
304
+ Check("Runtime dependencies", Status.OK, f"{module} available ({purpose})")
305
+ )
306
+ else:
307
+ checks.append(
308
+ Check(
309
+ "Runtime dependencies",
310
+ Status.FAIL,
311
+ f"{module} is not importable ({purpose})",
312
+ "reinstall CommitGuard",
313
+ )
314
+ )
315
+ # Presence only: importing it here would make every CLI invocation pay for it, and
316
+ # the architecture tests keep cryptography out of everything but App authentication.
317
+ if importlib.util.find_spec("cryptography") is None:
318
+ checks.append(
319
+ Check(
320
+ "Runtime dependencies",
321
+ Status.NOT_CONFIGURED,
322
+ "cryptography is not installed; only the GitHub App service needs it",
323
+ )
324
+ )
325
+ else:
326
+ checks.append(
327
+ Check("Runtime dependencies", Status.OK, "cryptography available (GitHub App service)")
328
+ )
329
+ return checks
330
+
331
+
332
+ def _app_checks() -> list[Check]:
333
+ """Whether a GitHub App service is configured *on this machine*.
334
+
335
+ Configuration is not a connection: this only reports what is set here, never
336
+ that an installation is healthy on GitHub.
337
+ """
338
+ configured = [name for name in _APP_ENVIRONMENT if os.environ.get(name)]
339
+ if not configured:
340
+ return [
341
+ Check(
342
+ "GitHub App",
343
+ Status.NOT_CONFIGURED,
344
+ "no GitHub App service is configured on this machine",
345
+ "only needed if you host the App: see docs/deployment/github-app.md",
346
+ )
347
+ ]
348
+ missing = [name for name in _APP_ENVIRONMENT if not os.environ.get(name)]
349
+ if missing:
350
+ return [
351
+ Check(
352
+ "GitHub App",
353
+ Status.WARN,
354
+ f"GitHub App settings are incomplete: {', '.join(missing)} not set",
355
+ "commitguard github validate",
356
+ )
357
+ ]
358
+ return [
359
+ Check(
360
+ "GitHub App",
361
+ Status.INFO,
362
+ "GitHub App settings are present; this does not verify the installation",
363
+ "commitguard github validate (checks authentication and permissions)",
364
+ )
365
+ ]
366
+
367
+
368
+ def _rule_checks(repository: Repository) -> list[Check]:
369
+ try:
370
+ rules_dir = builtin_rules_dir()
371
+ rules = load_builtin_rules()
372
+ except CommitGuardError as exc:
373
+ return [Check("Rules", Status.FAIL, str(exc), "reinstall CommitGuard")]
374
+ checks = [
375
+ Check(
376
+ "Rules",
377
+ Status.OK,
378
+ f"bundled rules available ({len(rules.rules.ai_identities.agents)} AI agents, "
379
+ f"{len(rules.rules.bots.bots)} bots) from {rules_dir}",
380
+ )
381
+ ]
382
+ repo_rules = repository.root / "rules" / "ai-identities.yaml"
383
+ try:
384
+ same = repo_rules.exists() and repo_rules.resolve().parent == rules_dir.resolve()
385
+ except OSError:
386
+ same = False
387
+ if repo_rules.exists() and not same:
388
+ checks.append(
389
+ Check(
390
+ "Rules",
391
+ Status.INFO,
392
+ "this repository's rules/ directory is not used; detection rules always come "
393
+ "from the installed CommitGuard package",
394
+ )
395
+ )
396
+ return checks
397
+
398
+
399
+ def _github_checks(repository: Repository) -> list[Check]:
400
+ section = "GitHub enforcement"
401
+ inspections = inspect_repository_workflows(repository.root)
402
+ if not inspections:
403
+ return [
404
+ Check(
405
+ section,
406
+ Status.NOT_CONFIGURED,
407
+ "no GitHub workflow runs CommitGuard (local enforcement only)",
408
+ "commitguard init --github --action-repository OWNER/REPO --action-ref <sha>",
409
+ )
410
+ ]
411
+ checks = []
412
+ for inspection in inspections:
413
+ path = inspection.path.relative_to(repository.root).as_posix()
414
+ names = ", ".join(inspection.check_names)
415
+ checks.append(Check(section, Status.OK, f"workflow exists: {path} (check: {names})"))
416
+ for issue in inspection.issues:
417
+ status = {
418
+ WorkflowIssueLevel.OK: Status.INFO,
419
+ WorkflowIssueLevel.WARN: Status.WARN,
420
+ WorkflowIssueLevel.FAIL: Status.FAIL,
421
+ }[issue.level]
422
+ checks.append(
423
+ Check(section, status, f"{path}: {issue.message}", "commitguard github setup")
424
+ )
425
+ checks.append(
426
+ Check(
427
+ section,
428
+ Status.INFO,
429
+ "branch protection cannot be verified locally; the check only blocks merges when "
430
+ "it is required on protected branches",
431
+ "commitguard github setup",
432
+ )
433
+ )
434
+ return checks
435
+
436
+
437
+ def _enforcement_summary(checks: list[Check]) -> tuple[str, bool, bool]:
438
+ hook_problem = any(
439
+ c.section in ("Hooks", "Enforcement") and c.status in (Status.WARN, Status.FAIL)
440
+ for c in checks
441
+ )
442
+ local_ready = any(c.section == "Hooks" for c in checks) and not hook_problem
443
+ github_ready = any(
444
+ c.section == "GitHub enforcement" and c.detail.startswith("workflow exists") for c in checks
445
+ ) and not any(
446
+ c.section == "GitHub enforcement" and c.status in (Status.WARN, Status.FAIL) for c in checks
447
+ )
448
+ enforcement = {
449
+ (True, True): "LOCAL + GITHUB ENFORCEMENT READY (branch protection not verified)",
450
+ (True, False): "LOCAL ENFORCEMENT ONLY",
451
+ (False, True): "GITHUB WORKFLOW READY, LOCAL ENFORCEMENT INCOMPLETE "
452
+ "(branch protection not verified)",
453
+ (False, False): "NO COMPLETE ENFORCEMENT LAYER",
454
+ }[(local_ready, github_ready)]
455
+ return enforcement, hook_problem, local_ready
456
+
457
+
458
+ def doctor_command(
459
+ as_json: Annotated[
460
+ bool, typer.Option("--json", help="Print the checks as a JSON document.")
461
+ ] = False,
462
+ ) -> None:
463
+ """Check installation, configuration, detection engine and hook enforcement.
464
+
465
+ Each check reports PASS, WARNING, FAIL, NOT CONFIGURED or INFO. NOT CONFIGURED
466
+ is never a pass: a capability that is not set up enforces nothing.
467
+ """
468
+ checks = _run_checks()
469
+ enforcement, hook_problem, _ = _enforcement_summary(checks)
470
+ failed = any(c.status is Status.FAIL for c in checks)
471
+ warned = any(c.status is Status.WARN for c in checks)
472
+ status = "UNHEALTHY" if failed else ("DEGRADED" if warned else "HEALTHY")
473
+
474
+ if as_json:
475
+ info(
476
+ json.dumps(
477
+ {
478
+ "commitguard_version": __version__,
479
+ "status": status,
480
+ "enforcement": enforcement,
481
+ "counts": {
482
+ LABELS[value]: sum(1 for c in checks if c.status is value)
483
+ for value in Status
484
+ },
485
+ "checks": [
486
+ {
487
+ "section": c.section,
488
+ "status": LABELS[c.status],
489
+ "detail": sanitize_for_terminal(c.detail, max_length=1000),
490
+ "remediation": sanitize_for_terminal(c.remediation, max_length=300)
491
+ or None,
492
+ }
493
+ for c in checks
494
+ ],
495
+ },
496
+ indent=2,
497
+ )
498
+ )
499
+ if failed:
500
+ raise typer.Exit(code=int(ExitCode.ERROR))
501
+ return
502
+
503
+ ok, cross = ("\u2713", "\u2717") if supports_unicode() else ("OK", "X")
504
+ bang = "!"
505
+ symbol = {
506
+ Status.OK: ok,
507
+ Status.INFO: "i",
508
+ Status.NOT_CONFIGURED: "-",
509
+ Status.WARN: bang,
510
+ Status.FAIL: cross,
511
+ }
512
+ info("CommitGuard Doctor")
513
+ section = None
514
+ for check in checks:
515
+ if check.section != section:
516
+ section = check.section
517
+ info("")
518
+ info(section)
519
+ label = LABELS[check.status]
520
+ info(
521
+ f"{symbol[check.status]} {label:<14} "
522
+ f"{sanitize_for_terminal(check.detail, max_length=1000)}"
523
+ )
524
+ if check.remediation and check.status in (Status.WARN, Status.FAIL, Status.NOT_CONFIGURED):
525
+ info(f" Fix: {sanitize_for_terminal(check.remediation, max_length=300)}")
526
+
527
+ info("")
528
+ if hook_problem:
529
+ info("Security enforcement is incomplete.")
530
+ info(f"Enforcement: {enforcement}")
531
+ info(f"Status: {status}")
532
+ if failed:
533
+ raise typer.Exit(code=int(ExitCode.ERROR))