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,111 @@
1
+ """Scanning real repository histories of increasing size.
2
+
3
+ For each size a repository is created with ``git fast-import`` (every 20th
4
+ commit carries an AI co-author trailer, one in twenty-five is authored by an
5
+ automation account through its message only - the mix matters less than the
6
+ count). The measured operation is what ``commitguard scan`` and the CI and App
7
+ range scans do: list the commits of a range with ``git rev-list``, read their
8
+ metadata with batched ``git cat-file``, and analyse each commit.
9
+
10
+ Reported per size: repository creation time (not part of scanning), listing,
11
+ reading and analysis time separately, commits per second, and whether every
12
+ seeded violation was found.
13
+ """
14
+
15
+ import time
16
+ from collections.abc import Sequence
17
+
18
+ from pydantic import BaseModel, ConfigDict
19
+
20
+ from commitguard.core.decision import Action
21
+ from commitguard.git.repository import Repository
22
+ from commitguard.policies.defaults import default_policy_set
23
+ from commitguard.research.gitenv import fast_import_stream, workspace
24
+ from commitguard.services.analysis import Analyzer
25
+
26
+ BENCHMARK_VERSION = "1.0.0"
27
+ HISTORY_SIZES = (100, 1_000, 10_000, 100_000)
28
+ AI_EVERY = 20
29
+
30
+
31
+ class HistoryResult(BaseModel):
32
+ model_config = ConfigDict(frozen=True, extra="forbid")
33
+
34
+ commits: int
35
+ create_ms: float
36
+ list_ms: float
37
+ read_ms: float
38
+ analyze_ms: float
39
+ scan_total_ms: float
40
+ commits_per_second: float
41
+ seeded_violations: int
42
+ blocked: int
43
+ all_seeded_violations_found: bool
44
+
45
+
46
+ class RepositoryResult(BaseModel):
47
+ model_config = ConfigDict(frozen=True, extra="forbid")
48
+
49
+ benchmark: str = "repository"
50
+ benchmark_version: str = BENCHMARK_VERSION
51
+ histories: tuple[HistoryResult, ...]
52
+ notes: tuple[str, ...]
53
+
54
+
55
+ def _messages(count: int) -> list[str]:
56
+ return [
57
+ f"feat: change {i}\n\nCo-authored-by: Claude <noreply@anthropic.com>\n"
58
+ if i % AI_EVERY == AI_EVERY - 1
59
+ else f"fix: change {i}\n\nSigned-off-by: Ada Lovelace <ada@example.com>\n"
60
+ for i in range(count)
61
+ ]
62
+
63
+
64
+ def run_repository(sizes: Sequence[int] = HISTORY_SIZES) -> RepositoryResult:
65
+ analyzer = Analyzer.create(default_policy_set())
66
+ histories = []
67
+ for size in sizes:
68
+ if size < 2:
69
+ raise ValueError("history sizes must be at least 2")
70
+ with workspace() as space:
71
+ path = space.init(f"history-{size}")
72
+ messages = _messages(size)
73
+ started = time.perf_counter()
74
+ space.git(path, "fast-import", "--quiet", input_bytes=fast_import_stream(messages))
75
+ space.git(path, "checkout", "--quiet", "main")
76
+ create = time.perf_counter() - started
77
+
78
+ repository = Repository.discover(path)
79
+ first = repository.resolve_commit("main~" + str(size - 1))
80
+ started = time.perf_counter()
81
+ shas = [first, *repository.list_commits(f"{first}..main", max_count=size)]
82
+ listed = time.perf_counter()
83
+ commits = repository.read_commits(shas)
84
+ read = time.perf_counter()
85
+ blocked = sum(1 for c in commits if analyzer.analyze(c).action is Action.BLOCK)
86
+ analyzed = time.perf_counter()
87
+ seeded = sum(1 for i in range(size) if i % AI_EVERY == AI_EVERY - 1)
88
+ total = analyzed - started
89
+ histories.append(
90
+ HistoryResult(
91
+ commits=len(shas),
92
+ create_ms=round(create * 1000, 1),
93
+ list_ms=round((listed - started) * 1000, 1),
94
+ read_ms=round((read - listed) * 1000, 1),
95
+ analyze_ms=round((analyzed - read) * 1000, 1),
96
+ scan_total_ms=round(total * 1000, 1),
97
+ commits_per_second=round(len(shas) / total, 1) if total > 0 else 0.0,
98
+ seeded_violations=seeded,
99
+ blocked=blocked,
100
+ all_seeded_violations_found=blocked == seeded,
101
+ )
102
+ )
103
+ return RepositoryResult(
104
+ histories=tuple(histories),
105
+ notes=(
106
+ "The commitguard scan command limits a range to 1,000 commits by default (a safety "
107
+ "bound); this benchmark calls the same library functions without that bound to "
108
+ "measure scaling.",
109
+ "Repositories are local; fetch time from a remote is not included.",
110
+ ),
111
+ )
@@ -0,0 +1,297 @@
1
+ """Re-running the evidence CommitGuard publishes.
2
+
3
+ ``commitguard reproduce`` exists so that someone who is not the author can check
4
+ the claims on their own machine. Each step reports one of:
5
+
6
+ ``PASS`` the step ran and its checks held;
7
+ ``FAIL`` the step ran and a check did not hold;
8
+ ``SKIPPED`` the step could not run (missing test files, pytest or credentials),
9
+ with the reason - never reported as a success;
10
+ ``NOT RUN`` the step was not selected.
11
+
12
+ Steps that need the repository (the test suites) are skipped when CommitGuard is
13
+ installed without it. Steps that need GitHub credentials are skipped when they
14
+ are not configured; no step ever invents a result.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from collections.abc import Sequence
23
+ from pathlib import Path
24
+ from typing import Literal
25
+
26
+ from pydantic import BaseModel, ConfigDict
27
+
28
+ import commitguard
29
+
30
+ Status = Literal["PASS", "FAIL", "SKIPPED", "NOT RUN"]
31
+ AREAS = ("security", "benchmark", "integration", "github")
32
+ #: Environment variables the GitHub step needs (the App's own credentials).
33
+ GITHUB_ENVIRONMENT = (
34
+ "COMMITGUARD_GITHUB_APP_ID",
35
+ "COMMITGUARD_GITHUB_WEBHOOK_SECRET",
36
+ "COMMITGUARD_APP_DATA_DIR",
37
+ )
38
+
39
+
40
+ class StepResult(BaseModel):
41
+ model_config = ConfigDict(frozen=True, extra="forbid")
42
+
43
+ area: str
44
+ name: str
45
+ status: Status
46
+ detail: str
47
+ seconds: float = 0.0
48
+ command: str = ""
49
+
50
+
51
+ class ReproductionResult(BaseModel):
52
+ model_config = ConfigDict(frozen=True, extra="forbid")
53
+
54
+ benchmark: str = "reproduce"
55
+ benchmark_version: str = "1.0.0"
56
+ steps: tuple[StepResult, ...]
57
+ passed: int
58
+ failed: int
59
+ skipped: int
60
+ not_run: int
61
+
62
+ @property
63
+ def ok(self) -> bool:
64
+ return self.failed == 0
65
+
66
+
67
+ def source_checkout() -> Path | None:
68
+ """The repository this package was installed from, if the tests are present."""
69
+ root = Path(commitguard.__file__).resolve().parents[2]
70
+ return root if (root / "tests").is_dir() and (root / "pyproject.toml").is_file() else None
71
+
72
+
73
+ def _pytest_available() -> bool:
74
+ try:
75
+ import pytest # noqa: F401
76
+ except ImportError:
77
+ return False
78
+ return True
79
+
80
+
81
+ def _run(arguments: Sequence[str], *, cwd: Path, environment: dict[str, str]) -> tuple[int, str]:
82
+ completed = subprocess.run( # noqa: S603 - fixed argument vector, no shell
83
+ list(arguments),
84
+ cwd=cwd,
85
+ env={**os.environ, **environment},
86
+ capture_output=True,
87
+ timeout=3600,
88
+ check=False,
89
+ )
90
+ output = (completed.stdout + completed.stderr).decode("utf-8", "replace")
91
+ return completed.returncode, output
92
+
93
+
94
+ def _summary_line(output: str) -> str:
95
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
96
+ for line in reversed(lines):
97
+ if " passed" in line or " failed" in line or " error" in line:
98
+ return line.strip("= ").strip()
99
+ return lines[-1][:200] if lines else "no output"
100
+
101
+
102
+ def _pytest_step(
103
+ area: str, name: str, arguments: Sequence[str], evidence_dir: Path | None
104
+ ) -> StepResult:
105
+ root = source_checkout()
106
+ command = "python -m pytest " + " ".join(arguments)
107
+ if root is None:
108
+ return StepResult(
109
+ area=area,
110
+ name=name,
111
+ status="SKIPPED",
112
+ detail=(
113
+ "the test suite is not installed with the package; clone the repository and "
114
+ "run this from the checkout"
115
+ ),
116
+ command=command,
117
+ )
118
+ if not _pytest_available():
119
+ return StepResult(
120
+ area=area,
121
+ name=name,
122
+ status="SKIPPED",
123
+ detail='pytest is not installed; install the development dependencies (".[dev]")',
124
+ command=command,
125
+ )
126
+ environment = {"COMMITGUARD_EVIDENCE_DIR": str(evidence_dir)} if evidence_dir else {}
127
+ started = time.perf_counter()
128
+ code, output = _run(
129
+ [sys.executable, "-m", "pytest", *arguments], cwd=root, environment=environment
130
+ )
131
+ return StepResult(
132
+ area=area,
133
+ name=name,
134
+ status="PASS" if code == 0 else "FAIL",
135
+ detail=_summary_line(output),
136
+ seconds=round(time.perf_counter() - started, 3),
137
+ command=command,
138
+ )
139
+
140
+
141
+ def security_steps(evidence_dir: Path | None) -> list[StepResult]:
142
+ return [
143
+ _pytest_step(
144
+ "security",
145
+ "security regression suite (pytest -m security)",
146
+ ["-q", "-m", "security", "-p", "no:cacheprovider"],
147
+ evidence_dir,
148
+ )
149
+ ]
150
+
151
+
152
+ def integration_steps(evidence_dir: Path | None) -> list[StepResult]:
153
+ return [
154
+ _pytest_step(
155
+ "integration",
156
+ "integration suite (tests/integration)",
157
+ ["-q", "tests/integration", "-p", "no:cacheprovider"],
158
+ evidence_dir,
159
+ )
160
+ ]
161
+
162
+
163
+ def benchmark_steps(results_dir: Path | None = None) -> list[StepResult]:
164
+ """Rebuild the dataset, check its fingerprint, and re-measure detection."""
165
+ from commitguard.research.datasets import (
166
+ DATASET_VERSION,
167
+ build_dataset,
168
+ fingerprint,
169
+ load_dataset,
170
+ )
171
+ from commitguard.research.detection import run_detection
172
+
173
+ steps: list[StepResult] = []
174
+ started = time.perf_counter()
175
+ cases = build_dataset(version=DATASET_VERSION)
176
+ built = fingerprint(cases)
177
+ published = results_dir.parent / "datasets" / f"v{DATASET_VERSION}" if results_dir else None
178
+ manifest_file = published / "manifest.json" if published else None
179
+ if published is not None and manifest_file is not None and manifest_file.is_file():
180
+ manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
181
+ # The files are written one per class, so loading returns the cases grouped by
182
+ # class: compare the recorded fingerprint, and the cases themselves by id.
183
+ # Reads the local JSONL files in benchmarks/datasets; not the Hugging Face
184
+ # hub function that Bandit's B615 rule looks for.
185
+ published_cases = load_dataset(published) # nosec B615
186
+ same_cases = sorted(published_cases, key=lambda case: case.id) == sorted(
187
+ cases, key=lambda case: case.id
188
+ )
189
+ matches = manifest.get("fingerprint") == built and same_cases
190
+ steps.append(
191
+ StepResult(
192
+ area="benchmark",
193
+ name=f"dataset {DATASET_VERSION} matches the published files",
194
+ status="PASS" if matches else "FAIL",
195
+ detail=(
196
+ f"rebuilt {built[:16]}, published {str(manifest.get('fingerprint'))[:16]}, "
197
+ f"{len(cases)} cases, contents identical: {same_cases}"
198
+ ),
199
+ seconds=round(time.perf_counter() - started, 3),
200
+ command=(
201
+ "commitguard benchmark dataset --write <dir> "
202
+ f"--dataset-version {DATASET_VERSION}"
203
+ ),
204
+ )
205
+ )
206
+ else:
207
+ steps.append(
208
+ StepResult(
209
+ area="benchmark",
210
+ name=f"dataset {DATASET_VERSION} rebuilt deterministically",
211
+ status="PASS",
212
+ detail=(
213
+ f"fingerprint {built[:16]} ({len(cases)} cases); no published copy to compare"
214
+ ),
215
+ seconds=round(time.perf_counter() - started, 3),
216
+ )
217
+ )
218
+ started = time.perf_counter()
219
+ result = run_detection(cases)
220
+ clean = result.decision.false_negative == 0 and result.decision.false_positive == 0
221
+ steps.append(
222
+ StepResult(
223
+ area="benchmark",
224
+ name="detection: no false negative and no false positive",
225
+ status="PASS" if clean else "FAIL",
226
+ detail=(
227
+ f"{result.decision.false_negative} false negatives, "
228
+ f"{result.decision.false_positive} false positives over {len(cases)} cases"
229
+ ),
230
+ seconds=round(time.perf_counter() - started, 3),
231
+ command=f"commitguard benchmark detection --dataset-version {DATASET_VERSION}",
232
+ )
233
+ )
234
+ return steps
235
+
236
+
237
+ def github_steps() -> list[StepResult]:
238
+ missing = [name for name in GITHUB_ENVIRONMENT if not os.environ.get(name)]
239
+ command = "commitguard github validate"
240
+ if missing:
241
+ return [
242
+ StepResult(
243
+ area="github",
244
+ name="GitHub App configuration and permissions",
245
+ status="SKIPPED",
246
+ detail=f"GitHub credentials not configured ({', '.join(missing)} not set)",
247
+ command=command,
248
+ )
249
+ ]
250
+ started = time.perf_counter()
251
+ code, output = _run(
252
+ [sys.executable, "-P", "-m", "commitguard", "github", "validate"],
253
+ cwd=Path.cwd(),
254
+ environment={},
255
+ )
256
+ return [
257
+ StepResult(
258
+ area="github",
259
+ name="GitHub App configuration and permissions",
260
+ status="PASS" if code == 0 else "FAIL",
261
+ detail=_summary_line(output),
262
+ seconds=round(time.perf_counter() - started, 3),
263
+ command=command,
264
+ )
265
+ ]
266
+
267
+
268
+ def reproduce(
269
+ areas: Sequence[str], *, evidence_dir: Path | None = None, results_dir: Path | None = None
270
+ ) -> ReproductionResult:
271
+ selected = [area for area in AREAS if area in areas]
272
+ steps: list[StepResult] = []
273
+ for area in AREAS:
274
+ if area not in selected:
275
+ steps.append(
276
+ StepResult(area=area, name=f"{area} steps", status="NOT RUN", detail="not selected")
277
+ )
278
+ continue
279
+ if area == "security":
280
+ steps.extend(security_steps(evidence_dir))
281
+ elif area == "benchmark":
282
+ steps.extend(benchmark_steps(results_dir))
283
+ elif area == "integration":
284
+ steps.extend(integration_steps(evidence_dir))
285
+ else:
286
+ steps.extend(github_steps())
287
+ counts = {
288
+ status: sum(1 for step in steps if step.status == status)
289
+ for status in ("PASS", "FAIL", "SKIPPED", "NOT RUN")
290
+ }
291
+ return ReproductionResult(
292
+ steps=tuple(steps),
293
+ passed=counts["PASS"],
294
+ failed=counts["FAIL"],
295
+ skipped=counts["SKIPPED"],
296
+ not_run=counts["NOT RUN"],
297
+ )
@@ -0,0 +1,94 @@
1
+ """Immutable benchmark results.
2
+
3
+ A result is written once, to a new file named after the benchmark, the time and
4
+ the CommitGuard version; an existing file is never overwritten. Newer runs do
5
+ not replace older ones - historical results stay as they were measured, so an
6
+ improvement between versions remains visible and verifiable.
7
+
8
+ Layout::
9
+
10
+ benchmarks/results/raw/<benchmark>/<UTC timestamp>_<version>.json
11
+ benchmarks/results/processed/index.json (regenerated from raw/)
12
+ """
13
+
14
+ import json
15
+ import re
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from commitguard.research.environment import BenchmarkManifest
23
+
24
+ RESULT_SCHEMA = 1
25
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
26
+
27
+
28
+ class ResultExistsError(FileExistsError):
29
+ pass
30
+
31
+
32
+ def result_document(
33
+ benchmark: str, manifest: BenchmarkManifest, result: BaseModel | dict[str, Any]
34
+ ) -> dict[str, Any]:
35
+ payload = result.model_dump(mode="json") if isinstance(result, BaseModel) else result
36
+ return {
37
+ "schema_version": RESULT_SCHEMA,
38
+ "benchmark": benchmark,
39
+ "manifest": manifest.model_dump(mode="json"),
40
+ "result": payload,
41
+ }
42
+
43
+
44
+ def write_result(root: Path, document: dict[str, Any]) -> Path:
45
+ """Write ``document`` under ``root``/raw and refresh the index. Never overwrites."""
46
+ benchmark = _SAFE.sub("-", str(document["benchmark"]))
47
+ manifest = document["manifest"]
48
+ stamp = datetime.fromisoformat(str(manifest["timestamp"])).astimezone(UTC)
49
+ version = _SAFE.sub("-", str(manifest["commitguard_version"]))
50
+ target = root / "raw" / benchmark / f"{stamp.strftime('%Y%m%dT%H%M%S%fZ')}_{version}.json"
51
+ target.parent.mkdir(parents=True, exist_ok=True)
52
+ try:
53
+ with target.open("x", encoding="utf-8", newline="\n") as handle:
54
+ handle.write(json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
55
+ except FileExistsError:
56
+ raise ResultExistsError(f"{target} already exists; results are never overwritten") from None
57
+ rebuild_index(root)
58
+ return target
59
+
60
+
61
+ def load_results(root: Path) -> list[tuple[Path, dict[str, Any]]]:
62
+ results = []
63
+ for path in sorted((root / "raw").glob("*/*.json")):
64
+ try:
65
+ results.append((path, json.loads(path.read_text(encoding="utf-8"))))
66
+ except ValueError:
67
+ continue
68
+ return results
69
+
70
+
71
+ def latest(root: Path, benchmark: str) -> tuple[Path, dict[str, Any]] | None:
72
+ matching = [item for item in load_results(root) if item[1].get("benchmark") == benchmark]
73
+ return matching[-1] if matching else None
74
+
75
+
76
+ def rebuild_index(root: Path) -> Path:
77
+ entries = []
78
+ for path, document in load_results(root):
79
+ manifest = document.get("manifest", {})
80
+ entries.append(
81
+ {
82
+ "benchmark": document.get("benchmark"),
83
+ "file": path.relative_to(root).as_posix(),
84
+ "timestamp": manifest.get("timestamp"),
85
+ "commitguard_version": manifest.get("commitguard_version"),
86
+ "operating_system": manifest.get("operating_system"),
87
+ "dataset_version": manifest.get("dataset_version"),
88
+ "rules_version": manifest.get("rules_version"),
89
+ }
90
+ )
91
+ index = root / "processed" / "index.json"
92
+ index.parent.mkdir(parents=True, exist_ok=True)
93
+ index.write_text(json.dumps({"results": entries}, indent=2) + "\n", encoding="utf-8")
94
+ return index
@@ -0,0 +1,11 @@
1
+ """Detection rules as data.
2
+
3
+ Detectors never hard-code agent names. The pipeline is::
4
+
5
+ rules/*.yaml --(loader: safe YAML + schema)--> RuleSet
6
+ RuleSet --> IdentityMatcher (normalisation, deliberate matching)
7
+ Detector + IdentityMatcher --> Finding
8
+
9
+ :mod:`commitguard.rules.models` and :mod:`commitguard.rules.matcher` are pure;
10
+ only :mod:`commitguard.rules.loader` reads files.
11
+ """
@@ -0,0 +1,51 @@
1
+ # Email domains associated with AI agent vendors.
2
+ #
3
+ # A vendor domain alone is NOT evidence of an AI agent: people who work at these
4
+ # companies commit with the same domain. Matching semantics:
5
+ #
6
+ # local_parts: automation (default)
7
+ # <automation local part>@domain (e.g. noreply@anthropic.com) -> strong evidence
8
+ # any other address at the domain (jane@anthropic.com) -> only
9
+ # corroborates a name alias of the same agent; never a match on its own.
10
+ # local_parts: any
11
+ # every address at the domain is strong evidence. Only for domains used
12
+ # exclusively for tool attribution.
13
+ #
14
+ # Google, Amazon, Microsoft/GitHub domains are intentionally absent: their
15
+ # automation addresses are used by many non-AI services.
16
+
17
+ schema_version: 1
18
+
19
+ automation_local_parts:
20
+ - noreply
21
+ - no-reply
22
+ - donotreply
23
+ - bot
24
+ - agent
25
+ - ai
26
+ - assistant
27
+
28
+ domains:
29
+ - domain: anthropic.com
30
+ agent: claude
31
+ - domain: openai.com
32
+ agent: openai_codex
33
+ - domain: cursor.com
34
+ agent: cursor
35
+ - domain: windsurf.com
36
+ agent: windsurf
37
+ - domain: codeium.com
38
+ agent: codeium
39
+ - domain: cline.bot
40
+ agent: cline
41
+ - domain: roocode.com
42
+ agent: roo_code
43
+ - domain: cognition.ai
44
+ agent: devin
45
+ - domain: devin.ai
46
+ agent: devin
47
+ - domain: all-hands.dev
48
+ agent: openhands
49
+ - domain: aider.chat
50
+ agent: aider
51
+ local_parts: any
@@ -0,0 +1,131 @@
1
+ # AI agent identities.
2
+ #
3
+ # Loaded by commitguard.rules.loader and validated against
4
+ # commitguard.rules.models.AIIdentityRules. Matching semantics:
5
+ #
6
+ # names full name equals an alias (after case/width/whitespace
7
+ # normalisation). Alone: medium confidence.
8
+ # ambiguous_names aliases that are also common human first names. Only
9
+ # count together with this agent's email/domain evidence.
10
+ # name_prefixes distinctive leading words ("Claude Opus" matches
11
+ # "Claude Opus 4.5 (1M context)"). Alone: high confidence.
12
+ # emails exact address. Alone: high confidence.
13
+ # github_logins login in a <id>+<login>@users.noreply.github.com address,
14
+ # or a "[bot]" account name. Alone: high confidence.
15
+ #
16
+ # There is NO substring matching: "Claude" never matches "Claudette" or
17
+ # "Claude Dupont". Domains live in ai-domains.yaml.
18
+ #
19
+ # `verified: true` requires a public reference commit or vendor documentation
20
+ # recorded in `reference`. Unverified entries are best-known identities that
21
+ # still need confirmation - please contribute references.
22
+
23
+ schema_version: 1
24
+
25
+ agents:
26
+ - id: claude
27
+ display_name: Claude
28
+ vendor: Anthropic
29
+ names: ["Claude", "Claude Code", "Anthropic Claude", "claude-code"]
30
+ name_prefixes: ["Claude Opus", "Claude Sonnet", "Claude Haiku", "Claude Fable"]
31
+ emails: ["noreply@anthropic.com"]
32
+ verified: true
33
+ reference: "Claude Code default trailer: Co-Authored-By: Claude <noreply@anthropic.com>"
34
+
35
+ - id: chatgpt
36
+ display_name: ChatGPT
37
+ vendor: OpenAI
38
+ names: ["ChatGPT", "OpenAI ChatGPT"]
39
+ verified: false
40
+
41
+ - id: openai_codex
42
+ display_name: OpenAI Codex
43
+ vendor: OpenAI
44
+ names: ["OpenAI Codex", "Codex", "codex-cli", "Codex CLI"]
45
+ github_logins: ["chatgpt-codex-connector[bot]"]
46
+ verified: false
47
+
48
+ - id: github_copilot
49
+ display_name: GitHub Copilot
50
+ vendor: GitHub
51
+ names: ["GitHub Copilot", "Copilot", "Copilot Agent", "copilot-swe-agent"]
52
+ github_logins: ["Copilot", "copilot-swe-agent[bot]"]
53
+ verified: false
54
+
55
+ - id: cursor
56
+ display_name: Cursor
57
+ vendor: Anysphere
58
+ names: ["Cursor", "Cursor Agent", "cursoragent"]
59
+ emails: ["cursoragent@cursor.com"]
60
+ github_logins: ["cursor[bot]"]
61
+ verified: false
62
+
63
+ - id: gemini
64
+ display_name: Gemini
65
+ vendor: Google
66
+ names: ["Gemini", "Google Gemini", "Gemini Code Assist", "Gemini CLI"]
67
+ github_logins: ["gemini-code-assist[bot]"]
68
+ verified: false
69
+
70
+ - id: jules
71
+ display_name: Jules
72
+ vendor: Google
73
+ names: ["Google Jules"]
74
+ ambiguous_names: ["Jules"]
75
+ github_logins: ["google-labs-jules[bot]"]
76
+ verified: false
77
+
78
+ - id: windsurf
79
+ display_name: Windsurf
80
+ vendor: Windsurf
81
+ names: ["Windsurf", "Windsurf Cascade", "Cascade"]
82
+ verified: false
83
+
84
+ - id: codeium
85
+ display_name: Codeium
86
+ vendor: Codeium
87
+ names: ["Codeium"]
88
+ verified: false
89
+
90
+ - id: cline
91
+ display_name: Cline
92
+ vendor: Cline
93
+ names: ["Cline"]
94
+ github_logins: ["cline[bot]"]
95
+ verified: false
96
+
97
+ - id: roo_code
98
+ display_name: Roo Code
99
+ vendor: Roo Code
100
+ names: ["Roo Code", "RooCode", "Roo Cline"]
101
+ verified: false
102
+
103
+ - id: amazon_q
104
+ display_name: Amazon Q Developer
105
+ vendor: Amazon
106
+ names: ["Amazon Q", "Amazon Q Developer", "Amazon CodeWhisperer", "CodeWhisperer"]
107
+ github_logins: ["amazon-q-developer[bot]"]
108
+ verified: false
109
+
110
+ - id: devin
111
+ display_name: Devin
112
+ vendor: Cognition
113
+ names: ["Devin AI"]
114
+ ambiguous_names: ["Devin"]
115
+ github_logins: ["devin-ai-integration[bot]"]
116
+ verified: false
117
+
118
+ - id: aider
119
+ display_name: aider
120
+ vendor: Aider
121
+ name_prefixes: ["aider"]
122
+ verified: false
123
+ reference: "aider attributes as 'aider (<model>)'"
124
+
125
+ - id: openhands
126
+ display_name: OpenHands
127
+ vendor: All Hands AI
128
+ names: ["OpenHands", "OpenHands Agent"]
129
+ emails: ["openhands@all-hands.dev"]
130
+ github_logins: ["openhands-agent"]
131
+ verified: false