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,48 @@
1
+ """Built-in policy defaults.
2
+
3
+ These are the secure defaults applied when a repository has no configuration,
4
+ and the base that repository configuration overrides. Every rule a built-in
5
+ detector can emit must appear here (enforced by tests); configuration may only
6
+ reference IDs listed here.
7
+ """
8
+
9
+ from types import MappingProxyType
10
+
11
+ from commitguard.core.decision import Action
12
+ from commitguard.policies.model import Policy, PolicySet
13
+
14
+ _DEFAULTS: tuple[Policy, ...] = (
15
+ Policy(
16
+ id="ai_coauthor",
17
+ action=Action.BLOCK,
18
+ description="AI agent listed as a co-author (e.g. Co-authored-by trailer).",
19
+ ),
20
+ Policy(
21
+ id="ai_identity",
22
+ action=Action.BLOCK,
23
+ description="AI agent recorded as the commit author or committer.",
24
+ ),
25
+ Policy(
26
+ id="ai_trailer",
27
+ action=Action.BLOCK,
28
+ description="AI attribution in other trailers (e.g. Generated-by, Assisted-by).",
29
+ ),
30
+ Policy(
31
+ id="malformed_trailer",
32
+ action=Action.WARN,
33
+ description="Trailer-like line that does not parse (possible evasion attempt).",
34
+ ),
35
+ Policy(
36
+ id="bot_identity",
37
+ action=Action.WARN,
38
+ description="Automation or bot account as author, committer or co-author.",
39
+ ),
40
+ )
41
+
42
+ DEFAULT_POLICIES = MappingProxyType({policy.id: policy for policy in _DEFAULTS})
43
+ KNOWN_POLICY_IDS = frozenset(DEFAULT_POLICIES)
44
+
45
+
46
+ def default_policy_set() -> PolicySet:
47
+ """Return the built-in policy set."""
48
+ return PolicySet(DEFAULT_POLICIES)
@@ -0,0 +1,66 @@
1
+ """Policy evaluation: :class:`DetectionResult` + :class:`PolicySet` -> :class:`Decision`.
2
+
3
+ Evaluation rules (all fail closed):
4
+
5
+ * finding for a rule with an enabled policy -> that policy's action;
6
+ * finding for a rule whose policy is disabled -> ALLOW (recorded, not hidden);
7
+ * finding for a rule with **no** policy -> BLOCK;
8
+ * detector failure -> BLOCK (an incomplete scan
9
+ cannot prove the commit is clean);
10
+ * the overall decision is the most restrictive individual action.
11
+ """
12
+
13
+ from commitguard.core.decision import Action, Decision, Explanation
14
+ from commitguard.core.result import DetectionResult
15
+ from commitguard.policies.model import PolicySet
16
+
17
+
18
+ class PolicyEvaluator:
19
+ """Apply a :class:`PolicySet` to scan results."""
20
+
21
+ def __init__(self, policies: PolicySet) -> None:
22
+ self._policies = policies
23
+
24
+ def evaluate(self, result: DetectionResult) -> Decision:
25
+ explanations: list[Explanation] = []
26
+
27
+ for finding in result.findings:
28
+ policy = self._policies.get(finding.rule_id)
29
+ if policy is None:
30
+ explanations.append(
31
+ Explanation(
32
+ action=Action.BLOCK,
33
+ reason=f"no policy configured for rule {finding.rule_id!r}; failing closed",
34
+ finding=finding,
35
+ )
36
+ )
37
+ elif not policy.enabled:
38
+ explanations.append(
39
+ Explanation(
40
+ action=Action.ALLOW,
41
+ reason=f"policy {policy.id!r} is disabled",
42
+ policy_id=policy.id,
43
+ finding=finding,
44
+ )
45
+ )
46
+ else:
47
+ explanations.append(
48
+ Explanation(
49
+ action=policy.action,
50
+ reason=f"policy {policy.id!r} action is {policy.action.value}",
51
+ policy_id=policy.id,
52
+ finding=finding,
53
+ )
54
+ )
55
+
56
+ for failure in result.failures:
57
+ explanations.append(
58
+ Explanation(
59
+ action=Action.BLOCK,
60
+ reason=f"detector {failure.detector!r} did not complete; failing closed",
61
+ failure=failure,
62
+ )
63
+ )
64
+
65
+ action = Action.most_restrictive([e.action for e in explanations])
66
+ return Decision(action=action, explanations=tuple(explanations))
@@ -0,0 +1,498 @@
1
+ """Governed policy resolution: which policy applies to a repository, and why.
2
+
3
+ The organization layer decides *what configuration applies*; the policy engine
4
+ (:mod:`commitguard.policies.evaluator`) still decides *what a finding means*.
5
+ This module is the pure part of that split: given the governance inputs for
6
+ one repository (policy layers, approved exceptions, onboarding mode) and,
7
+ at scan time, the repository's own ``.commitguard.yaml``, it builds the
8
+ effective :class:`~commitguard.policies.model.PolicySet` together with the
9
+ provenance of every rule. It performs no I/O and evaluates no findings.
10
+
11
+ Precedence, per rule (later steps win, subject to their own limits)::
12
+
13
+ 1. built-in default rules/policies/defaults.py
14
+ 2. organization policy - default replaced by a narrower default below
15
+ 3. repository group - default several groups: the most restrictive
16
+ 4. repository policy - default (set in the dashboard for one repository)
17
+ 5. repository configuration .commitguard.yaml at the trusted revision
18
+ 6. mandatory requirements (floor) the most restrictive mandatory entry of the
19
+ service, organization, group and repository
20
+ policies; nothing above can weaken it
21
+ 7. approved exception the only way below a floor; scoped, expiring,
22
+ the most specific scope applies
23
+ 8. monitor mode block is reported as warn (report only)
24
+
25
+ Two strengths exist. A **mandatory** entry (``warn`` or ``block``) is a floor:
26
+ lower layers may make the rule stricter but never weaker, and a lower layer
27
+ that asks for less is recorded as a :class:`PolicyConflict` - never silently
28
+ dropped. A **default** entry is a baseline that narrower layers and the
29
+ repository configuration may replace in either direction. A rule no layer
30
+ mentions is decided by the repository configuration (or the built-in default).
31
+
32
+ Exceptions never add or remove detection: a finding under an exception is still
33
+ recorded, with the lowered action and the exception that lowered it.
34
+ """
35
+
36
+ import json
37
+ from collections.abc import Iterable, Mapping, Sequence
38
+ from datetime import datetime
39
+ from enum import StrEnum
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
42
+
43
+ from commitguard.config.schema import CommitGuardConfig, PolicyOverride
44
+ from commitguard.core.decision import Action
45
+ from commitguard.policies.defaults import DEFAULT_POLICIES
46
+ from commitguard.policies.loader import build_policy_set
47
+ from commitguard.policies.mandatory import apply_mandatory_policies
48
+ from commitguard.policies.model import Policy, PolicySet
49
+ from commitguard.security.hashing import fingerprint, sha256_hex
50
+
51
+ _STRICT = ConfigDict(frozen=True, extra="forbid")
52
+
53
+ #: Limits on governance inputs (resource exhaustion protection).
54
+ MAX_LAYERS = 64
55
+ MAX_EXCEPTIONS = 256
56
+ MAX_LABEL_CHARS = 200
57
+
58
+
59
+ class Enforcement(StrEnum):
60
+ """How strongly a policy layer requires an action."""
61
+
62
+ MANDATORY = "mandatory" # a floor: nothing below may weaken it
63
+ DEFAULT = "default" # a baseline: narrower layers may replace it
64
+
65
+
66
+ class PolicyLevel(StrEnum):
67
+ BUILT_IN = "built_in"
68
+ SERVICE = "service"
69
+ ORGANIZATION = "organization"
70
+ GROUP = "group"
71
+ REPOSITORY_POLICY = "repository_policy"
72
+ REPOSITORY_CONFIGURATION = "repository_configuration"
73
+ EXCEPTION = "exception"
74
+ MONITOR_MODE = "monitor_mode"
75
+
76
+
77
+ #: Order used to break ties between floors of equal strength (broadest first).
78
+ _LEVEL_ORDER = {
79
+ PolicyLevel.SERVICE: 0,
80
+ PolicyLevel.ORGANIZATION: 1,
81
+ PolicyLevel.GROUP: 2,
82
+ PolicyLevel.REPOSITORY_POLICY: 3,
83
+ }
84
+ LAYER_LEVELS = frozenset(_LEVEL_ORDER)
85
+
86
+
87
+ class RepositoryMode(StrEnum):
88
+ ENFORCE = "enforce"
89
+ MONITOR = "monitor"
90
+
91
+
92
+ class ExceptionScope(StrEnum):
93
+ ORGANIZATION = "organization"
94
+ GROUP = "group"
95
+ REPOSITORY = "repository"
96
+
97
+
98
+ _SCOPE_SPECIFICITY = {
99
+ ExceptionScope.REPOSITORY: 2,
100
+ ExceptionScope.GROUP: 1,
101
+ ExceptionScope.ORGANIZATION: 0,
102
+ }
103
+
104
+
105
+ class RuleRequirement(BaseModel):
106
+ """One layer's requirement for one rule."""
107
+
108
+ model_config = _STRICT
109
+
110
+ action: Action
111
+ enforcement: Enforcement = Enforcement.MANDATORY
112
+
113
+ @model_validator(mode="after")
114
+ def _floor_actions(self) -> "RuleRequirement":
115
+ if self.enforcement is Enforcement.MANDATORY and self.action is Action.ALLOW:
116
+ raise ValueError("a mandatory requirement must be warn or block")
117
+ return self
118
+
119
+
120
+ def validate_rule_ids(rules: Iterable[str]) -> None:
121
+ unknown = sorted(set(rules) - set(DEFAULT_POLICIES))
122
+ if unknown:
123
+ raise ValueError(f"unknown policy id(s): {', '.join(unknown)}")
124
+
125
+
126
+ class PolicyLayer(BaseModel):
127
+ """A published policy version that applies to the repository."""
128
+
129
+ model_config = _STRICT
130
+
131
+ level: PolicyLevel
132
+ source_id: str = Field(default="", max_length=64) # group ID or repository ID
133
+ label: str = Field(min_length=1, max_length=MAX_LABEL_CHARS)
134
+ version: int | None = Field(default=None, ge=1)
135
+ rules: dict[str, RuleRequirement] = {}
136
+
137
+ @field_validator("level")
138
+ @classmethod
139
+ def _layer_level(cls, value: PolicyLevel) -> PolicyLevel:
140
+ if value not in LAYER_LEVELS:
141
+ raise ValueError(f"{value.value} is not a policy layer")
142
+ return value
143
+
144
+ @field_validator("rules")
145
+ @classmethod
146
+ def _known(cls, value: dict[str, RuleRequirement]) -> dict[str, RuleRequirement]:
147
+ validate_rule_ids(value)
148
+ return value
149
+
150
+
151
+ class ExceptionGrant(BaseModel):
152
+ """An approved, active exception as seen by the resolver."""
153
+
154
+ model_config = _STRICT
155
+
156
+ exception_id: str = Field(min_length=1, max_length=64)
157
+ rule_id: str
158
+ action: Action
159
+ scope: ExceptionScope
160
+ scope_label: str = Field(min_length=1, max_length=MAX_LABEL_CHARS)
161
+ expires_at: datetime | None = None # None only for a documented permanent exception
162
+
163
+ @field_validator("rule_id")
164
+ @classmethod
165
+ def _known(cls, value: str) -> str:
166
+ validate_rule_ids([value])
167
+ return value
168
+
169
+ @field_validator("action")
170
+ @classmethod
171
+ def _lowering(cls, value: Action) -> Action:
172
+ if value is Action.BLOCK:
173
+ raise ValueError("an exception lowers enforcement to warn or allow")
174
+ return value
175
+
176
+
177
+ class GovernanceInputs(BaseModel):
178
+ """Everything the organization layer decides for one repository."""
179
+
180
+ model_config = _STRICT
181
+
182
+ layers: tuple[PolicyLayer, ...] = ()
183
+ exceptions: tuple[ExceptionGrant, ...] = ()
184
+ mode: RepositoryMode = RepositoryMode.ENFORCE
185
+
186
+ @field_validator("layers")
187
+ @classmethod
188
+ def _bounded_layers(cls, value: tuple[PolicyLayer, ...]) -> tuple[PolicyLayer, ...]:
189
+ if len(value) > MAX_LAYERS:
190
+ raise ValueError(f"more than {MAX_LAYERS} policy layers")
191
+ return value
192
+
193
+ @field_validator("exceptions")
194
+ @classmethod
195
+ def _bounded_exceptions(cls, value: tuple[ExceptionGrant, ...]) -> tuple[ExceptionGrant, ...]:
196
+ if len(value) > MAX_EXCEPTIONS:
197
+ raise ValueError(f"more than {MAX_EXCEPTIONS} exceptions")
198
+ return value
199
+
200
+ @property
201
+ def empty(self) -> bool:
202
+ return not self.layers and not self.exceptions and self.mode is RepositoryMode.ENFORCE
203
+
204
+ def canonical(self) -> str:
205
+ """Deterministic JSON of the inputs that affect enforcement (labels excluded)."""
206
+ ordered = sorted(self.layers, key=lambda item: (_LEVEL_ORDER[item.level], item.source_id))
207
+ document = {
208
+ "layers": [
209
+ {
210
+ "level": layer.level.value,
211
+ "source": layer.source_id,
212
+ "version": layer.version,
213
+ "rules": {
214
+ rule: [req.action.value, req.enforcement.value]
215
+ for rule, req in sorted(layer.rules.items())
216
+ },
217
+ }
218
+ for layer in ordered
219
+ ],
220
+ "exceptions": sorted(
221
+ [g.exception_id, g.rule_id, g.action.value, g.scope.value] for g in self.exceptions
222
+ ),
223
+ "mode": self.mode.value,
224
+ }
225
+ return json.dumps(document, separators=(",", ":"), sort_keys=True)
226
+
227
+ @property
228
+ def fingerprint(self) -> str:
229
+ return sha256_hex(self.canonical().encode("utf-8"))
230
+
231
+ def describe(self) -> str:
232
+ parts = [layer.label for layer in self.ordered_layers()]
233
+ if self.exceptions:
234
+ parts.append(f"{len(self.exceptions)} exception(s)")
235
+ if self.mode is RepositoryMode.MONITOR:
236
+ parts.append("monitor mode")
237
+ return " + ".join(parts) if parts else "no organization governance"
238
+
239
+ def ordered_layers(self) -> list[PolicyLayer]:
240
+ return sorted(self.layers, key=lambda layer: (_LEVEL_ORDER[layer.level], layer.label))
241
+
242
+
243
+ class PolicyConflict(BaseModel):
244
+ """A lower layer asked for less than a mandatory requirement allows."""
245
+
246
+ model_config = _STRICT
247
+
248
+ policy_id: str
249
+ requested_action: Action
250
+ requested_enabled: bool
251
+ requested_by: PolicyLevel
252
+ requested_label: str
253
+ required_action: Action
254
+ required_by: PolicyLevel
255
+ required_label: str
256
+ effective_action: Action
257
+ reason: str
258
+
259
+
260
+ class RuleProvenance(BaseModel):
261
+ """The effective policy for one rule and where every part of it came from."""
262
+
263
+ model_config = _STRICT
264
+
265
+ policy_id: str
266
+ enabled: bool
267
+ action: Action
268
+ source: PolicyLevel
269
+ source_label: str
270
+ enforcement: Enforcement | None = None
271
+ required_action: Action | None = None # the floor, when a mandatory entry exists
272
+ required_by: PolicyLevel | None = None
273
+ required_label: str | None = None
274
+ conflict: PolicyConflict | None = None
275
+ exception_id: str | None = None
276
+ exception_expires_at: datetime | None = None
277
+ action_before_exception: Action | None = None
278
+ monitor_mode: bool = False # block reported as warn
279
+ repository_configuration_known: bool = True
280
+
281
+
282
+ class EffectivePolicy(BaseModel):
283
+ """The resolved policy set for one repository plus its provenance."""
284
+
285
+ model_config = _STRICT
286
+
287
+ policies: tuple[Policy, ...]
288
+ rules: tuple[RuleProvenance, ...]
289
+ inputs_fingerprint: str
290
+ description: str
291
+ mode: RepositoryMode
292
+
293
+ def policy_set(self) -> PolicySet:
294
+ return PolicySet({p.id: p for p in self.policies})
295
+
296
+ @property
297
+ def conflicts(self) -> tuple[PolicyConflict, ...]:
298
+ return tuple(r.conflict for r in self.rules if r.conflict is not None)
299
+
300
+ @property
301
+ def fingerprint(self) -> str:
302
+ """Fingerprint of the evaluated policies (same scheme as scan results)."""
303
+ return policy_set_fingerprint(self.policy_set())
304
+
305
+
306
+ def policy_set_fingerprint(policies: Mapping[str, Policy]) -> str:
307
+ parts: list[str] = []
308
+ for policy in policies.values():
309
+ parts += [policy.id, str(policy.enabled), policy.action.value]
310
+ return fingerprint(parts)
311
+
312
+
313
+ def _level_label(level: PolicyLevel) -> str:
314
+ return {
315
+ PolicyLevel.BUILT_IN: "Built-in default",
316
+ PolicyLevel.REPOSITORY_CONFIGURATION: "Repository configuration (.commitguard.yaml)",
317
+ }.get(level, level.value.replace("_", " ").capitalize())
318
+
319
+
320
+ def _touched(configs: Sequence[CommitGuardConfig], policy_id: str) -> bool:
321
+ return any(
322
+ policy_id in config.policies and config.policies[policy_id].model_fields_set
323
+ for config in configs
324
+ )
325
+
326
+
327
+ def _effective_rank(policy: Policy) -> int:
328
+ return policy.action.rank if policy.enabled else Action.ALLOW.rank
329
+
330
+
331
+ def resolve_policy(
332
+ inputs: GovernanceInputs,
333
+ repository_configs: Sequence[CommitGuardConfig] | None = None,
334
+ ) -> EffectivePolicy:
335
+ """Resolve the effective policy for one repository.
336
+
337
+ ``repository_configs`` are the repository's configuration layers as loaded
338
+ from the trusted revision (the built-in empty layer may be included). Pass
339
+ ``None`` when they are not known (dashboard views between scans): the result
340
+ then shows what governance guarantees, and marks the repository layer unknown.
341
+ """
342
+ layers = inputs.ordered_layers()
343
+ known = repository_configs is not None
344
+ configs = list(repository_configs or ())
345
+
346
+ # Steps 2-4: defaults, narrower levels replacing broader ones.
347
+ defaults: dict[str, tuple[Action, PolicyLevel, str]] = {}
348
+ for level in (PolicyLevel.ORGANIZATION, PolicyLevel.GROUP, PolicyLevel.REPOSITORY_POLICY):
349
+ level_entries: dict[str, list[tuple[Action, str]]] = {}
350
+ for layer in (candidate for candidate in layers if candidate.level is level):
351
+ for rule, requirement in layer.rules.items():
352
+ if requirement.enforcement is Enforcement.DEFAULT:
353
+ level_entries.setdefault(rule, []).append((requirement.action, layer.label))
354
+ for rule, entries in level_entries.items():
355
+ # Several groups: the most restrictive default wins (ties: first label).
356
+ action, label = max(entries, key=lambda entry: entry[0].rank)
357
+ defaults[rule] = (action, level, label)
358
+ defaults_config = CommitGuardConfig(
359
+ version=1,
360
+ policies={rule: PolicyOverride(action=entry[0]) for rule, entry in defaults.items()},
361
+ )
362
+
363
+ # Step 5: the repository configuration overrides defaults (policy engine layering).
364
+ requested = build_policy_set(defaults_config, *configs)
365
+
366
+ # Step 6: mandatory floors.
367
+ floors: dict[str, tuple[Action, PolicyLevel, str]] = {}
368
+ for layer in layers:
369
+ for rule, requirement in layer.rules.items():
370
+ if requirement.enforcement is not Enforcement.MANDATORY:
371
+ continue
372
+ current = floors.get(rule)
373
+ if current is None or requirement.action.rank > current[0].rank:
374
+ floors[rule] = (requirement.action, layer.level, layer.label)
375
+ floor_config = CommitGuardConfig(
376
+ version=1,
377
+ policies={rule: PolicyOverride(action=entry[0]) for rule, entry in floors.items()},
378
+ )
379
+ floored = apply_mandatory_policies(requested, floor_config) if floors else requested
380
+
381
+ grants: dict[str, list[ExceptionGrant]] = {}
382
+ for grant in inputs.exceptions:
383
+ grants.setdefault(grant.rule_id, []).append(grant)
384
+
385
+ policies: list[Policy] = []
386
+ provenance: list[RuleProvenance] = []
387
+ for policy_id in sorted(DEFAULT_POLICIES):
388
+ before_floor = requested[policy_id]
389
+ if _touched(configs, policy_id):
390
+ source, label, enforcement = (
391
+ PolicyLevel.REPOSITORY_CONFIGURATION,
392
+ _level_label(PolicyLevel.REPOSITORY_CONFIGURATION),
393
+ None,
394
+ )
395
+ elif policy_id in defaults:
396
+ _, source, label = defaults[policy_id]
397
+ enforcement = Enforcement.DEFAULT
398
+ else:
399
+ source, label, enforcement = (
400
+ PolicyLevel.BUILT_IN,
401
+ _level_label(PolicyLevel.BUILT_IN),
402
+ None,
403
+ )
404
+ policy = floored[policy_id]
405
+ conflict = None
406
+ floor = floors.get(policy_id)
407
+ if floor is not None:
408
+ floor_action, floor_level, floor_label = floor
409
+ if source is PolicyLevel.BUILT_IN:
410
+ # Nobody asked for less: the floor simply decides the rule.
411
+ if policy.action.rank == floor_action.rank:
412
+ source, label, enforcement = floor_level, floor_label, Enforcement.MANDATORY
413
+ elif _effective_rank(before_floor) < floor_action.rank:
414
+ asked = before_floor.action if before_floor.enabled else Action.ALLOW
415
+ conflict = PolicyConflict(
416
+ policy_id=policy_id,
417
+ requested_action=asked,
418
+ requested_enabled=before_floor.enabled,
419
+ requested_by=source,
420
+ requested_label=label,
421
+ required_action=floor_action,
422
+ required_by=floor_level,
423
+ required_label=floor_label,
424
+ effective_action=policy.action,
425
+ reason=(
426
+ f"{floor_label} requires at least {floor_action.value} and is mandatory: "
427
+ f"{label} cannot weaken it."
428
+ ),
429
+ )
430
+ source, label, enforcement = floor_level, floor_label, Enforcement.MANDATORY
431
+
432
+ exception_id = None
433
+ exception_expires = None
434
+ before_exception = None
435
+ applicable = grants.get(policy_id, [])
436
+ if applicable:
437
+ specificity = max(_SCOPE_SPECIFICITY[g.scope] for g in applicable)
438
+ narrowest = [g for g in applicable if _SCOPE_SPECIFICITY[g.scope] == specificity]
439
+ # The most restrictive of the most specific exceptions (safer on overlap).
440
+ grant = max(narrowest, key=lambda g: (g.action.rank, g.exception_id))
441
+ if _effective_rank(policy) > grant.action.rank:
442
+ before_exception = policy.action
443
+ policy = policy.model_copy(update={"enabled": True, "action": grant.action})
444
+ exception_id, exception_expires = grant.exception_id, grant.expires_at
445
+ source, label = PolicyLevel.EXCEPTION, f"Exception ({grant.scope_label})"
446
+
447
+ monitor = False
448
+ if (
449
+ inputs.mode is RepositoryMode.MONITOR
450
+ and policy.enabled
451
+ and policy.action is Action.BLOCK
452
+ ):
453
+ policy = policy.model_copy(update={"action": Action.WARN})
454
+ monitor = True
455
+
456
+ policies.append(policy)
457
+ provenance.append(
458
+ RuleProvenance(
459
+ policy_id=policy_id,
460
+ enabled=policy.enabled,
461
+ action=policy.action,
462
+ source=source,
463
+ source_label=label,
464
+ enforcement=enforcement,
465
+ required_action=floor[0] if floor else None,
466
+ required_by=floor[1] if floor else None,
467
+ required_label=floor[2] if floor else None,
468
+ conflict=conflict,
469
+ exception_id=exception_id,
470
+ exception_expires_at=exception_expires,
471
+ action_before_exception=before_exception,
472
+ monitor_mode=monitor,
473
+ repository_configuration_known=known,
474
+ )
475
+ )
476
+ return EffectivePolicy(
477
+ policies=tuple(policies),
478
+ rules=tuple(provenance),
479
+ inputs_fingerprint=inputs.fingerprint,
480
+ description=inputs.describe(),
481
+ mode=inputs.mode,
482
+ )
483
+
484
+
485
+ def floor_config(inputs: GovernanceInputs) -> CommitGuardConfig | None:
486
+ """The combined mandatory floor of ``inputs`` (for display and compatibility)."""
487
+ floors: dict[str, Action] = {}
488
+ for layer in inputs.layers:
489
+ for rule, requirement in layer.rules.items():
490
+ if requirement.enforcement is Enforcement.MANDATORY:
491
+ floors[rule] = Action.most_restrictive(
492
+ [requirement.action, floors.get(rule, Action.WARN)]
493
+ )
494
+ if not floors:
495
+ return None
496
+ return CommitGuardConfig(
497
+ version=1, policies={rule: PolicyOverride(action=action) for rule, action in floors.items()}
498
+ )
@@ -0,0 +1,23 @@
1
+ """Build the effective :class:`PolicySet` from validated configuration layers.
2
+
3
+ Merge semantics: start from :data:`DEFAULT_POLICIES`; each configuration layer
4
+ (in precedence order, lowest first) overrides only the policy fields it sets.
5
+ A policy that is *omitted* keeps its value from the layer below and,
6
+ ultimately, its secure default - weakening enforcement always requires an
7
+ explicit entry.
8
+ """
9
+
10
+ from commitguard.config.schema import CommitGuardConfig
11
+ from commitguard.policies.defaults import DEFAULT_POLICIES
12
+ from commitguard.policies.model import Policy, PolicySet
13
+
14
+
15
+ def build_policy_set(*configs: CommitGuardConfig) -> PolicySet:
16
+ """Return the effective policies for configuration layers ``configs``."""
17
+ effective: dict[str, Policy] = dict(DEFAULT_POLICIES)
18
+ for config in configs:
19
+ for policy_id, override in config.policies.items():
20
+ # Schema validation guarantees policy_id is a known ID.
21
+ updates = override.model_dump(exclude_unset=True)
22
+ effective[policy_id] = effective[policy_id].model_copy(update=updates)
23
+ return PolicySet(effective)
@@ -0,0 +1,52 @@
1
+ """Mandatory policies: a floor that repository configuration cannot weaken.
2
+
3
+ Ordinary configuration layers *override* each other (a repository may change
4
+ ``bot_identity`` from warn to allow). A mandatory policy - set by the operator
5
+ of a central service such as the GitHub App, and in future by an organisation -
6
+ is applied *after* those layers and can only make enforcement stricter:
7
+
8
+ * a policy it names is always enabled;
9
+ * its action is the more restrictive of the mandatory action and the action
10
+ the lower layers produced.
11
+
12
+ So with a mandatory ``ai_coauthor: block``, a repository ``ai_coauthor: allow``
13
+ (or ``enabled: false``) still results in BLOCK. A mandatory policy cannot
14
+ disable or relax anything; ``enabled: false``, local hook enforcement and
15
+ ``remediation`` (which can turn a block into a commit) are rejected as
16
+ configuration errors rather than silently ignored.
17
+ """
18
+
19
+ from commitguard.config.schema import CommitGuardConfig
20
+ from commitguard.core.decision import Action
21
+ from commitguard.policies.defaults import DEFAULT_POLICIES
22
+ from commitguard.policies.model import PolicySet
23
+
24
+
25
+ def validate_mandatory_config(config: CommitGuardConfig) -> None:
26
+ """Raise ``ValueError`` if ``config`` tries to weaken rather than enforce."""
27
+ disabled = sorted(pid for pid, o in config.policies.items() if o.enabled is False)
28
+ if disabled:
29
+ raise ValueError(
30
+ "a mandatory policy can only enforce policies; enabled: false is not allowed for "
31
+ + ", ".join(disabled)
32
+ )
33
+ if config.enforcement.model_fields_set:
34
+ raise ValueError("a mandatory policy cannot configure local hook enforcement")
35
+ if config.remediation.model_fields_set:
36
+ # auto_remove turns a block into a commit. A floor may only make
37
+ # enforcement stricter, so it must never be able to switch that on.
38
+ raise ValueError("a mandatory policy cannot configure remediation")
39
+
40
+
41
+ def apply_mandatory_policies(policies: PolicySet, mandatory: CommitGuardConfig) -> PolicySet:
42
+ """Return ``policies`` with the mandatory floor applied (never weaker)."""
43
+ validate_mandatory_config(mandatory)
44
+ effective = dict(policies)
45
+ for policy_id, override in mandatory.policies.items():
46
+ current = effective[policy_id]
47
+ floor = override.action or DEFAULT_POLICIES[policy_id].action
48
+ action = current.action if current.enabled else Action.ALLOW
49
+ effective[policy_id] = current.model_copy(
50
+ update={"enabled": True, "action": Action.most_restrictive([action, floor])}
51
+ )
52
+ return PolicySet(effective)