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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|