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,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
|