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,72 @@
1
+ """Detector registry.
2
+
3
+ The registry is an explicit, in-process list. There is intentionally no
4
+ dynamic plugin discovery (entry points, import-by-name from configuration):
5
+ configuration must never cause arbitrary code to be imported or executed.
6
+ """
7
+
8
+ from commitguard.detectors.base import Detector
9
+ from commitguard.exceptions.base import UnsafeInputError
10
+ from commitguard.exceptions.detection import DetectorRegistrationError
11
+ from commitguard.rules.matcher import CompiledRules
12
+ from commitguard.security.validation import validate_identifier
13
+
14
+
15
+ class DetectorRegistry:
16
+ """An ordered collection of uniquely named detectors."""
17
+
18
+ def __init__(self) -> None:
19
+ self._detectors: dict[str, Detector] = {}
20
+
21
+ def register(self, detector: Detector) -> None:
22
+ if not isinstance(detector, Detector):
23
+ raise DetectorRegistrationError(f"{detector!r} is not a Detector")
24
+ try:
25
+ validate_identifier(detector.name, kind="detector name")
26
+ for rule in detector.rules:
27
+ validate_identifier(rule, kind="rule id")
28
+ except (AttributeError, UnsafeInputError) as exc:
29
+ raise DetectorRegistrationError(str(exc)) from exc
30
+ if not detector.rules:
31
+ raise DetectorRegistrationError(f"detector {detector.name!r} declares no rules")
32
+ if detector.name in self._detectors:
33
+ raise DetectorRegistrationError(f"detector {detector.name!r} is already registered")
34
+ self._detectors[detector.name] = detector
35
+
36
+ def get(self, name: str) -> Detector:
37
+ try:
38
+ return self._detectors[name]
39
+ except KeyError:
40
+ raise DetectorRegistrationError(f"no detector named {name!r}") from None
41
+
42
+ def all(self) -> tuple[Detector, ...]:
43
+ """All detectors, sorted by name for deterministic execution."""
44
+ return tuple(self._detectors[name] for name in sorted(self._detectors))
45
+
46
+ def rules(self) -> frozenset[str]:
47
+ """Every rule ID declared by a registered detector."""
48
+ return frozenset(rule for detector in self._detectors.values() for rule in detector.rules)
49
+
50
+ def __len__(self) -> int:
51
+ return len(self._detectors)
52
+
53
+ def __contains__(self, name: object) -> bool:
54
+ return name in self._detectors
55
+
56
+
57
+ def builtin_registry(rules: CompiledRules) -> DetectorRegistry:
58
+ """Return a registry of CommitGuard's built-in detectors using ``rules``."""
59
+ from commitguard.detectors.bot import BotDetector
60
+ from commitguard.detectors.coauthor import CoauthorDetector
61
+ from commitguard.detectors.identity import IdentityDetector
62
+ from commitguard.detectors.trailer import TrailerDetector
63
+
64
+ registry = DetectorRegistry()
65
+ for detector in (
66
+ CoauthorDetector(rules),
67
+ IdentityDetector(rules),
68
+ TrailerDetector(rules),
69
+ BotDetector(rules),
70
+ ):
71
+ registry.register(detector)
72
+ return registry
@@ -0,0 +1,211 @@
1
+ """Trailer detector: configured attribution trailers, malformed trailers and
2
+ exact tool attribution footers.
3
+
4
+ Nothing here is flagged merely for existing. ``Reviewed-by`` or
5
+ ``Signed-off-by`` only produce ``ai_trailer`` when a configured rule says so
6
+ (e.g. the value is an AI identity); ``malformed_trailer`` only applies to keys
7
+ listed in ``malformed_trailer_checks``. ``Co-authored-by`` belongs to the
8
+ co-author detector and is not reported here as AI attribution.
9
+
10
+ Message markers are exact whole-line matches of tool-inserted footers such as
11
+ ``Generated with [Claude Code](https://claude.com/claude-code)``, which tools
12
+ usually prefix with a pictographic symbol (U+1F916 ROBOT FACE). There is no
13
+ free-text inference: "use AI service for recommendations" is not evidence.
14
+ """
15
+
16
+ import unicodedata
17
+ from collections.abc import Sequence
18
+ from typing import ClassVar
19
+
20
+ from commitguard.core.context import CommitContext
21
+ from commitguard.core.result import (
22
+ Confidence,
23
+ Evidence,
24
+ EvidenceSource,
25
+ Finding,
26
+ MatchKind,
27
+ MatchReason,
28
+ Severity,
29
+ )
30
+ from commitguard.detectors.base import Detector, require_complete_trailers
31
+ from commitguard.provenance.normalization import normalize_text
32
+ from commitguard.provenance.trailers import Trailer, TrailerIssue
33
+ from commitguard.rules.matcher import CompiledRules
34
+ from commitguard.rules.models import PATTERNS_FILE, MalformedTrailerCheck, TrailerRule
35
+
36
+ RULE_AI_TRAILER = "ai_trailer"
37
+ RULE_MALFORMED_TRAILER = "malformed_trailer"
38
+
39
+ MAX_MARKER_LINE_CHARS = 300
40
+
41
+
42
+ def _marker_key(line: str) -> str:
43
+ """Normalised line with any leading pictographic symbols removed.
44
+
45
+ Tool footers are commonly prefixed with one (U+1F916 ROBOT FACE before
46
+ ``Generated with ...``); the marker must match with or without it.
47
+ """
48
+ text = normalize_text(line)
49
+ index = 0
50
+ while index < len(text) and (
51
+ text[index].isspace() or unicodedata.category(text[index]) in ("So", "Sk", "Sm", "Mn")
52
+ ):
53
+ index += 1
54
+ return text[index:]
55
+
56
+
57
+ class TrailerDetector(Detector):
58
+ name: ClassVar[str] = "trailer"
59
+ rules: ClassVar[frozenset[str]] = frozenset({RULE_AI_TRAILER, RULE_MALFORMED_TRAILER})
60
+ description: ClassVar[str] = (
61
+ "Configured AI attribution trailers/footers and malformed trailers."
62
+ )
63
+
64
+ def __init__(self, rules: CompiledRules) -> None:
65
+ patterns = rules.rules.patterns
66
+ self._rules = rules
67
+ self._attribution: dict[str, TrailerRule] = {
68
+ key: rule for rule in patterns.attribution_trailers for key in rule.keys
69
+ }
70
+ self._malformed: dict[str, MalformedTrailerCheck] = {}
71
+ for check in patterns.malformed_trailer_checks:
72
+ for key in check.keys:
73
+ self._malformed.setdefault(key, check)
74
+ self._markers: dict[str, tuple[str, str, str]] = {
75
+ _marker_key(line): (marker.id, marker.agent, line)
76
+ for marker in patterns.message_markers
77
+ for line in marker.lines
78
+ }
79
+
80
+ def detect(self, context: CommitContext) -> Sequence[Finding]:
81
+ commit = context.commit
82
+ require_complete_trailers(commit)
83
+ findings: list[Finding] = []
84
+ for trailer in commit.trailers:
85
+ key = trailer.normalized_key
86
+ if (rule := self._attribution.get(key)) is not None:
87
+ finding = self._attribution_finding(rule, trailer, commit.sha)
88
+ if finding is not None:
89
+ findings.append(finding)
90
+ if (check := self._malformed.get(key)) is not None:
91
+ finding = self._malformed_finding(check, trailer, commit.sha)
92
+ if finding is not None:
93
+ findings.append(finding)
94
+ findings.extend(self._marker_findings(commit.message, commit.sha))
95
+ return findings
96
+
97
+ # ------------------------------------------------------------------ #
98
+ def _attribution_finding(
99
+ self, rule: TrailerRule, trailer: Trailer, sha: str | None
100
+ ) -> Finding | None:
101
+ key_reason = MatchReason(
102
+ kind=MatchKind.TRAILER_KEY,
103
+ value=trailer.normalized_key,
104
+ rule=f"{PATTERNS_FILE}#{rule.id}",
105
+ )
106
+ reasons: tuple[MatchReason, ...]
107
+ if rule.match == "key_present":
108
+ if normalize_text(trailer.value) in rule.ignore_values:
109
+ return None
110
+ reasons, confidence, subject = (key_reason,), Confidence.HIGH, "AI assistance"
111
+ else:
112
+ match = self._rules.ai_matcher.match(trailer.name, trailer.email)
113
+ if match is None:
114
+ return None
115
+ reasons = (key_reason, *match.reasons)
116
+ confidence, subject = match.confidence, f"the AI agent {match.display_name}"
117
+ return Finding(
118
+ detector=self.name,
119
+ rule_id=RULE_AI_TRAILER,
120
+ severity=Severity.HIGH,
121
+ confidence=confidence,
122
+ title="AI attribution trailer detected",
123
+ message=f"The {trailer.key!r} trailer attributes the commit to {subject}.",
124
+ evidence=(
125
+ Evidence(
126
+ source=EvidenceSource.TRAILER,
127
+ value=trailer.raw,
128
+ line_number=trailer.line_number,
129
+ matched=reasons,
130
+ ),
131
+ ),
132
+ commit_sha=sha,
133
+ remediation="Remove the AI attribution trailer from the commit message before pushing.",
134
+ )
135
+
136
+ def _malformed_finding(
137
+ self, check: MalformedTrailerCheck, trailer: Trailer, sha: str | None
138
+ ) -> Finding | None:
139
+ # Characters before a key (a quoted "> Signed-off-by:" line) are recorded so that
140
+ # attribution cannot hide behind them, but are not by themselves malformed.
141
+ notes = [
142
+ f"trailer: {issue.value}"
143
+ for issue in trailer.issues
144
+ if issue is not TrailerIssue.LEADING_CHARACTERS
145
+ ]
146
+ if check.require_identity:
147
+ notes.extend(f"identity: {issue.value}" for issue in trailer.identity.issues)
148
+ if not notes:
149
+ return None
150
+ return Finding(
151
+ detector=self.name,
152
+ rule_id=RULE_MALFORMED_TRAILER,
153
+ severity=Severity.LOW,
154
+ confidence=Confidence.HIGH,
155
+ title="Malformed trailer",
156
+ message=(
157
+ f"The {trailer.key!r} trailer is malformed. Tools disagree on how to read "
158
+ "malformed trailers, which can hide or misstate attribution."
159
+ ),
160
+ evidence=(
161
+ Evidence(
162
+ source=EvidenceSource.TRAILER,
163
+ value=trailer.raw,
164
+ line_number=trailer.line_number,
165
+ notes=tuple(notes),
166
+ ),
167
+ ),
168
+ commit_sha=sha,
169
+ remediation="Rewrite the trailer as 'Key: Name <email>' or remove it.",
170
+ )
171
+
172
+ def _marker_findings(self, message: str, sha: str | None) -> list[Finding]:
173
+ findings = []
174
+ for line_number, line in enumerate(message.splitlines(), start=1):
175
+ if len(line) > MAX_MARKER_LINE_CHARS:
176
+ continue
177
+ hit = self._markers.get(_marker_key(line))
178
+ if hit is None:
179
+ continue
180
+ marker_id, agent_id, marker_line = hit
181
+ agent = self._rules.agents[agent_id]
182
+ findings.append(
183
+ Finding(
184
+ detector=self.name,
185
+ rule_id=RULE_AI_TRAILER,
186
+ severity=Severity.HIGH,
187
+ confidence=Confidence.HIGH,
188
+ title="AI attribution footer detected",
189
+ message=(
190
+ f"The commit message contains an attribution line inserted by "
191
+ f"{agent.display_name}."
192
+ ),
193
+ evidence=(
194
+ Evidence(
195
+ source=EvidenceSource.MESSAGE,
196
+ value=line.strip(),
197
+ line_number=line_number,
198
+ matched=(
199
+ MatchReason(
200
+ kind=MatchKind.MESSAGE_MARKER,
201
+ value=marker_line,
202
+ rule=f"{PATTERNS_FILE}#{marker_id}",
203
+ ),
204
+ ),
205
+ ),
206
+ ),
207
+ commit_sha=sha,
208
+ remediation="Remove the tool attribution line from the commit message.",
209
+ )
210
+ )
211
+ return findings
@@ -0,0 +1,33 @@
1
+ """CommitGuard exception hierarchy.
2
+
3
+ Every error raised deliberately by CommitGuard derives from
4
+ :class:`CommitGuardError`, so callers (in particular the CLI and Git hooks) can
5
+ distinguish expected failures from programming errors and fail closed.
6
+ """
7
+
8
+ from commitguard.exceptions.base import CommitGuardError, UnsafeInputError
9
+ from commitguard.exceptions.configuration import ConfigurationError, RulesError
10
+ from commitguard.exceptions.detection import DetectionError, DetectorRegistrationError
11
+ from commitguard.exceptions.git import (
12
+ GitCommandError,
13
+ GitError,
14
+ GitNotFoundError,
15
+ HookInstallError,
16
+ MalformedGitOutputError,
17
+ NotAGitRepositoryError,
18
+ )
19
+
20
+ __all__ = [
21
+ "CommitGuardError",
22
+ "ConfigurationError",
23
+ "DetectionError",
24
+ "DetectorRegistrationError",
25
+ "GitCommandError",
26
+ "GitError",
27
+ "GitNotFoundError",
28
+ "HookInstallError",
29
+ "MalformedGitOutputError",
30
+ "NotAGitRepositoryError",
31
+ "RulesError",
32
+ "UnsafeInputError",
33
+ ]
@@ -0,0 +1,9 @@
1
+ """Root exception types shared by every CommitGuard layer."""
2
+
3
+
4
+ class CommitGuardError(Exception):
5
+ """Base class for all CommitGuard errors."""
6
+
7
+
8
+ class UnsafeInputError(CommitGuardError, ValueError):
9
+ """Raised when untrusted input fails a security validation check."""
@@ -0,0 +1,22 @@
1
+ """Configuration errors.
2
+
3
+ Invalid configuration is always an error, never a warning: a typo in a policy
4
+ file must not silently weaken enforcement.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from commitguard.exceptions.base import CommitGuardError
10
+
11
+
12
+ class ConfigurationError(CommitGuardError):
13
+ """Raised when a configuration file is missing, unreadable, or invalid."""
14
+
15
+ def __init__(self, message: str, *, path: Path | None = None) -> None:
16
+ self.path = path
17
+ prefix = f"{path}: " if path is not None else ""
18
+ super().__init__(f"{prefix}{message}")
19
+
20
+
21
+ class RulesError(ConfigurationError):
22
+ """Raised when detection rule files are missing or invalid."""
@@ -0,0 +1,11 @@
1
+ """Detection errors."""
2
+
3
+ from commitguard.exceptions.base import CommitGuardError
4
+
5
+
6
+ class DetectionError(CommitGuardError):
7
+ """Raised when a detector misbehaves (e.g. emits an undeclared rule)."""
8
+
9
+
10
+ class DetectorRegistrationError(DetectionError):
11
+ """Raised when a detector cannot be registered (duplicate or invalid name)."""
@@ -0,0 +1,41 @@
1
+ """Git integration errors."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from commitguard.exceptions.base import CommitGuardError
6
+
7
+
8
+ class GitError(CommitGuardError):
9
+ """Base class for Git-related failures."""
10
+
11
+
12
+ class GitNotFoundError(GitError):
13
+ """Raised when the ``git`` executable cannot be located."""
14
+
15
+
16
+ class NotAGitRepositoryError(GitError):
17
+ """Raised when an operation requires a Git repository and none was found."""
18
+
19
+
20
+ class MalformedGitOutputError(GitError):
21
+ """Raised when Git output does not match the expected, validated shape.
22
+
23
+ Commit metadata is untrusted; a crafted object can produce output that a
24
+ naive parser would mis-assign. We refuse to guess.
25
+ """
26
+
27
+
28
+ class GitCommandError(GitError):
29
+ """Raised when a Git command exits with a non-zero status."""
30
+
31
+ def __init__(self, args: Sequence[str], returncode: int, stderr: str) -> None:
32
+ self.command = tuple(args)
33
+ self.returncode = returncode
34
+ self.stderr = stderr
35
+ # Only the git sub-command is echoed; arguments may contain user data.
36
+ subcommand = next((a for a in self.command[1:] if not a.startswith("-")), "?")
37
+ super().__init__(f"git {subcommand} failed with exit code {returncode}: {stderr}")
38
+
39
+
40
+ class HookInstallError(GitError):
41
+ """Raised when hooks cannot be installed or removed safely."""
@@ -0,0 +1,25 @@
1
+ """Errors raised by long-running services (scan workers, integrations).
2
+
3
+ The split matters for enforcement: a *policy violation* is a successful scan
4
+ with a BLOCK result, while these errors mean the scan could not be completed.
5
+ Both fail the check, but they are recorded and reported differently.
6
+ """
7
+
8
+ from commitguard.exceptions.base import CommitGuardError
9
+ from commitguard.exceptions.configuration import ConfigurationError
10
+
11
+
12
+ class PolicyError(ConfigurationError):
13
+ """A policy (e.g. a mandatory organisation policy) is invalid or cannot be applied."""
14
+
15
+
16
+ class ScanError(CommitGuardError):
17
+ """A scan could not be completed (missing commits, too many commits, stale scan...)."""
18
+
19
+
20
+ class StaleScanError(ScanError):
21
+ """A newer scan owns the result; this scan must not publish anything."""
22
+
23
+
24
+ class InfrastructureError(CommitGuardError):
25
+ """A dependency the service needs (Git, storage, network) failed."""
@@ -0,0 +1,12 @@
1
+ """Git integration layer.
2
+
3
+ * :mod:`commitguard.git.commit` - the normalised :class:`Commit` data model
4
+ (pure data, no I/O; safe for detectors to import).
5
+ * :mod:`commitguard.git.commands` - the single choke point that executes Git.
6
+ * :mod:`commitguard.git.repository` - repository discovery and read-only queries.
7
+ * :mod:`commitguard.git.diff` - staged change inspection (planned).
8
+ * :mod:`commitguard.git.hooks` - hook installation and removal (planned).
9
+
10
+ The detection engine and detectors must never import the I/O modules; this is
11
+ enforced by ``tests/unit/test_architecture.py``.
12
+ """
@@ -0,0 +1,101 @@
1
+ """The single choke point through which CommitGuard executes Git.
2
+
3
+ Hardening applied to every invocation:
4
+
5
+ * ``git`` is resolved once via ``PATH`` and invoked with an argument vector;
6
+ * ``--no-pager`` and ``GIT_TERMINAL_PROMPT=0``: never block on a pager/prompt;
7
+ * ``GIT_NO_REPLACE_OBJECTS=1``: ``git replace`` refs cannot substitute the
8
+ commit we inspect with a different, innocent-looking one;
9
+ * ``GIT_OPTIONAL_LOCKS=0``: read-only queries do not refresh the index;
10
+ * ``LC_ALL=C``: stable, parseable error messages.
11
+
12
+ Callers that pass revisions must place them after ``--end-of-options``.
13
+ """
14
+
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ from collections.abc import Mapping, Sequence
19
+ from functools import cache
20
+ from pathlib import Path
21
+
22
+ from commitguard.exceptions.git import GitCommandError, GitError, GitNotFoundError
23
+ from commitguard.security.sanitization import sanitize_for_terminal
24
+ from commitguard.utils.subprocess import DEFAULT_TIMEOUT_SECONDS, CommandResult, run_command
25
+
26
+ # ``rev-parse --end-of-options`` and ``--path-format=absolute`` need Git 2.31.
27
+ MINIMUM_GIT_VERSION = (2, 31)
28
+
29
+ GIT_ENV_OVERRIDES: dict[str, str] = {
30
+ "GIT_TERMINAL_PROMPT": "0",
31
+ "GIT_NO_REPLACE_OBJECTS": "1",
32
+ "GIT_OPTIONAL_LOCKS": "0",
33
+ "LC_ALL": "C",
34
+ }
35
+
36
+ _VERSION_RE = re.compile(rb"git version (\d+)\.(\d+)(?:\.(\d+))?")
37
+
38
+
39
+ @cache
40
+ def git_executable() -> str:
41
+ """Return the absolute path of the ``git`` executable."""
42
+ path = shutil.which("git")
43
+ if path is None:
44
+ raise GitNotFoundError("git executable not found on PATH")
45
+ return path
46
+
47
+
48
+ def run_git(
49
+ args: Sequence[str],
50
+ *,
51
+ cwd: Path | None = None,
52
+ check: bool = True,
53
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
54
+ input_bytes: bytes | None = None,
55
+ extra_env: Mapping[str, str] | None = None,
56
+ ) -> CommandResult:
57
+ """Run ``git <args>`` with CommitGuard's hardening applied.
58
+
59
+ With ``check=True`` a non-zero exit raises :class:`GitCommandError` whose
60
+ message contains sanitised stderr only. ``extra_env`` may add variables but
61
+ never replace the hardening in :data:`GIT_ENV_OVERRIDES`.
62
+ """
63
+ argv = [git_executable(), "--no-pager", *args]
64
+ env = dict(GIT_ENV_OVERRIDES)
65
+ if extra_env:
66
+ clash = sorted(set(extra_env) & set(GIT_ENV_OVERRIDES))
67
+ if clash:
68
+ raise ValueError(f"extra_env must not override git hardening: {', '.join(clash)}")
69
+ env.update(extra_env)
70
+ try:
71
+ result = run_command(
72
+ argv,
73
+ cwd=cwd,
74
+ env_overrides=env,
75
+ timeout=timeout,
76
+ input_bytes=input_bytes,
77
+ )
78
+ except FileNotFoundError as exc:
79
+ raise GitNotFoundError("git executable could not be started") from exc
80
+ except subprocess.TimeoutExpired as exc:
81
+ raise GitError(f"git command timed out after {timeout:g}s") from exc
82
+
83
+ if check and not result.ok:
84
+ stderr = sanitize_for_terminal(result.stderr.decode("utf-8", errors="replace").strip())
85
+ raise GitCommandError(argv, result.returncode, stderr)
86
+ return result
87
+
88
+
89
+ def git_version() -> tuple[int, int, int]:
90
+ """Return the installed Git version as ``(major, minor, patch)``."""
91
+ result = run_git(["--version"])
92
+ match = _VERSION_RE.search(result.stdout)
93
+ if match is None:
94
+ raise GitError("could not determine git version")
95
+ major, minor, patch = match.groups()
96
+ return int(major), int(minor), int(patch or 0)
97
+
98
+
99
+ def git_version_supported() -> bool:
100
+ """Return True if the installed Git meets :data:`MINIMUM_GIT_VERSION`."""
101
+ return git_version()[:2] >= MINIMUM_GIT_VERSION
@@ -0,0 +1,97 @@
1
+ """Normalised commit model.
2
+
3
+ This module is pure data: it has no Git or filesystem access, so detectors can
4
+ depend on it without depending on Git itself.
5
+
6
+ A :class:`Commit` may describe a commit that does not exist yet. During a
7
+ ``commit-msg`` check there is a message and an identity but no object ID, so
8
+ ``sha`` and ``parents`` are optional.
9
+
10
+ Trailers are always *derived* from ``message`` by
11
+ :func:`~commitguard.provenance.trailers.parse_trailers`; they cannot be passed
12
+ in separately, so they can never disagree with the message they came from.
13
+ """
14
+
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
19
+
20
+ from commitguard.provenance.author import Identity
21
+ from commitguard.provenance.signatures import SignatureInfo
22
+ from commitguard.provenance.trailers import Trailer, parse_trailers
23
+ from commitguard.security.validation import validate_git_sha
24
+
25
+ SHORT_SHA_LENGTH = 7
26
+
27
+
28
+ class Commit(BaseModel):
29
+ """A normalised, immutable view of a Git commit.
30
+
31
+ All string fields hold *untrusted* data exactly as recorded in the commit.
32
+ Sanitise before displaying (see :mod:`commitguard.security.sanitization`).
33
+ """
34
+
35
+ model_config = ConfigDict(frozen=True, extra="forbid")
36
+
37
+ sha: str | None = Field(default=None, description="Object ID; None for a pending commit")
38
+ parents: tuple[str, ...] = ()
39
+ author: Identity
40
+ committer: Identity
41
+ authored_at: datetime | None = None
42
+ committed_at: datetime | None = None
43
+ message: str
44
+ trailers: tuple[Trailer, ...] = Field(default=(), description="Derived from message")
45
+ trailers_truncated: bool = Field(
46
+ default=False, description="True if the message had more trailers than can be analysed"
47
+ )
48
+ signature: SignatureInfo | None = Field(
49
+ default=None,
50
+ description="Not collected yet (security intelligence phase); None means unknown",
51
+ )
52
+
53
+ @model_validator(mode="before")
54
+ @classmethod
55
+ def _derive_trailers(cls, data: Any) -> Any:
56
+ if isinstance(data, dict) and isinstance(data.get("message"), str):
57
+ parsed = parse_trailers(data["message"])
58
+ data = {**data, "trailers": parsed.trailers, "trailers_truncated": parsed.truncated}
59
+ return data
60
+
61
+ @field_validator("sha")
62
+ @classmethod
63
+ def _validate_sha(cls, value: str | None) -> str | None:
64
+ return None if value is None else validate_git_sha(value)
65
+
66
+ @field_validator("parents")
67
+ @classmethod
68
+ def _validate_parents(cls, value: tuple[str, ...]) -> tuple[str, ...]:
69
+ for parent in value:
70
+ validate_git_sha(parent)
71
+ return value
72
+
73
+ @property
74
+ def is_pending(self) -> bool:
75
+ """True if this commit has not been written to the object database."""
76
+ return self.sha is None
77
+
78
+ @property
79
+ def is_merge(self) -> bool:
80
+ return len(self.parents) > 1
81
+
82
+ @property
83
+ def short_sha(self) -> str:
84
+ return self.sha[:SHORT_SHA_LENGTH] if self.sha else "pending"
85
+
86
+ @property
87
+ def timestamp(self) -> datetime | None:
88
+ """Commit time (committer date), falling back to the author date."""
89
+ return self.committed_at or self.authored_at
90
+
91
+ @property
92
+ def subject(self) -> str:
93
+ """First line of the message (untrusted; sanitise before display)."""
94
+ return self.message.split("\n", 1)[0]
95
+
96
+ def trailers_with_key(self, normalized_key: str) -> tuple[Trailer, ...]:
97
+ return tuple(t for t in self.trailers if t.normalized_key == normalized_key)
@@ -0,0 +1,36 @@
1
+ """Staged and committed change inspection.
2
+
3
+ TODO(phase-3): implement read-only diff inspection for the ``pre-commit`` and
4
+ ``pre-push`` hooks, e.g. ``git diff --cached --name-status -z`` for staged
5
+ paths and ``git diff-tree`` for pushed commits. Requirements:
6
+
7
+ * NUL-delimited output only (paths may contain newlines or escape codes);
8
+ * binary-safe, size-bounded reads of blob content (for future secret detection);
9
+ * no content ever leaves the machine unless a policy explicitly opts in.
10
+ """
11
+
12
+ from enum import StrEnum
13
+
14
+ from pydantic import BaseModel, ConfigDict
15
+
16
+
17
+ class ChangeType(StrEnum):
18
+ """Git name-status change codes."""
19
+
20
+ ADDED = "A"
21
+ COPIED = "C"
22
+ DELETED = "D"
23
+ MODIFIED = "M"
24
+ RENAMED = "R"
25
+ TYPE_CHANGED = "T"
26
+ UNMERGED = "U"
27
+
28
+
29
+ class FileChange(BaseModel):
30
+ """A single changed path. Not populated yet (Phase 3)."""
31
+
32
+ model_config = ConfigDict(frozen=True, extra="forbid")
33
+
34
+ path: str
35
+ change: ChangeType
36
+ old_path: str | None = None