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,288 @@
1
+ """A portable validation of the local enforcement path on this machine.
2
+
3
+ Designed to run unchanged on Linux, macOS and Windows (GitHub Actions runners
4
+ for the latter two), so the compatibility matrix is built from observed results
5
+ rather than from what should work. Every check records what was expected and
6
+ what was observed; a check that could not run is ``SKIPPED`` with the reason,
7
+ never ``PASS``.
8
+
9
+ The repository lives in a directory whose path contains spaces and non-ASCII
10
+ characters, because path handling is where Git hooks most often break across
11
+ platforms.
12
+ """
13
+
14
+ import os
15
+ import platform as platform_module
16
+ import sys
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ from pydantic import BaseModel, ConfigDict
22
+
23
+ from commitguard.git.hooks import (
24
+ HookState,
25
+ HookStatus,
26
+ HookType,
27
+ hook_status,
28
+ install_hooks,
29
+ repository_hooks_dir,
30
+ uninstall_hooks,
31
+ )
32
+ from commitguard.git.repository import Repository
33
+ from commitguard.research.gitenv import Workspace, workspace
34
+ from commitguard.utils.subprocess import run_command
35
+
36
+ BENCHMARK_VERSION = "1.0.0"
37
+ Status = Literal["PASS", "FAIL", "SKIPPED"]
38
+
39
+ CLEAN = "feat: add session rotation\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
40
+ AI = "feat: add payment service\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
41
+
42
+
43
+ class Check(BaseModel):
44
+ model_config = ConfigDict(frozen=True, extra="forbid")
45
+
46
+ area: str
47
+ name: str
48
+ status: Status
49
+ expected: str
50
+ observed: str
51
+
52
+
53
+ class PlatformResult(BaseModel):
54
+ model_config = ConfigDict(frozen=True, extra="forbid")
55
+
56
+ benchmark: str = "platform"
57
+ benchmark_version: str = BENCHMARK_VERSION
58
+ system: str
59
+ shell_for_hooks: str
60
+ checks: tuple[Check, ...]
61
+ passed: int
62
+ failed: int
63
+ skipped: int
64
+
65
+
66
+ def _statuses(repository: Repository) -> list[HookStatus]:
67
+ directory = repository_hooks_dir(repository)
68
+ return [hook_status(directory, hook, expected_python=sys.executable) for hook in HookType]
69
+
70
+
71
+ def _cli(space: Workspace, cwd: Path, *args: str) -> tuple[int, str]:
72
+ result = run_command(
73
+ [sys.executable, "-P", "-m", "commitguard", *args],
74
+ cwd=cwd,
75
+ env_overrides={**space.env, "PYTHONIOENCODING": "utf-8"},
76
+ timeout=300,
77
+ )
78
+ output = (result.stdout + result.stderr).decode("utf-8", "replace")
79
+ return result.returncode, output[-300:]
80
+
81
+
82
+ def run_platform() -> PlatformResult:
83
+ checks: list[Check] = []
84
+
85
+ def record(area: str, name: str, expected: str, probe: Callable[[], tuple[bool, str]]) -> bool:
86
+ try:
87
+ ok, observed = probe()
88
+ except Exception as exc: # noqa: BLE001 - a failing probe is a FAIL, not a crash
89
+ ok, observed = False, f"{type(exc).__name__}: {exc}"[:300]
90
+ checks.append(
91
+ Check(
92
+ area=area,
93
+ name=name,
94
+ status="PASS" if ok else "FAIL",
95
+ expected=expected,
96
+ observed=observed,
97
+ )
98
+ )
99
+ return ok
100
+
101
+ with workspace() as space:
102
+ record(
103
+ "environment",
104
+ "git is available",
105
+ "git --version succeeds",
106
+ lambda: (
107
+ (r := space.git(space.root, "--version", check=False)).returncode == 0,
108
+ r.stdout.decode("utf-8", "replace").strip(),
109
+ ),
110
+ )
111
+ base = space.root / "path with spaces" / "répertoire-ünïcøde"
112
+ base.mkdir(parents=True)
113
+ remote = space.init("remote.git", bare=True)
114
+ space.git(base.parent, "init", "--quiet", str(base))
115
+ path = base
116
+ space.git(path, "remote", "add", "origin", str(remote))
117
+ space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", "chore: init")
118
+ space.git(path, "push", "--quiet", "--no-verify", "origin", "main")
119
+ repository = Repository.discover(path)
120
+
121
+ installed = record(
122
+ "hooks",
123
+ "install hooks (path with spaces and non-ASCII characters)",
124
+ "pre-commit, commit-msg and pre-push installed",
125
+ lambda: (
126
+ all(
127
+ r.action.value in ("installed", "updated", "unchanged")
128
+ for r in install_hooks(repository, python=sys.executable)
129
+ ),
130
+ ", ".join(f"{s.hook.value}={s.state.value}" for s in _statuses(repository)),
131
+ ),
132
+ )
133
+ if installed:
134
+ record(
135
+ "hooks",
136
+ "hook integrity",
137
+ "every hook reports installed",
138
+ lambda: (
139
+ all(s.state is HookState.INSTALLED for s in _statuses(repository)),
140
+ ", ".join(f"{s.hook.value}={s.state.value}" for s in _statuses(repository)),
141
+ ),
142
+ )
143
+
144
+ def head() -> str:
145
+ return space.git(path, "rev-parse", "HEAD").stdout.decode().strip()
146
+
147
+ def commit_probe(
148
+ message: str, *extra: str, allowed: bool
149
+ ) -> Callable[[], tuple[bool, str]]:
150
+ def probe() -> tuple[bool, str]:
151
+ before = head()
152
+ result = space.git(
153
+ path, "commit", "--quiet", "--allow-empty", *extra, "-m", message, check=False
154
+ )
155
+ created = head() != before
156
+ ok = (
157
+ (result.returncode == 0 and created)
158
+ if allowed
159
+ else (result.returncode != 0 and not created)
160
+ )
161
+ return (
162
+ ok,
163
+ f"exit {result.returncode}, commit {'created' if created else 'not created'}",
164
+ )
165
+
166
+ return probe
167
+
168
+ record(
169
+ "pre-commit / commit-msg",
170
+ "clean commit allowed",
171
+ "exit 0, commit created",
172
+ commit_probe(CLEAN, allowed=True),
173
+ )
174
+ record(
175
+ "pre-commit / commit-msg",
176
+ "AI co-authored commit blocked",
177
+ "non-zero exit, no commit",
178
+ commit_probe(AI, allowed=False),
179
+ )
180
+ record(
181
+ "bypass",
182
+ "git commit --no-verify skips local hooks",
183
+ "exit 0, commit created (documented limitation)",
184
+ commit_probe(AI, "--no-verify", allowed=True),
185
+ )
186
+
187
+ def push_probe(
188
+ message: str, branch: str, *extra: str, allowed: bool
189
+ ) -> Callable[[], tuple[bool, str]]:
190
+ def probe() -> tuple[bool, str]:
191
+ space.git(path, "checkout", "--quiet", "-B", branch, "origin/main")
192
+ space.git(path, "commit", "--quiet", "--allow-empty", "--no-verify", "-m", message)
193
+ result = space.git(path, "push", "--quiet", *extra, "origin", branch, check=False)
194
+ ok = result.returncode == 0 if allowed else result.returncode != 0
195
+ return ok, f"exit {result.returncode}"
196
+
197
+ return probe
198
+
199
+ record("pre-push", "clean push allowed", "exit 0", push_probe(CLEAN, "clean", allowed=True))
200
+ record(
201
+ "pre-push",
202
+ "AI co-authored push blocked",
203
+ "non-zero exit",
204
+ push_probe(AI, "ai", allowed=False),
205
+ )
206
+ record(
207
+ "bypass",
208
+ "git push --no-verify skips pre-push",
209
+ "exit 0 (documented limitation)",
210
+ push_probe(AI, "ai-bypass", "--no-verify", allowed=True),
211
+ )
212
+
213
+ space.git(path, "checkout", "--quiet", "clean")
214
+ record(
215
+ "cli",
216
+ "commitguard scan on a clean commit",
217
+ "exit 0",
218
+ lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 0, f"exit {r[0]}"),
219
+ )
220
+ space.git(path, "checkout", "--quiet", "ai")
221
+ record(
222
+ "cli",
223
+ "commitguard scan on an AI co-authored commit",
224
+ "exit 1 (blocked)",
225
+ lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 1, f"exit {r[0]}"),
226
+ )
227
+ config = path / ".commitguard.yaml"
228
+ config.write_bytes(b"version: 1\r\npolicies:\r\n bot_identity:\r\n action: block\r\n")
229
+ record(
230
+ "configuration",
231
+ "configuration with CRLF line endings",
232
+ "exit 1 (valid configuration, AI commit still blocked)",
233
+ lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 1, f"exit {r[0]}"),
234
+ )
235
+ config.write_text("version: 1\npolicies: [not, a, mapping]\n", encoding="utf-8")
236
+ record(
237
+ "configuration",
238
+ "invalid configuration fails closed",
239
+ "exit 2 (error), never exit 0",
240
+ lambda: ((r := _cli(space, path, "scan", "HEAD"))[0] == 2, f"exit {r[0]}"),
241
+ )
242
+ config.unlink()
243
+ record(
244
+ "cli",
245
+ "commitguard check --message-file",
246
+ "exit 1 for an AI co-authored message",
247
+ lambda: _message_file_check(space, path),
248
+ )
249
+ record(
250
+ "hooks",
251
+ "uninstall removes the hooks",
252
+ "hooks absent; an AI commit is no longer blocked locally",
253
+ lambda: _uninstall_probe(space, path, repository),
254
+ )
255
+
256
+ passed = sum(1 for c in checks if c.status == "PASS")
257
+ failed = sum(1 for c in checks if c.status == "FAIL")
258
+ return PlatformResult(
259
+ system=(
260
+ f"{platform_module.system()} {platform_module.release()} ({platform_module.machine()})"
261
+ ),
262
+ shell_for_hooks="Git's sh (Git for Windows ships one)" if os.name == "nt" else "/bin/sh",
263
+ checks=tuple(checks),
264
+ passed=passed,
265
+ failed=failed,
266
+ skipped=len(checks) - passed - failed,
267
+ )
268
+
269
+
270
+ def _message_file_check(space: Workspace, path: Path) -> tuple[bool, str]:
271
+ message = path.parent / "message with spaces.txt"
272
+ message.write_text(AI, encoding="utf-8")
273
+ code, _ = _cli(space, path, "check", "--message-file", str(message))
274
+ return code == 1, f"exit {code}"
275
+
276
+
277
+ def _uninstall_probe(space: Workspace, path: Path, repository: Repository) -> tuple[bool, str]:
278
+ uninstall_hooks(repository)
279
+ states = {s.hook.value: s.state.value for s in _statuses(repository)}
280
+ before = space.git(path, "rev-parse", "HEAD").stdout
281
+ result = space.git(path, "commit", "--quiet", "--allow-empty", "-m", AI, check=False)
282
+ created = space.git(path, "rev-parse", "HEAD").stdout != before
283
+ ok = all(state == HookState.MISSING.value for state in states.values()) and created
284
+ return (
285
+ ok,
286
+ f"hooks {states}; AI commit exit {result.returncode}, "
287
+ f"{'created' if created else 'not created'}",
288
+ )
@@ -0,0 +1,372 @@
1
+ """Security and benchmark reports assembled from recorded evidence.
2
+
3
+ Nothing here computes a result: every number is read from a recorded benchmark
4
+ result (``benchmarks/results/raw/``) or from evidence written by the test suites
5
+ (``COMMITGUARD_EVIDENCE_DIR``). Anything that has no evidence is reported as
6
+ ``Not tested`` rather than left out, so the report cannot quietly overstate what
7
+ is known.
8
+
9
+ Every statement carries one label:
10
+
11
+ ``Measured`` a number from a recorded benchmark run on a stated machine;
12
+ ``Tested`` an automated test asserts it (test suites, experiments);
13
+ ``Observed`` an experiment recorded what actually happened, including failures
14
+ and documented bypasses;
15
+ ``Expected`` design intent that no test covers yet;
16
+ ``Not tested`` no evidence.
17
+ """
18
+
19
+ import json
20
+ from collections.abc import Iterable
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from commitguard import __version__
26
+ from commitguard.research.results import latest, load_results
27
+
28
+ LABELS = ("Measured", "Tested", "Observed", "Expected", "Not tested")
29
+ BENCHMARKS = ("detection", "performance", "hooks", "repository", "platform")
30
+
31
+
32
+ def _read_json(path: Path) -> Any:
33
+ try:
34
+ return json.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, ValueError):
36
+ return None
37
+
38
+
39
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
40
+ entries: list[dict[str, Any]] = []
41
+ try:
42
+ text = path.read_text(encoding="utf-8")
43
+ except OSError:
44
+ return entries
45
+ for line in text.splitlines():
46
+ if line.strip():
47
+ try:
48
+ entries.append(json.loads(line))
49
+ except ValueError:
50
+ continue
51
+ return entries
52
+
53
+
54
+ def _environment(manifest: dict[str, Any]) -> str:
55
+ return (
56
+ f"{manifest.get('operating_system')} {manifest.get('os_release')} "
57
+ f"({manifest.get('machine')}), {manifest.get('cpu_model')}, "
58
+ f"{manifest.get('cpu_count')} CPUs, Python {manifest.get('python_version')}, "
59
+ f"Git {manifest.get('git_version')}"
60
+ )
61
+
62
+
63
+ def collect(results_dir: Path, evidence_dir: Path | None) -> dict[str, Any]:
64
+ """Gather every recorded result and piece of evidence into one document."""
65
+ documents = {name: latest(results_dir, name) for name in BENCHMARKS}
66
+ history = [
67
+ {
68
+ "benchmark": document.get("benchmark"),
69
+ "file": path.relative_to(results_dir).as_posix(),
70
+ "timestamp": document.get("manifest", {}).get("timestamp"),
71
+ "commitguard_version": document.get("manifest", {}).get("commitguard_version"),
72
+ "dataset_version": document.get("manifest", {}).get("dataset_version"),
73
+ "source_revision": document.get("manifest", {}).get("source_revision"),
74
+ }
75
+ for path, document in load_results(results_dir)
76
+ ]
77
+ experiments = _read_jsonl(evidence_dir / "experiments.jsonl") if evidence_dir else []
78
+ fuzzing = _read_json(evidence_dir / "fuzzing.json") if evidence_dir else None
79
+ redos = _read_json(evidence_dir / "redos.json") if evidence_dir else None
80
+ return {
81
+ "generated_at": datetime.now(UTC).isoformat(),
82
+ "commitguard_version": __version__,
83
+ "results_directory": str(results_dir),
84
+ "evidence_directory": str(evidence_dir) if evidence_dir else None,
85
+ "latest": {
86
+ name: (
87
+ {"file": path.relative_to(results_dir).as_posix(), **document}
88
+ if (pair := documents[name]) and (path := pair[0]) and (document := pair[1])
89
+ else None
90
+ )
91
+ for name in BENCHMARKS
92
+ },
93
+ "history": history,
94
+ "experiments": experiments,
95
+ "fuzzing": fuzzing,
96
+ "redos": redos,
97
+ }
98
+
99
+
100
+ def _detection_section(document: dict[str, Any] | None) -> list[str]:
101
+ if document is None:
102
+ return ["| Detection accuracy | Not tested | no recorded detection run |"]
103
+ result, manifest = document["result"], document["manifest"]
104
+ decision = result["decision"]
105
+ rates = result["decision_rates"]
106
+
107
+ def percent(key: str) -> str:
108
+ value = rates.get(key)
109
+ return "n/a" if value is None else f"{value * 100:.3f}%"
110
+
111
+ return [
112
+ f"| False negatives | Measured | {decision['false_negative']} of {result['positives']} "
113
+ f"cases that must not be allowed (dataset {manifest.get('dataset_version')}) |",
114
+ f"| False positives | Measured | {decision['false_positive']} of {result['negatives']} "
115
+ f"clean cases |",
116
+ f"| Precision / recall | Measured | {percent('precision')} / {percent('recall')} |",
117
+ f"| False positive rate / false negative rate | Measured | {percent('false_positive_rate')}"
118
+ f" / {percent('false_negative_rate')} |",
119
+ f"| Detection latency per commit | Measured | p50 {result['latency']['p50_ms']:.4f} ms, "
120
+ f"p95 {result['latency']['p95_ms']:.4f} ms, p99 {result['latency']['p99_ms']:.4f} ms |",
121
+ ]
122
+
123
+
124
+ def _performance_section(document: dict[str, Any] | None) -> list[str]:
125
+ if document is None:
126
+ return ["| Performance | Not tested | no recorded performance run |"]
127
+ result = document["result"]
128
+ batches = result.get("batches") or []
129
+ messages = result.get("message_sizes") or []
130
+ lines = []
131
+ if batches:
132
+ biggest = batches[-1]
133
+ lines.append(
134
+ f"| Throughput | Measured | {biggest['commits_per_second']:.1f} commits/s on "
135
+ f"{biggest['commits']} commits "
136
+ f"(p50 {biggest['latency']['p50_ms']:.4f} ms per commit) |"
137
+ )
138
+ if messages:
139
+ biggest = messages[-1]
140
+ lines.append(
141
+ f"| Large commit messages | Measured | {biggest['message_bytes']:,} byte message: "
142
+ f"p50 {biggest['latency']['p50_ms']:.3f} ms |"
143
+ )
144
+ peak = result.get("peak_rss_bytes")
145
+ if peak:
146
+ lines.append(f"| Peak memory | Measured | {peak / 1048576:.1f} MiB for the whole run |")
147
+ return lines or ["| Performance | Not tested | recorded run has no measurements |"]
148
+
149
+
150
+ def _hooks_section(document: dict[str, Any] | None) -> list[str]:
151
+ if document is None:
152
+ return ["| Hook overhead | Not tested | no recorded hooks run |"]
153
+ result = document["result"]
154
+ lines = []
155
+ for overhead in result.get("overhead", []):
156
+ lines.append(
157
+ f"| Hook overhead: {overhead['operation']} | Measured | "
158
+ f"+{overhead['overhead_p50_ms']:.0f} ms "
159
+ f"({overhead['without_hooks_p50_ms']:.1f} -> {overhead['with_hooks_p50_ms']:.1f} ms, "
160
+ f"median of {result.get('repetitions', '?')} runs) |"
161
+ )
162
+ for observation in result.get("observations", []):
163
+ lines.append(
164
+ f"| {observation['check']} | Observed | {observation['observed']} "
165
+ f"(expected {observation['expected']}) |"
166
+ )
167
+ return lines or ["| Hook overhead | Not tested | recorded run has no measurements |"]
168
+
169
+
170
+ def _platform_section(document: dict[str, Any] | None) -> list[str]:
171
+ if document is None:
172
+ return ["| Cross-platform validation | Not tested | no recorded platform run |"]
173
+ result = document["result"]
174
+ return [
175
+ f"| Local enforcement on {result['system']} | Tested | {result['passed']} passed, "
176
+ f"{result['failed']} failed, {result['skipped']} skipped |"
177
+ ]
178
+
179
+
180
+ def _repository_section(document: dict[str, Any] | None) -> list[str]:
181
+ if document is None:
182
+ return ["| Repository history scanning | Not tested | no recorded repository run |"]
183
+ histories = document["result"].get("histories") or []
184
+ if not histories:
185
+ return ["| Repository history scanning | Not tested | recorded run has no measurements |"]
186
+ biggest = histories[-1]
187
+ return [
188
+ f"| Repository history scanning | Measured | {biggest['commits']} commits in "
189
+ f"{biggest['scan_total_ms'] / 1000:.1f} s ({biggest['commits_per_second']:.0f} commits/s); "
190
+ f"{biggest['blocked']} of {biggest['seeded_violations']} seeded violations found |"
191
+ ]
192
+
193
+
194
+ def _experiment_rows(experiments: Iterable[dict[str, Any]]) -> list[str]:
195
+ rows = []
196
+ for entry in sorted(
197
+ experiments, key=lambda item: (item.get("area", ""), item.get("attack", ""))
198
+ ):
199
+ rows.append(
200
+ f"| {entry.get('area', '')} | {entry.get('attack', '')} | "
201
+ f"**{entry.get('outcome', '')}** | {entry.get('observed', '')} | "
202
+ f"{entry.get('mitigation', '')} |"
203
+ )
204
+ return rows
205
+
206
+
207
+ def render_security_report(data: dict[str, Any]) -> str:
208
+ latest_results = data["latest"]
209
+ detection = latest_results.get("detection")
210
+ lines = [
211
+ "# CommitGuard security report",
212
+ "",
213
+ f"Generated {data['generated_at']} for CommitGuard {data['commitguard_version']}.",
214
+ "",
215
+ "Every row is labelled: **Measured** (a number from a recorded benchmark run),",
216
+ "**Tested** (an automated test asserts it), **Observed** (an experiment recorded what",
217
+ "happened, including bypasses), **Expected** (design intent, not yet covered by a test)",
218
+ "or **Not tested**. Nothing in this report is estimated.",
219
+ "",
220
+ ]
221
+ if detection:
222
+ lines += [
223
+ "## Environment of the latest runs",
224
+ "",
225
+ f"- Detection: {_environment(detection['manifest'])}",
226
+ f"- Source revision: `{detection['manifest'].get('source_revision')}`"
227
+ f" (dirty: {detection['manifest'].get('source_dirty')})",
228
+ "",
229
+ ]
230
+ lines += [
231
+ "## Detection",
232
+ "",
233
+ "| Statement | Label | Evidence |",
234
+ "|---|---|---|",
235
+ *_detection_section(detection),
236
+ "",
237
+ "## Performance",
238
+ "",
239
+ "| Statement | Label | Evidence |",
240
+ "|---|---|---|",
241
+ *_performance_section(latest_results.get("performance")),
242
+ *_repository_section(latest_results.get("repository")),
243
+ *_hooks_section(latest_results.get("hooks")),
244
+ "",
245
+ "## Cross-platform validation",
246
+ "",
247
+ "| Statement | Label | Evidence |",
248
+ "|---|---|---|",
249
+ *_platform_section(latest_results.get("platform")),
250
+ "",
251
+ ]
252
+ experiments = data.get("experiments") or []
253
+ lines += ["## Security experiments", ""]
254
+ if experiments:
255
+ outcomes: dict[str, int] = {}
256
+ for entry in experiments:
257
+ outcome = str(entry.get("outcome", "unknown"))
258
+ outcomes[outcome] = outcomes.get(outcome, 0) + 1
259
+ lines += [
260
+ f"{len(experiments)} recorded experiments: "
261
+ + ", ".join(f"{count} {name}" for name, count in sorted(outcomes.items()))
262
+ + ".",
263
+ "",
264
+ "| Area | Attack | Outcome | Observed | Mitigation |",
265
+ "|---|---|---|---|---|",
266
+ *_experiment_rows(experiments),
267
+ "",
268
+ ]
269
+ else:
270
+ lines += [
271
+ "Not tested here: no experiment evidence was found. Run",
272
+ "`commitguard reproduce security --evidence-dir <dir>` first.",
273
+ "",
274
+ ]
275
+ fuzzing, redos = data.get("fuzzing"), data.get("redos")
276
+ lines += ["## Fuzzing and ReDoS", ""]
277
+ if fuzzing:
278
+ lines += [
279
+ f"- **Tested**: {fuzzing['total_executed']} property-based examples across "
280
+ f"{len(fuzzing['executed'])} properties "
281
+ f"({'derandomized' if fuzzing.get('derandomized') else 'randomized'}).",
282
+ ]
283
+ else:
284
+ lines.append("- **Not tested**: no fuzzing evidence found.")
285
+ if redos:
286
+ worst = max((item["worst_seconds"] for item in redos["patterns"].values()), default=0.0)
287
+ lines += [
288
+ f"- **Measured**: {len(redos['patterns'])} regular expressions run against "
289
+ f"{redos['adversarial_inputs_per_pattern']} adversarial inputs of "
290
+ f"{redos['input_characters']} characters; worst case {worst * 1000:.1f} ms "
291
+ f"(budget {redos['budget_seconds'] * 1000:.0f} ms).",
292
+ ]
293
+ else:
294
+ lines.append("- **Not tested**: no ReDoS evidence found.")
295
+ lines += [
296
+ "",
297
+ "## Recorded runs",
298
+ "",
299
+ "Results are immutable: a new run is a new file, and earlier results are kept.",
300
+ "",
301
+ "| Benchmark | Runs |",
302
+ "|---|---|",
303
+ ]
304
+ counts: dict[str, int] = {}
305
+ for entry in data["history"]:
306
+ name = str(entry.get("benchmark"))
307
+ counts[name] = counts.get(name, 0) + 1
308
+ for name in sorted(counts):
309
+ lines.append(f"| {name} | {counts[name]} |")
310
+ lines += [
311
+ "",
312
+ "## What this report does not say",
313
+ "",
314
+ "- It does not claim CommitGuard cannot be bypassed: local hooks are bypassable by the",
315
+ " person running them, and the experiments record exactly that.",
316
+ "- Results labelled Measured come from the machine named above, not from a controlled",
317
+ " laboratory, and were not repeated across machines.",
318
+ "- No external party has reproduced these results yet.",
319
+ "",
320
+ ]
321
+ return "\n".join(lines) + "\n"
322
+
323
+
324
+ def render_benchmark_report(data: dict[str, Any]) -> str:
325
+ lines = [
326
+ "# CommitGuard benchmark report",
327
+ "",
328
+ f"Generated {data['generated_at']} for CommitGuard {data['commitguard_version']}.",
329
+ "",
330
+ "Every number below is read from a recorded result file; none is recomputed here.",
331
+ "",
332
+ "| Benchmark | Latest run | Version | Environment |",
333
+ "|---|---|---|---|",
334
+ ]
335
+ for name in BENCHMARKS:
336
+ document = data["latest"].get(name)
337
+ if document is None:
338
+ lines.append(f"| {name} | not recorded | - | - |")
339
+ continue
340
+ manifest = document["manifest"]
341
+ lines.append(
342
+ f"| {name} | `{document['file']}` | {manifest.get('commitguard_version')} | "
343
+ f"{manifest.get('operating_system')} {manifest.get('os_release')}, "
344
+ f"Python {manifest.get('python_version')} |"
345
+ )
346
+ lines += [
347
+ "",
348
+ "## All recorded runs",
349
+ "",
350
+ "| Benchmark | File | Timestamp | Dataset |",
351
+ "|---|---|---|---|",
352
+ ]
353
+ for entry in data["history"]:
354
+ lines.append(
355
+ f"| {entry['benchmark']} | `{entry['file']}` | {entry['timestamp']} | "
356
+ f"{entry.get('dataset_version') or '-'} |"
357
+ )
358
+ return "\n".join(lines) + "\n"
359
+
360
+
361
+ def write_reports(data: dict[str, Any], output_dir: Path) -> list[Path]:
362
+ output_dir.mkdir(parents=True, exist_ok=True)
363
+ written = []
364
+ for name, content in (
365
+ ("security-report.json", json.dumps(data, indent=2, sort_keys=True) + "\n"),
366
+ ("security-report.md", render_security_report(data)),
367
+ ("benchmark-report.md", render_benchmark_report(data)),
368
+ ):
369
+ target = output_dir / name
370
+ target.write_text(content, encoding="utf-8")
371
+ written.append(target)
372
+ return written