codereview-loop 0.13.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.
- codereview/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
codereview/policy.py
ADDED
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview project policy — the `.codereview.toml` file
|
|
3
|
+
(Epic B of the adopted roadmap).
|
|
4
|
+
|
|
5
|
+
A project policy is the REPOSITORY's own review contract: the
|
|
6
|
+
panel a change must face, the evidence it must carry, the extra
|
|
7
|
+
rules for sensitive paths, and the terms on which findings may
|
|
8
|
+
be waived. It is a different thing from the per-user setup
|
|
9
|
+
(`userconfig.py`): the setup says which models a PERSON runs;
|
|
10
|
+
the policy says what a PROJECT requires of any review, whoever
|
|
11
|
+
runs it. The gate (core.run_review) reads the policy and the
|
|
12
|
+
record lists every rule that applied and every violation found;
|
|
13
|
+
a blocking violation turns a PASS into BLOCKED. Nothing here
|
|
14
|
+
chooses models — policy constrains the panel's shape (size,
|
|
15
|
+
vendor families, lens coverage), never its members.
|
|
16
|
+
|
|
17
|
+
File: `.codereview.toml` at the repository root (the review
|
|
18
|
+
command discovers it there; `--policy PATH` overrides).
|
|
19
|
+
Parsed with stdlib `tomllib` only. An invalid file fails with a
|
|
20
|
+
line-referenced error and stops the run — a broken policy is
|
|
21
|
+
loud, never silently ignored.
|
|
22
|
+
|
|
23
|
+
Schema (every key optional; defaults in parentheses):
|
|
24
|
+
|
|
25
|
+
[panel]
|
|
26
|
+
min_seats = 0 # successful seats required (0 = none)
|
|
27
|
+
min_families = 0 # distinct known vendor families among
|
|
28
|
+
# successful seats (0 = none)
|
|
29
|
+
required_families = [] # families that must serve successfully,
|
|
30
|
+
# e.g. ["google", "openai"]
|
|
31
|
+
require_lenses = [] # lenses needing a successful seat;
|
|
32
|
+
# subset of ["correctness", "operations"]
|
|
33
|
+
|
|
34
|
+
[evidence]
|
|
35
|
+
require_tests = false # a test command must have run
|
|
36
|
+
require_tests_pass = false # ... and every test must have passed
|
|
37
|
+
|
|
38
|
+
[gate]
|
|
39
|
+
profile = "strict" # the gate profile: "strict" (the
|
|
40
|
+
# default — every blocking-severity
|
|
41
|
+
# finding blocks), "balanced"
|
|
42
|
+
# (policy B1, step 6: a blocking
|
|
43
|
+
# finding whose merged sources are
|
|
44
|
+
# solely the operations lens, with
|
|
45
|
+
# no correctness-lens or
|
|
46
|
+
# deterministic corroboration within
|
|
47
|
+
# the gate's merge identity, is
|
|
48
|
+
# demoted to a visible minor), or
|
|
49
|
+
# "corroborated" (policy B2:
|
|
50
|
+
# evidence-class blocking for every
|
|
51
|
+
# lens — a blocking finding needs
|
|
52
|
+
# deterministic, probe-confirmed,
|
|
53
|
+
# or cross-family-corroborated
|
|
54
|
+
# evidence, plus a validated quote
|
|
55
|
+
# and a concrete trigger;
|
|
56
|
+
# single-source findings become
|
|
57
|
+
# visible non-blocking "unverified
|
|
58
|
+
# majors". The panel must offer at
|
|
59
|
+
# least two distinct vendor families
|
|
60
|
+
# for the correctness lens. B2 is
|
|
61
|
+
# opt-in and unconfirmed — dev
|
|
62
|
+
# evidence only). The CLI's
|
|
63
|
+
# --gate-profile overrides this; the
|
|
64
|
+
# default stays "strict". A fourth
|
|
65
|
+
# profile from the original design,
|
|
66
|
+
# "quiet" (blanket corroboration for
|
|
67
|
+
# every lens), was REJECTED on the
|
|
68
|
+
# step-4 replay evidence — it demoted
|
|
69
|
+
# three real single-seat majors — and
|
|
70
|
+
# is not implemented: its absence is
|
|
71
|
+
# a decision, not an omission, and
|
|
72
|
+
# naming it here is an error.
|
|
73
|
+
|
|
74
|
+
[[paths]] # repeatable; applies when the change
|
|
75
|
+
match = "auth/**" # touches a path matching this pattern
|
|
76
|
+
require_full_panel = false # every configured seat must serve
|
|
77
|
+
require_tests = false
|
|
78
|
+
require_tests_pass = false
|
|
79
|
+
min_seats = 0
|
|
80
|
+
min_families = 0
|
|
81
|
+
enforce = "block" # "block" | "advisory" (advisory
|
|
82
|
+
# violations are recorded, never
|
|
83
|
+
# gate-changing)
|
|
84
|
+
|
|
85
|
+
[waivers]
|
|
86
|
+
allow = true # false: any recorded waiver violates
|
|
87
|
+
require_reason = false # a waiver without a reason violates
|
|
88
|
+
max_age_days = 0 # >0: a waiver older than this (or with
|
|
89
|
+
# no usable date) is expired
|
|
90
|
+
|
|
91
|
+
[diversity] # Epic E3 panel-diversity
|
|
92
|
+
min_families = 0 # enforcement, judged on the
|
|
93
|
+
# COMPOSED panel before any seat is
|
|
94
|
+
# called (distinct from [panel]
|
|
95
|
+
# min_families, which judges the
|
|
96
|
+
# seats that SERVED): the panel must
|
|
97
|
+
# span at least this many distinct
|
|
98
|
+
# known vendor families (0 = check
|
|
99
|
+
# off unless exclusion is enabled)
|
|
100
|
+
exclude_builder_family = false
|
|
101
|
+
# true: when the run's caller knows
|
|
102
|
+
# the builder's vendor family, seats of
|
|
103
|
+
# that family are excluded from the
|
|
104
|
+
# panel when the remaining panel still
|
|
105
|
+
# meets min_families (or, with no
|
|
106
|
+
# minimum, still leaves a seat); when
|
|
107
|
+
# exclusion would breach the minimum
|
|
108
|
+
# the seats stay and the record says
|
|
109
|
+
# why. No caller knowing the family ⇒
|
|
110
|
+
# the exclusion never engages.
|
|
111
|
+
enforce = "degrade" # what a diversity violation does:
|
|
112
|
+
# "degrade" (the default) labels the
|
|
113
|
+
# run degraded and records the
|
|
114
|
+
# violation as advisory — never
|
|
115
|
+
# gate-changing; "block" additionally
|
|
116
|
+
# makes it a blocking policy violation
|
|
117
|
+
|
|
118
|
+
[hooks] # Epic L3 lifecycle hooks: shell
|
|
119
|
+
pre_run = "" # commands fired around the run —
|
|
120
|
+
# pre_run before any seat call,
|
|
121
|
+
post_verdict = "" # post_verdict after the record is
|
|
122
|
+
# written, and
|
|
123
|
+
on_blocked = "" # on_blocked (also after the record
|
|
124
|
+
# is written) when the run's first
|
|
125
|
+
# SEAT ended in a K2 blocked class —
|
|
126
|
+
# a seat state, not the gate verdict
|
|
127
|
+
# (a PASS run can fire it; a BLOCKED
|
|
128
|
+
# run with no blocked seat does not).
|
|
129
|
+
# on_seat_blocked is accepted as an
|
|
130
|
+
# alias for the same slot.
|
|
131
|
+
timeout = 600 # per-hook wall-clock bound, seconds
|
|
132
|
+
# Hooks run through the Epic C
|
|
133
|
+
# evidence sandbox and are pure
|
|
134
|
+
# notification: their results land in
|
|
135
|
+
# the record's `hooks` section and
|
|
136
|
+
# they can NEVER change a verdict, a
|
|
137
|
+
# finding, or any gate input (see
|
|
138
|
+
# hooks.py). An empty/absent command
|
|
139
|
+
# means the event does not fire.
|
|
140
|
+
|
|
141
|
+
[risk] # Epic E1 risk scorer: when
|
|
142
|
+
enabled = false # enabled, every review run's
|
|
143
|
+
# record gains a `risk` section —
|
|
144
|
+
# the deterministic score, band, and
|
|
145
|
+
# reasons computed by risk.py from
|
|
146
|
+
# the change's paths, size, touched
|
|
147
|
+
# sensitive patterns, and dependency
|
|
148
|
+
# changes. Descriptive only in E1:
|
|
149
|
+
# the score never changes a verdict
|
|
150
|
+
# (tiering/escalation consume it in
|
|
151
|
+
# E2/E4). Disabled (the default)
|
|
152
|
+
# leaves records byte-identical.
|
|
153
|
+
medium_threshold = 35 # score >= this is band "medium"
|
|
154
|
+
high_threshold = 70 # score >= this is band "high";
|
|
155
|
+
# each threshold is 0..100 and
|
|
156
|
+
# medium must be below high.
|
|
157
|
+
tiering = false # Epic E2/E4 tiered panels: when
|
|
158
|
+
# true (and enabled is true), the
|
|
159
|
+
# band sizes the panel — low runs
|
|
160
|
+
# the pre-screen seat(s) only,
|
|
161
|
+
# medium a reduced panel, high the
|
|
162
|
+
# full panel with evidence
|
|
163
|
+
# required — and a reduced panel
|
|
164
|
+
# that returns a blocking finding,
|
|
165
|
+
# a reviewer failure, or an
|
|
166
|
+
# unserved lens escalates to the
|
|
167
|
+
# full panel within the run (see
|
|
168
|
+
# tiering.py). Off (the default)
|
|
169
|
+
# leaves runs byte-identical.
|
|
170
|
+
prescreen_seats = [] # seat names for the low tier;
|
|
171
|
+
# empty = the composed panel's
|
|
172
|
+
# first seat.
|
|
173
|
+
medium_seats = [] # seat names for the medium tier;
|
|
174
|
+
# empty = the first tasked seat
|
|
175
|
+
# per lens.
|
|
176
|
+
|
|
177
|
+
With no policy file, the effective policy is the defaults
|
|
178
|
+
above: no requirements at all. CodeReview ships no imposed
|
|
179
|
+
project policy, exactly as it ships no default panel.
|
|
180
|
+
|
|
181
|
+
Trust (P3-01, owner ruling 2026-11-06, option 1): a policy the
|
|
182
|
+
review command DISCOVERS in the repository under review is the
|
|
183
|
+
tree's own file, not the operator's. From a discovered policy
|
|
184
|
+
the gate honours only what can strengthen a review; the keys
|
|
185
|
+
that execute code or weaken the gate
|
|
186
|
+
(DISCOVERED_POLICY_RESTRICTED_KEYS below) are honoured only
|
|
187
|
+
from an operator-named --policy, or from a discovered policy
|
|
188
|
+
the operator explicitly trusts with --trust-repo-policy.
|
|
189
|
+
Dropped keys are reset to their defaults before the policy is
|
|
190
|
+
applied anywhere, and every dropped key is named on the run
|
|
191
|
+
record (policy.ignored_keys) and counted in the CLI's stderr
|
|
192
|
+
warning.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
from __future__ import annotations
|
|
196
|
+
|
|
197
|
+
import datetime
|
|
198
|
+
import hashlib
|
|
199
|
+
import os
|
|
200
|
+
import re
|
|
201
|
+
import tomllib
|
|
202
|
+
|
|
203
|
+
POLICY_FILENAME = ".codereview.toml"
|
|
204
|
+
POLICY_SCHEMA = "codereview.policy/1"
|
|
205
|
+
|
|
206
|
+
# Mirrors core.LENSES (the two review lenses). Kept here so this
|
|
207
|
+
# module stays import-free of the engine; a test pins the two
|
|
208
|
+
# together so they cannot drift.
|
|
209
|
+
KNOWN_LENSES = ("correctness", "operations")
|
|
210
|
+
|
|
211
|
+
# Mirrors core.GATE_PROFILES (the gate profiles, step 6; B2
|
|
212
|
+
# added "corroborated"), kept here under the same import-free
|
|
213
|
+
# discipline; a test pins the two together.
|
|
214
|
+
KNOWN_GATE_PROFILES = ("strict", "balanced", "corroborated")
|
|
215
|
+
|
|
216
|
+
_ENFORCE_LEVELS = ("block", "advisory")
|
|
217
|
+
|
|
218
|
+
_PANEL_KEYS = ("min_seats", "min_families", "required_families",
|
|
219
|
+
"require_lenses")
|
|
220
|
+
_EVIDENCE_KEYS = ("require_tests", "require_tests_pass")
|
|
221
|
+
_GATE_KEYS = ("profile",)
|
|
222
|
+
_PATH_KEYS = ("match", "require_full_panel", "require_tests",
|
|
223
|
+
"require_tests_pass", "min_seats", "min_families",
|
|
224
|
+
"enforce")
|
|
225
|
+
_WAIVER_KEYS = ("allow", "require_reason", "max_age_days")
|
|
226
|
+
# on_seat_blocked is an accepted ALIAS for on_blocked (P6-09:
|
|
227
|
+
# the event means a SEAT blocked, not that the gate verdict is
|
|
228
|
+
# BLOCKED, and the alias says so). It is an input key only —
|
|
229
|
+
# the load loop folds it into the canonical on_blocked slot,
|
|
230
|
+
# so _HOOK_COMMAND_KEYS (and the hooks module's event keys it
|
|
231
|
+
# mirrors) never see it.
|
|
232
|
+
_HOOK_KEYS = ("pre_run", "post_verdict", "on_blocked",
|
|
233
|
+
"on_seat_blocked", "timeout")
|
|
234
|
+
_HOOK_COMMAND_KEYS = ("pre_run", "post_verdict", "on_blocked")
|
|
235
|
+
_RISK_KEYS = ("enabled", "medium_threshold", "high_threshold",
|
|
236
|
+
"tiering", "prescreen_seats", "medium_seats")
|
|
237
|
+
# Mirror risk.DEFAULT_MEDIUM_THRESHOLD /
|
|
238
|
+
# risk.DEFAULT_HIGH_THRESHOLD (Epic E1), kept here under the
|
|
239
|
+
# same import-free discipline as KNOWN_LENSES; a test pins the
|
|
240
|
+
# pairs together so they cannot drift.
|
|
241
|
+
_RISK_DEFAULT_MEDIUM_THRESHOLD = 35
|
|
242
|
+
_RISK_DEFAULT_HIGH_THRESHOLD = 70
|
|
243
|
+
_DIVERSITY_KEYS = ("min_families", "exclude_builder_family",
|
|
244
|
+
"enforce")
|
|
245
|
+
_DIVERSITY_ENFORCE_LEVELS = ("degrade", "block")
|
|
246
|
+
_TABLES = ("panel", "evidence", "gate", "paths", "waivers",
|
|
247
|
+
"hooks", "risk", "diversity")
|
|
248
|
+
|
|
249
|
+
# P3-01 trust restriction (owner ruling 2026-11-06, option 1):
|
|
250
|
+
# the keys a repo-DISCOVERED policy may not set. Everything not
|
|
251
|
+
# listed here — panel minimums, evidence requirements, path
|
|
252
|
+
# require_* rules, [diversity] min_families, the strengthening
|
|
253
|
+
# waiver values (allow = false, require_reason = true,
|
|
254
|
+
# max_age_days > 0), [diversity] enforce = "block", a [gate]
|
|
255
|
+
# profile of "strict" — is strengthening or neutral and stays
|
|
256
|
+
# honoured from a discovered policy. This tuple is the single
|
|
257
|
+
# audit point for the boundary: a future key that can execute
|
|
258
|
+
# code or weaken the gate joins this list FIRST, and
|
|
259
|
+
# apply_discovered_restrictions() is its only enforcement.
|
|
260
|
+
# (Precedent: the L4 notifier command is likewise not
|
|
261
|
+
# policy-settable at all — notify.py; hooks were the anomaly.)
|
|
262
|
+
DISCOVERED_POLICY_RESTRICTED_KEYS = (
|
|
263
|
+
# [hooks] — tree-supplied shell commands, the timeout
|
|
264
|
+
# included: never honoured from a discovered policy.
|
|
265
|
+
"hooks.pre_run",
|
|
266
|
+
"hooks.post_verdict",
|
|
267
|
+
"hooks.on_blocked",
|
|
268
|
+
"hooks.on_seat_blocked",
|
|
269
|
+
"hooks.timeout",
|
|
270
|
+
# [gate] profile — when it selects anything weaker than
|
|
271
|
+
# "strict" ("balanced" or "corroborated").
|
|
272
|
+
"gate.profile",
|
|
273
|
+
# [[paths]] enforce — when "advisory" (violations recorded
|
|
274
|
+
# but never gate-changing).
|
|
275
|
+
"paths.enforce",
|
|
276
|
+
# [waivers] leniency — waivers permitted at all
|
|
277
|
+
# (allow = true), without a recorded reason
|
|
278
|
+
# (require_reason = false), or never expiring
|
|
279
|
+
# (max_age_days = 0). These values EQUAL the defaults, so
|
|
280
|
+
# for them the drop is nominal — the defaults govern, not
|
|
281
|
+
# the tree — but the attempt is still recorded.
|
|
282
|
+
"waivers.allow",
|
|
283
|
+
"waivers.require_reason",
|
|
284
|
+
"waivers.max_age_days",
|
|
285
|
+
# [diversity] enforce — when "degrade" (violations label
|
|
286
|
+
# the run degraded but never block; also the default, so
|
|
287
|
+
# nominal like the waiver values above).
|
|
288
|
+
"diversity.enforce",
|
|
289
|
+
# [diversity] exclude_builder_family — when true (panel
|
|
290
|
+
# seats of the builder's family are dropped).
|
|
291
|
+
"diversity.exclude_builder_family",
|
|
292
|
+
# [risk] tiering — when true (low/medium bands run reduced
|
|
293
|
+
# panels), and its seat selection.
|
|
294
|
+
"risk.tiering",
|
|
295
|
+
"risk.prescreen_seats",
|
|
296
|
+
"risk.medium_seats",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class PolicyError(ValueError):
|
|
301
|
+
"""A policy file that cannot be parsed or validated. The
|
|
302
|
+
message names the file and, whenever it can be located, the
|
|
303
|
+
line of the offending key."""
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def default_policy():
|
|
307
|
+
"""The effective policy when no file exists: no requirements."""
|
|
308
|
+
return {
|
|
309
|
+
"schema": POLICY_SCHEMA,
|
|
310
|
+
"path": None,
|
|
311
|
+
"sha256": None,
|
|
312
|
+
"panel": {"min_seats": 0, "min_families": 0,
|
|
313
|
+
"required_families": [], "require_lenses": []},
|
|
314
|
+
"evidence": {"require_tests": False,
|
|
315
|
+
"require_tests_pass": False},
|
|
316
|
+
"gate": {"profile": "strict"},
|
|
317
|
+
"paths": [],
|
|
318
|
+
"waivers": {"allow": True, "require_reason": False,
|
|
319
|
+
"max_age_days": 0},
|
|
320
|
+
"hooks": {"pre_run": None, "post_verdict": None,
|
|
321
|
+
"on_blocked": None, "timeout": None},
|
|
322
|
+
"risk": {"enabled": False, "medium_threshold": None,
|
|
323
|
+
"high_threshold": None, "tiering": False,
|
|
324
|
+
"prescreen_seats": [], "medium_seats": []},
|
|
325
|
+
"diversity": {"min_families": 0,
|
|
326
|
+
"exclude_builder_family": False,
|
|
327
|
+
"enforce": "degrade"},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def find_policy(repo="."):
|
|
332
|
+
"""The discovered policy path for a repo, or None."""
|
|
333
|
+
candidate = os.path.join(repo or ".", POLICY_FILENAME)
|
|
334
|
+
return candidate if os.path.exists(candidate) else None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ----------------------------------------------------------------
|
|
338
|
+
# Loading + validation (B1). Errors are line-referenced: TOML
|
|
339
|
+
# syntax errors carry tomllib's own line; semantic errors locate
|
|
340
|
+
# the offending key by scanning the source lines for its table
|
|
341
|
+
# header and assignment (the locator only serves error messages,
|
|
342
|
+
# so an exotic formatting it cannot follow falls back to the
|
|
343
|
+
# table's line, then to the file as a whole).
|
|
344
|
+
|
|
345
|
+
def _locate(lines, table, key=None, occurrence=0):
|
|
346
|
+
"""1-based line of `key` under the `occurrence`-th `[table]`
|
|
347
|
+
(or `[[table]]`) header, else the header's own line, else
|
|
348
|
+
None."""
|
|
349
|
+
header_line = None
|
|
350
|
+
current = None
|
|
351
|
+
seen = -1
|
|
352
|
+
header_re = re.compile(r"\[\[?\s*([^\]\s]+)\s*\]\]?")
|
|
353
|
+
for i, raw in enumerate(lines, 1):
|
|
354
|
+
stripped = raw.strip()
|
|
355
|
+
if stripped.startswith("["):
|
|
356
|
+
m = header_re.match(stripped)
|
|
357
|
+
current = m.group(1) if m else None
|
|
358
|
+
if current == table:
|
|
359
|
+
seen += 1
|
|
360
|
+
if seen == occurrence:
|
|
361
|
+
header_line = i
|
|
362
|
+
if key is None:
|
|
363
|
+
return i
|
|
364
|
+
continue
|
|
365
|
+
if current == table and seen == occurrence and key:
|
|
366
|
+
if re.match(re.escape(key) + r"\s*=", stripped):
|
|
367
|
+
return i
|
|
368
|
+
return header_line
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _fail(source, lines, message, table=None, key=None,
|
|
372
|
+
occurrence=0):
|
|
373
|
+
line = _locate(lines, table, key, occurrence) if table else None
|
|
374
|
+
where = f"{source}:{line}" if line else str(source)
|
|
375
|
+
raise PolicyError(f"{where}: {message}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _check_int(value, source, lines, table, key, occurrence=0):
|
|
379
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
380
|
+
_fail(source, lines,
|
|
381
|
+
f"[{table}] {key} must be an integer, got "
|
|
382
|
+
f"{type(value).__name__}", table, key, occurrence)
|
|
383
|
+
if value < 0:
|
|
384
|
+
_fail(source, lines,
|
|
385
|
+
f"[{table}] {key} must be >= 0, got {value}",
|
|
386
|
+
table, key, occurrence)
|
|
387
|
+
return value
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _check_bool(value, source, lines, table, key, occurrence=0):
|
|
391
|
+
if not isinstance(value, bool):
|
|
392
|
+
_fail(source, lines,
|
|
393
|
+
f"[{table}] {key} must be true or false, got "
|
|
394
|
+
f"{type(value).__name__}", table, key, occurrence)
|
|
395
|
+
return value
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _check_str_list(value, source, lines, table, key,
|
|
399
|
+
occurrence=0):
|
|
400
|
+
if not isinstance(value, list) or any(
|
|
401
|
+
not isinstance(v, str) or not v.strip()
|
|
402
|
+
for v in value):
|
|
403
|
+
_fail(source, lines,
|
|
404
|
+
f"[{table}] {key} must be a list of non-empty "
|
|
405
|
+
f"strings", table, key, occurrence)
|
|
406
|
+
return [str(v) for v in value]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _check_keys(table_data, allowed, source, lines, table,
|
|
410
|
+
occurrence=0):
|
|
411
|
+
if not isinstance(table_data, dict):
|
|
412
|
+
_fail(source, lines, f"[{table}] must be a TOML table",
|
|
413
|
+
table, occurrence=occurrence)
|
|
414
|
+
for key in table_data:
|
|
415
|
+
if key not in allowed:
|
|
416
|
+
_fail(source, lines,
|
|
417
|
+
f"unknown key {key!r} in [{table}] (allowed: "
|
|
418
|
+
f"{', '.join(allowed)})", table, key, occurrence)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def load_policy(path):
|
|
422
|
+
"""Load + validate a policy file -> the effective policy
|
|
423
|
+
dict (defaults filled, source path + content hash recorded).
|
|
424
|
+
Raises PolicyError on any problem."""
|
|
425
|
+
with open(path, "rb") as fh:
|
|
426
|
+
raw = fh.read()
|
|
427
|
+
try:
|
|
428
|
+
# P2-04: utf-8-sig accepts a UTF-8 BOM as well as plain
|
|
429
|
+
# UTF-8 — editors that save with a BOM must not make a
|
|
430
|
+
# valid policy unloadable.
|
|
431
|
+
text = raw.decode("utf-8-sig")
|
|
432
|
+
except UnicodeDecodeError as exc:
|
|
433
|
+
raise PolicyError(f"{path}: not valid UTF-8 ({exc})")
|
|
434
|
+
try:
|
|
435
|
+
data = tomllib.loads(text)
|
|
436
|
+
except tomllib.TOMLDecodeError as exc:
|
|
437
|
+
raise PolicyError(f"{path}: invalid TOML: {exc}")
|
|
438
|
+
except RecursionError as exc:
|
|
439
|
+
# P2-04: a deeply nested document exhausts the parser's
|
|
440
|
+
# stack instead of raising TOMLDecodeError — surface it
|
|
441
|
+
# as the loader's one error type.
|
|
442
|
+
raise PolicyError(
|
|
443
|
+
f"{path}: invalid TOML: excessive nesting depth"
|
|
444
|
+
) from exc
|
|
445
|
+
lines = text.splitlines()
|
|
446
|
+
for key in data:
|
|
447
|
+
if key not in _TABLES:
|
|
448
|
+
line = None
|
|
449
|
+
for i, raw_line in enumerate(lines, 1):
|
|
450
|
+
if raw_line.strip().startswith(f"[{key}]") \
|
|
451
|
+
or raw_line.strip().startswith(f"[[{key}]]") \
|
|
452
|
+
or re.match(re.escape(key) + r"\s*=",
|
|
453
|
+
raw_line.strip()):
|
|
454
|
+
line = i
|
|
455
|
+
break
|
|
456
|
+
where = f"{path}:{line}" if line else str(path)
|
|
457
|
+
raise PolicyError(
|
|
458
|
+
f"{where}: unknown top-level key/table {key!r} "
|
|
459
|
+
f"(policy tables are: {', '.join(_TABLES)}; bare "
|
|
460
|
+
f"keys must live inside one of them)")
|
|
461
|
+
|
|
462
|
+
policy = default_policy()
|
|
463
|
+
policy["path"] = str(path)
|
|
464
|
+
policy["sha256"] = hashlib.sha256(raw).hexdigest()
|
|
465
|
+
|
|
466
|
+
panel = data.get("panel") or {}
|
|
467
|
+
_check_keys(panel, _PANEL_KEYS, path, lines, "panel")
|
|
468
|
+
if "min_seats" in panel:
|
|
469
|
+
policy["panel"]["min_seats"] = _check_int(
|
|
470
|
+
panel["min_seats"], path, lines, "panel", "min_seats")
|
|
471
|
+
if "min_families" in panel:
|
|
472
|
+
policy["panel"]["min_families"] = _check_int(
|
|
473
|
+
panel["min_families"], path, lines, "panel",
|
|
474
|
+
"min_families")
|
|
475
|
+
if "required_families" in panel:
|
|
476
|
+
policy["panel"]["required_families"] = _check_str_list(
|
|
477
|
+
panel["required_families"], path, lines, "panel",
|
|
478
|
+
"required_families")
|
|
479
|
+
if "require_lenses" in panel:
|
|
480
|
+
lenses = _check_str_list(
|
|
481
|
+
panel["require_lenses"], path, lines, "panel",
|
|
482
|
+
"require_lenses")
|
|
483
|
+
for lens in lenses:
|
|
484
|
+
if lens not in KNOWN_LENSES:
|
|
485
|
+
_fail(path, lines,
|
|
486
|
+
f"[panel] require_lenses names unknown lens "
|
|
487
|
+
f"{lens!r} (known: {', '.join(KNOWN_LENSES)})",
|
|
488
|
+
"panel", "require_lenses")
|
|
489
|
+
policy["panel"]["require_lenses"] = lenses
|
|
490
|
+
|
|
491
|
+
evidence = data.get("evidence") or {}
|
|
492
|
+
_check_keys(evidence, _EVIDENCE_KEYS, path, lines, "evidence")
|
|
493
|
+
for key in _EVIDENCE_KEYS:
|
|
494
|
+
if key in evidence:
|
|
495
|
+
policy["evidence"][key] = _check_bool(
|
|
496
|
+
evidence[key], path, lines, "evidence", key)
|
|
497
|
+
|
|
498
|
+
gate = data.get("gate") or {}
|
|
499
|
+
_check_keys(gate, _GATE_KEYS, path, lines, "gate")
|
|
500
|
+
if "profile" in gate:
|
|
501
|
+
if gate["profile"] not in KNOWN_GATE_PROFILES:
|
|
502
|
+
_fail(path, lines,
|
|
503
|
+
f"[gate] profile must be one of "
|
|
504
|
+
f"{', '.join(KNOWN_GATE_PROFILES)}, got "
|
|
505
|
+
f"{gate['profile']!r} ('quiet' was considered "
|
|
506
|
+
f"and rejected on the step-4 replay evidence "
|
|
507
|
+
f"— it is not an implemented profile)",
|
|
508
|
+
"gate", "profile")
|
|
509
|
+
policy["gate"]["profile"] = gate["profile"]
|
|
510
|
+
|
|
511
|
+
raw_paths = data.get("paths") or []
|
|
512
|
+
if not isinstance(raw_paths, list):
|
|
513
|
+
_fail(path, lines,
|
|
514
|
+
"[paths] must be an array of tables ([[paths]])",
|
|
515
|
+
"paths")
|
|
516
|
+
for idx, entry in enumerate(raw_paths):
|
|
517
|
+
_check_keys(entry, _PATH_KEYS, path, lines, "paths", idx)
|
|
518
|
+
match = entry.get("match")
|
|
519
|
+
if not isinstance(match, str) or not match.strip():
|
|
520
|
+
_fail(path, lines,
|
|
521
|
+
"[[paths]] entry needs a non-empty 'match' "
|
|
522
|
+
"pattern (e.g. \"auth/**\")", "paths", "match",
|
|
523
|
+
idx)
|
|
524
|
+
rule = {"match": match,
|
|
525
|
+
"require_full_panel": False,
|
|
526
|
+
"require_tests": False,
|
|
527
|
+
"require_tests_pass": False,
|
|
528
|
+
"min_seats": 0, "min_families": 0,
|
|
529
|
+
"enforce": "block"}
|
|
530
|
+
for key in ("require_full_panel", "require_tests",
|
|
531
|
+
"require_tests_pass"):
|
|
532
|
+
if key in entry:
|
|
533
|
+
rule[key] = _check_bool(entry[key], path, lines,
|
|
534
|
+
"paths", key, idx)
|
|
535
|
+
for key in ("min_seats", "min_families"):
|
|
536
|
+
if key in entry:
|
|
537
|
+
rule[key] = _check_int(entry[key], path, lines,
|
|
538
|
+
"paths", key, idx)
|
|
539
|
+
if "enforce" in entry:
|
|
540
|
+
if entry["enforce"] not in _ENFORCE_LEVELS:
|
|
541
|
+
_fail(path, lines,
|
|
542
|
+
f"[[paths]] enforce must be one of "
|
|
543
|
+
f"{', '.join(_ENFORCE_LEVELS)}, got "
|
|
544
|
+
f"{entry['enforce']!r}", "paths", "enforce",
|
|
545
|
+
idx)
|
|
546
|
+
rule["enforce"] = entry["enforce"]
|
|
547
|
+
policy["paths"].append(rule)
|
|
548
|
+
|
|
549
|
+
waivers = data.get("waivers") or {}
|
|
550
|
+
_check_keys(waivers, _WAIVER_KEYS, path, lines, "waivers")
|
|
551
|
+
for key in ("allow", "require_reason"):
|
|
552
|
+
if key in waivers:
|
|
553
|
+
policy["waivers"][key] = _check_bool(
|
|
554
|
+
waivers[key], path, lines, "waivers", key)
|
|
555
|
+
if "max_age_days" in waivers:
|
|
556
|
+
policy["waivers"]["max_age_days"] = _check_int(
|
|
557
|
+
waivers["max_age_days"], path, lines, "waivers",
|
|
558
|
+
"max_age_days")
|
|
559
|
+
|
|
560
|
+
hooks = data.get("hooks") or {}
|
|
561
|
+
_check_keys(hooks, _HOOK_KEYS, path, lines, "hooks")
|
|
562
|
+
for key in _HOOK_COMMAND_KEYS:
|
|
563
|
+
if key in hooks:
|
|
564
|
+
value = hooks[key]
|
|
565
|
+
if not isinstance(value, str) or not value.strip():
|
|
566
|
+
_fail(path, lines,
|
|
567
|
+
f"[hooks] {key} must be a non-empty "
|
|
568
|
+
f"command string, got {value!r}",
|
|
569
|
+
"hooks", key)
|
|
570
|
+
policy["hooks"][key] = value
|
|
571
|
+
if "on_seat_blocked" in hooks:
|
|
572
|
+
# P6-09 alias: folds into the on_blocked slot. When
|
|
573
|
+
# both are given the canonical key wins — one slot,
|
|
574
|
+
# one command, stated here rather than resolved by
|
|
575
|
+
# dict order.
|
|
576
|
+
value = hooks["on_seat_blocked"]
|
|
577
|
+
if not isinstance(value, str) or not value.strip():
|
|
578
|
+
_fail(path, lines,
|
|
579
|
+
f"[hooks] on_seat_blocked must be a "
|
|
580
|
+
f"non-empty command string, got {value!r}",
|
|
581
|
+
"hooks", "on_seat_blocked")
|
|
582
|
+
if policy["hooks"]["on_blocked"] is None:
|
|
583
|
+
policy["hooks"]["on_blocked"] = value
|
|
584
|
+
if "timeout" in hooks:
|
|
585
|
+
value = hooks["timeout"]
|
|
586
|
+
if isinstance(value, bool) \
|
|
587
|
+
or not isinstance(value, (int, float)) \
|
|
588
|
+
or value <= 0:
|
|
589
|
+
_fail(path, lines,
|
|
590
|
+
f"[hooks] timeout must be a positive number "
|
|
591
|
+
f"of seconds, got {value!r}", "hooks",
|
|
592
|
+
"timeout")
|
|
593
|
+
policy["hooks"]["timeout"] = value
|
|
594
|
+
|
|
595
|
+
risk = data.get("risk") or {}
|
|
596
|
+
_check_keys(risk, _RISK_KEYS, path, lines, "risk")
|
|
597
|
+
if "enabled" in risk:
|
|
598
|
+
policy["risk"]["enabled"] = _check_bool(
|
|
599
|
+
risk["enabled"], path, lines, "risk", "enabled")
|
|
600
|
+
for key in ("medium_threshold", "high_threshold"):
|
|
601
|
+
if key in risk:
|
|
602
|
+
value = _check_int(risk[key], path, lines, "risk",
|
|
603
|
+
key)
|
|
604
|
+
if value > 100:
|
|
605
|
+
_fail(path, lines,
|
|
606
|
+
f"[risk] {key} must be at most 100, got "
|
|
607
|
+
f"{value}", "risk", key)
|
|
608
|
+
policy["risk"][key] = value
|
|
609
|
+
# The effective band pair (stored values over the module
|
|
610
|
+
# defaults) must be ordered, whether or not scoring is
|
|
611
|
+
# enabled — an inverted pair is a broken policy either way
|
|
612
|
+
# and B1 errors are loud, never deferred to first use.
|
|
613
|
+
_medium = policy["risk"]["medium_threshold"]
|
|
614
|
+
if _medium is None:
|
|
615
|
+
_medium = _RISK_DEFAULT_MEDIUM_THRESHOLD
|
|
616
|
+
_high = policy["risk"]["high_threshold"]
|
|
617
|
+
if _high is None:
|
|
618
|
+
_high = _RISK_DEFAULT_HIGH_THRESHOLD
|
|
619
|
+
if _medium >= _high:
|
|
620
|
+
_fail(path, lines,
|
|
621
|
+
f"[risk] medium_threshold ({_medium}) must be "
|
|
622
|
+
f"below high_threshold ({_high}) — the bands "
|
|
623
|
+
f"would be empty or inverted", "risk",
|
|
624
|
+
"high_threshold")
|
|
625
|
+
if "tiering" in risk:
|
|
626
|
+
policy["risk"]["tiering"] = _check_bool(
|
|
627
|
+
risk["tiering"], path, lines, "risk", "tiering")
|
|
628
|
+
for key in ("prescreen_seats", "medium_seats"):
|
|
629
|
+
if key in risk:
|
|
630
|
+
policy["risk"][key] = _check_str_list(
|
|
631
|
+
risk[key], path, lines, "risk", key)
|
|
632
|
+
if policy["risk"]["tiering"] \
|
|
633
|
+
and not policy["risk"]["enabled"]:
|
|
634
|
+
# Tiering consumes the risk band; with the scorer
|
|
635
|
+
# disabled there is no band to tier on, so the
|
|
636
|
+
# combination is a broken policy — loud at load,
|
|
637
|
+
# never a silent no-op at run time (B1).
|
|
638
|
+
_fail(path, lines,
|
|
639
|
+
"[risk] tiering = true requires enabled = true "
|
|
640
|
+
"— tiering is driven by the risk score, and a "
|
|
641
|
+
"disabled scorer produces no band", "risk",
|
|
642
|
+
"tiering")
|
|
643
|
+
diversity = data.get("diversity") or {}
|
|
644
|
+
_check_keys(diversity, _DIVERSITY_KEYS, path, lines,
|
|
645
|
+
"diversity")
|
|
646
|
+
if "min_families" in diversity:
|
|
647
|
+
policy["diversity"]["min_families"] = _check_int(
|
|
648
|
+
diversity["min_families"], path, lines, "diversity",
|
|
649
|
+
"min_families")
|
|
650
|
+
if "exclude_builder_family" in diversity:
|
|
651
|
+
policy["diversity"]["exclude_builder_family"] = \
|
|
652
|
+
_check_bool(diversity["exclude_builder_family"],
|
|
653
|
+
path, lines, "diversity",
|
|
654
|
+
"exclude_builder_family")
|
|
655
|
+
if "enforce" in diversity:
|
|
656
|
+
if diversity["enforce"] not in _DIVERSITY_ENFORCE_LEVELS:
|
|
657
|
+
_fail(path, lines,
|
|
658
|
+
f"[diversity] enforce must be one of "
|
|
659
|
+
f"{', '.join(_DIVERSITY_ENFORCE_LEVELS)}, got "
|
|
660
|
+
f"{diversity['enforce']!r}", "diversity",
|
|
661
|
+
"enforce")
|
|
662
|
+
policy["diversity"]["enforce"] = diversity["enforce"]
|
|
663
|
+
return policy
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _raw_policy_tables(path):
|
|
667
|
+
"""The policy file's raw tables (tomllib), or {} when the
|
|
668
|
+
file cannot be re-read. Serves only the explicitness
|
|
669
|
+
checks in apply_discovered_restrictions — the loader has
|
|
670
|
+
already validated the file by the time this runs."""
|
|
671
|
+
if not path:
|
|
672
|
+
return {}
|
|
673
|
+
try:
|
|
674
|
+
with open(path, "rb") as fh:
|
|
675
|
+
text = fh.read().decode("utf-8-sig")
|
|
676
|
+
return tomllib.loads(text)
|
|
677
|
+
except Exception: # noqa: BLE001 — best-effort provenance
|
|
678
|
+
return {}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def apply_discovered_restrictions(policy):
|
|
682
|
+
"""Enforce the P3-01 trust restriction on a policy that was
|
|
683
|
+
DISCOVERED in the reviewed repository (owner ruling
|
|
684
|
+
2026-11-06, option 1): reset every
|
|
685
|
+
DISCOVERED_POLICY_RESTRICTED_KEYS value to its default,
|
|
686
|
+
stamp the policy with the sorted dotted names dropped
|
|
687
|
+
(policy["ignored_keys"]), and return that list.
|
|
688
|
+
|
|
689
|
+
The caller is the CLI, at load time — before the policy is
|
|
690
|
+
applied anywhere (before the hook runner is built from it,
|
|
691
|
+
before the gate reads it) — when the policy's origin is
|
|
692
|
+
"discovered-in-repo" and the operator passed neither an
|
|
693
|
+
explicit --policy for the file nor --trust-repo-policy.
|
|
694
|
+
An operator-named policy never passes through here.
|
|
695
|
+
|
|
696
|
+
Most restricted values are visible in the effective policy
|
|
697
|
+
alone: a set hook, a non-"strict" profile, an "advisory"
|
|
698
|
+
path rule, tiering switched on, non-empty tier seat lists,
|
|
699
|
+
builder-family exclusion — none of those is a default. The
|
|
700
|
+
exceptions are the leniency values that EQUAL the defaults
|
|
701
|
+
([waivers] allow = true / require_reason = false /
|
|
702
|
+
max_age_days = 0, [diversity] enforce = "degrade"): for
|
|
703
|
+
those, explicitness is read back from the file itself via
|
|
704
|
+
policy["path"], and presence in the file is what is
|
|
705
|
+
recorded — the drop is nominal there (the defaults govern,
|
|
706
|
+
not the tree) but the tree's attempt to set gate terms is
|
|
707
|
+
still named on the record."""
|
|
708
|
+
ignored = set()
|
|
709
|
+
defaults = default_policy()
|
|
710
|
+
|
|
711
|
+
hooks = policy.get("hooks") or {}
|
|
712
|
+
if any(hooks.get(key) is not None for key in _HOOK_KEYS):
|
|
713
|
+
for key in _HOOK_KEYS:
|
|
714
|
+
if hooks.get(key) is not None:
|
|
715
|
+
ignored.add(f"hooks.{key}")
|
|
716
|
+
policy["hooks"] = dict(defaults["hooks"])
|
|
717
|
+
|
|
718
|
+
gate = policy.get("gate") or {}
|
|
719
|
+
if gate.get("profile") not in (None, "strict"):
|
|
720
|
+
ignored.add("gate.profile")
|
|
721
|
+
gate["profile"] = "strict"
|
|
722
|
+
|
|
723
|
+
for rule in policy.get("paths") or []:
|
|
724
|
+
if rule.get("enforce") == "advisory":
|
|
725
|
+
ignored.add(f"paths[{rule.get('match')}].enforce")
|
|
726
|
+
rule["enforce"] = "block"
|
|
727
|
+
|
|
728
|
+
diversity = policy.get("diversity") or {}
|
|
729
|
+
if diversity.get("exclude_builder_family"):
|
|
730
|
+
ignored.add("diversity.exclude_builder_family")
|
|
731
|
+
diversity["exclude_builder_family"] = False
|
|
732
|
+
|
|
733
|
+
risk = policy.get("risk") or {}
|
|
734
|
+
if risk.get("tiering"):
|
|
735
|
+
ignored.add("risk.tiering")
|
|
736
|
+
risk["tiering"] = False
|
|
737
|
+
for key in ("prescreen_seats", "medium_seats"):
|
|
738
|
+
if risk.get(key):
|
|
739
|
+
ignored.add(f"risk.{key}")
|
|
740
|
+
risk[key] = []
|
|
741
|
+
|
|
742
|
+
raw = _raw_policy_tables(policy.get("path"))
|
|
743
|
+
raw_waivers = raw.get("waivers") or {}
|
|
744
|
+
if raw_waivers.get("allow") is True:
|
|
745
|
+
ignored.add("waivers.allow")
|
|
746
|
+
if raw_waivers.get("require_reason") is False:
|
|
747
|
+
ignored.add("waivers.require_reason")
|
|
748
|
+
if raw_waivers.get("max_age_days") == 0 \
|
|
749
|
+
and not isinstance(raw_waivers.get("max_age_days"),
|
|
750
|
+
bool) \
|
|
751
|
+
and "max_age_days" in raw_waivers:
|
|
752
|
+
ignored.add("waivers.max_age_days")
|
|
753
|
+
raw_diversity = raw.get("diversity") or {}
|
|
754
|
+
if raw_diversity.get("enforce") == "degrade":
|
|
755
|
+
ignored.add("diversity.enforce")
|
|
756
|
+
|
|
757
|
+
policy["ignored_keys"] = sorted(ignored)
|
|
758
|
+
return policy["ignored_keys"]
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def render_effective(policy):
|
|
762
|
+
"""Deterministic text rendering of the effective policy —
|
|
763
|
+
what `codereview policy check` prints (B3)."""
|
|
764
|
+
def _b(value):
|
|
765
|
+
return "true" if value else "false"
|
|
766
|
+
|
|
767
|
+
def _lst(values):
|
|
768
|
+
return "[" + ", ".join(f'"{v}"' for v in values) + "]"
|
|
769
|
+
|
|
770
|
+
panel, evidence, waivers = (policy["panel"],
|
|
771
|
+
policy["evidence"],
|
|
772
|
+
policy["waivers"])
|
|
773
|
+
gate = policy["gate"]
|
|
774
|
+
lines = [
|
|
775
|
+
"[panel]",
|
|
776
|
+
f"min_seats = {panel['min_seats']}",
|
|
777
|
+
f"min_families = {panel['min_families']}",
|
|
778
|
+
f"required_families = {_lst(panel['required_families'])}",
|
|
779
|
+
f"require_lenses = {_lst(panel['require_lenses'])}",
|
|
780
|
+
"",
|
|
781
|
+
"[evidence]",
|
|
782
|
+
f"require_tests = {_b(evidence['require_tests'])}",
|
|
783
|
+
f"require_tests_pass = {_b(evidence['require_tests_pass'])}",
|
|
784
|
+
"",
|
|
785
|
+
"[gate]",
|
|
786
|
+
f"profile = \"{gate['profile']}\"",
|
|
787
|
+
"",
|
|
788
|
+
]
|
|
789
|
+
for rule in policy["paths"]:
|
|
790
|
+
lines += [
|
|
791
|
+
"[[paths]]",
|
|
792
|
+
f"match = \"{rule['match']}\"",
|
|
793
|
+
f"require_full_panel = {_b(rule['require_full_panel'])}",
|
|
794
|
+
f"require_tests = {_b(rule['require_tests'])}",
|
|
795
|
+
f"require_tests_pass = {_b(rule['require_tests_pass'])}",
|
|
796
|
+
f"min_seats = {rule['min_seats']}",
|
|
797
|
+
f"min_families = {rule['min_families']}",
|
|
798
|
+
f"enforce = \"{rule['enforce']}\"",
|
|
799
|
+
"",
|
|
800
|
+
]
|
|
801
|
+
lines += [
|
|
802
|
+
"[waivers]",
|
|
803
|
+
f"allow = {_b(waivers['allow'])}",
|
|
804
|
+
f"require_reason = {_b(waivers['require_reason'])}",
|
|
805
|
+
f"max_age_days = {waivers['max_age_days']}",
|
|
806
|
+
]
|
|
807
|
+
hooks = policy.get("hooks") or {}
|
|
808
|
+
hook_lines = []
|
|
809
|
+
for key in _HOOK_COMMAND_KEYS:
|
|
810
|
+
if hooks.get(key):
|
|
811
|
+
escaped = str(hooks[key]).replace("\\", "\\\\") \
|
|
812
|
+
.replace('"', '\\"')
|
|
813
|
+
hook_lines.append(f'{key} = "{escaped}"')
|
|
814
|
+
if hooks.get("timeout") is not None:
|
|
815
|
+
hook_lines.append(f"timeout = {hooks['timeout']}")
|
|
816
|
+
if hook_lines:
|
|
817
|
+
# Rendered only when a hook is actually configured, so
|
|
818
|
+
# a hookless policy renders exactly as it did before
|
|
819
|
+
# Epic L3.
|
|
820
|
+
lines += ["", "[hooks]"] + hook_lines
|
|
821
|
+
risk = policy.get("risk") or {}
|
|
822
|
+
if risk.get("enabled"):
|
|
823
|
+
# Rendered only when risk scoring is enabled, so a
|
|
824
|
+
# riskless policy renders exactly as it did before
|
|
825
|
+
# Epic E1 (same rule as [hooks] above).
|
|
826
|
+
medium = risk.get("medium_threshold")
|
|
827
|
+
if medium is None:
|
|
828
|
+
medium = _RISK_DEFAULT_MEDIUM_THRESHOLD
|
|
829
|
+
high = risk.get("high_threshold")
|
|
830
|
+
if high is None:
|
|
831
|
+
high = _RISK_DEFAULT_HIGH_THRESHOLD
|
|
832
|
+
lines += ["", "[risk]", "enabled = true",
|
|
833
|
+
f"medium_threshold = {medium}",
|
|
834
|
+
f"high_threshold = {high}"]
|
|
835
|
+
if risk.get("tiering"):
|
|
836
|
+
# Rendered only when tiering is on, so a policy
|
|
837
|
+
# without it renders exactly as it did before
|
|
838
|
+
# Epic E2 (same rule as the [risk] block itself).
|
|
839
|
+
lines += [
|
|
840
|
+
"tiering = true",
|
|
841
|
+
"prescreen_seats = "
|
|
842
|
+
+ _lst(risk.get("prescreen_seats") or []),
|
|
843
|
+
"medium_seats = "
|
|
844
|
+
+ _lst(risk.get("medium_seats") or []),
|
|
845
|
+
]
|
|
846
|
+
diversity = policy.get("diversity") or {}
|
|
847
|
+
if diversity.get("min_families") \
|
|
848
|
+
or diversity.get("exclude_builder_family") \
|
|
849
|
+
or diversity.get("enforce") not in (None, "degrade"):
|
|
850
|
+
# Rendered only when diversity enforcement is actually
|
|
851
|
+
# configured, so a policy without it renders exactly as
|
|
852
|
+
# it did before Epic E3.
|
|
853
|
+
lines += [
|
|
854
|
+
"",
|
|
855
|
+
"[diversity]",
|
|
856
|
+
f"min_families = {diversity.get('min_families') or 0}",
|
|
857
|
+
"exclude_builder_family = "
|
|
858
|
+
+ _b(bool(diversity.get("exclude_builder_family"))),
|
|
859
|
+
f"enforce = \"{diversity.get('enforce') or 'degrade'}\"",
|
|
860
|
+
]
|
|
861
|
+
return "\n".join(lines)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
# ----------------------------------------------------------------
|
|
865
|
+
# Evaluation (B2): the gate's view of a finished review round.
|
|
866
|
+
# Pure and deterministic: given the policy and the round's facts
|
|
867
|
+
# (changed files, reviewer reports, test results, recorded
|
|
868
|
+
# waivers), produce the rules that applied and the violations.
|
|
869
|
+
# `path_matches` is the engine's matcher (core.path_matches),
|
|
870
|
+
# passed in so path semantics live in exactly one place.
|
|
871
|
+
|
|
872
|
+
def _waiver_date(waiver):
|
|
873
|
+
"""The waiver's recorded date as a datetime.date, or None.
|
|
874
|
+
Accepts 'date', 'waived_at', or 'created_at' (ISO date or
|
|
875
|
+
datetime)."""
|
|
876
|
+
for key in ("date", "waived_at", "created_at"):
|
|
877
|
+
value = waiver.get(key)
|
|
878
|
+
if not value:
|
|
879
|
+
continue
|
|
880
|
+
text = str(value).strip()
|
|
881
|
+
try:
|
|
882
|
+
return datetime.date.fromisoformat(text[:10])
|
|
883
|
+
except ValueError:
|
|
884
|
+
continue
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def evaluate_policy(policy, *, files, reviewer_reports,
|
|
889
|
+
test_results, waivers=(), path_matches,
|
|
890
|
+
today=None):
|
|
891
|
+
"""-> {"applied": [...], "violations": [...]}.
|
|
892
|
+
|
|
893
|
+
Each applied entry: {"rule", "requirement", "status"} with
|
|
894
|
+
status "satisfied" or "violated". Each violation: {"rule",
|
|
895
|
+
"detail", "enforce"} — "block" violations are gate-changing
|
|
896
|
+
(the caller turns a PASS into BLOCKED); "advisory" ones are
|
|
897
|
+
recorded only. A rule is 'applied' only when the policy
|
|
898
|
+
actually configures it (non-default) and, for path rules,
|
|
899
|
+
when the change touches a matching path."""
|
|
900
|
+
applied, violations = [], []
|
|
901
|
+
|
|
902
|
+
def check(rule, requirement, ok, detail, enforce="block"):
|
|
903
|
+
applied.append({"rule": rule, "requirement": requirement,
|
|
904
|
+
"status": "satisfied" if ok else "violated"})
|
|
905
|
+
if not ok:
|
|
906
|
+
violations.append({"rule": rule, "detail": detail,
|
|
907
|
+
"enforce": enforce})
|
|
908
|
+
|
|
909
|
+
reports = reviewer_reports or []
|
|
910
|
+
ok_reports = [r for r in reports if r.get("status") == "ok"]
|
|
911
|
+
ok_seats = sorted({r.get("provider") for r in ok_reports
|
|
912
|
+
if r.get("provider")})
|
|
913
|
+
all_seats = sorted({r.get("provider") for r in reports
|
|
914
|
+
if r.get("provider")})
|
|
915
|
+
families = sorted({r.get("family") for r in ok_reports
|
|
916
|
+
if r.get("family")
|
|
917
|
+
not in (None, "", "unknown")})
|
|
918
|
+
lenses_ok = {r.get("lens") for r in ok_reports if r.get("lens")}
|
|
919
|
+
tests = test_results or []
|
|
920
|
+
tests_ran = len(tests) > 0
|
|
921
|
+
tests_failed = [t for t in tests if not t.get("ok")]
|
|
922
|
+
tests_ok = tests_ran and not tests_failed
|
|
923
|
+
|
|
924
|
+
def tests_detail(verb):
|
|
925
|
+
if not tests_ran:
|
|
926
|
+
return f"policy requires {verb} but no test command ran"
|
|
927
|
+
return (f"policy requires {verb} but "
|
|
928
|
+
f"{len(tests_failed)} of {len(tests)} test "
|
|
929
|
+
f"command(s) failed")
|
|
930
|
+
|
|
931
|
+
panel = policy["panel"]
|
|
932
|
+
if panel["min_seats"]:
|
|
933
|
+
check("panel.min_seats",
|
|
934
|
+
f"at least {panel['min_seats']} successful seat(s)",
|
|
935
|
+
len(ok_seats) >= panel["min_seats"],
|
|
936
|
+
f"policy requires at least {panel['min_seats']} "
|
|
937
|
+
f"successful seat(s); {len(ok_seats)} served "
|
|
938
|
+
f"({', '.join(ok_seats) or 'none'})")
|
|
939
|
+
if panel["min_families"]:
|
|
940
|
+
check("panel.min_families",
|
|
941
|
+
f"at least {panel['min_families']} distinct vendor "
|
|
942
|
+
f"families",
|
|
943
|
+
len(families) >= panel["min_families"],
|
|
944
|
+
f"policy requires at least {panel['min_families']} "
|
|
945
|
+
f"distinct vendor families among successful seats; "
|
|
946
|
+
f"saw {len(families)} ({', '.join(families) or 'none'})")
|
|
947
|
+
for fam in panel["required_families"]:
|
|
948
|
+
check("panel.required_families",
|
|
949
|
+
f"family {fam!r} serves successfully",
|
|
950
|
+
fam in families,
|
|
951
|
+
f"policy requires vendor family {fam!r} on the "
|
|
952
|
+
f"panel; it did not serve successfully (served: "
|
|
953
|
+
f"{', '.join(families) or 'none'})")
|
|
954
|
+
for lens in panel["require_lenses"]:
|
|
955
|
+
check("panel.require_lenses",
|
|
956
|
+
f"lens {lens!r} covered by a successful seat",
|
|
957
|
+
lens in lenses_ok,
|
|
958
|
+
f"policy requires a successful {lens!r} reviewer; "
|
|
959
|
+
f"none served")
|
|
960
|
+
|
|
961
|
+
evidence = policy["evidence"]
|
|
962
|
+
if evidence["require_tests"]:
|
|
963
|
+
check("evidence.require_tests",
|
|
964
|
+
"test results recorded",
|
|
965
|
+
tests_ran,
|
|
966
|
+
"policy requires test evidence but no test command "
|
|
967
|
+
"ran")
|
|
968
|
+
if evidence["require_tests_pass"]:
|
|
969
|
+
check("evidence.require_tests_pass",
|
|
970
|
+
"all tests pass",
|
|
971
|
+
tests_ok,
|
|
972
|
+
tests_detail("passing tests"))
|
|
973
|
+
|
|
974
|
+
changed = list(files or [])
|
|
975
|
+
for rule in policy["paths"]:
|
|
976
|
+
matched = [f for f in changed
|
|
977
|
+
if path_matches(f, [rule["match"]])]
|
|
978
|
+
if not matched:
|
|
979
|
+
continue
|
|
980
|
+
scope = f"paths[{rule['match']}]"
|
|
981
|
+
where = (f"change touches {len(matched)} path(s) matching "
|
|
982
|
+
f"{rule['match']!r}")
|
|
983
|
+
enforce = rule["enforce"]
|
|
984
|
+
if rule["require_full_panel"]:
|
|
985
|
+
missing = [s for s in all_seats if s not in ok_seats]
|
|
986
|
+
check(f"{scope}.require_full_panel",
|
|
987
|
+
"every configured seat serves successfully",
|
|
988
|
+
bool(all_seats) and not missing,
|
|
989
|
+
f"{where}: the full panel is required but "
|
|
990
|
+
f"seat(s) {', '.join(missing) or 'none configured'} "
|
|
991
|
+
f"did not serve successfully",
|
|
992
|
+
enforce)
|
|
993
|
+
if rule["min_seats"]:
|
|
994
|
+
check(f"{scope}.min_seats",
|
|
995
|
+
f"at least {rule['min_seats']} successful seat(s)",
|
|
996
|
+
len(ok_seats) >= rule["min_seats"],
|
|
997
|
+
f"{where}: at least {rule['min_seats']} "
|
|
998
|
+
f"successful seat(s) required; {len(ok_seats)} "
|
|
999
|
+
f"served", enforce)
|
|
1000
|
+
if rule["min_families"]:
|
|
1001
|
+
check(f"{scope}.min_families",
|
|
1002
|
+
f"at least {rule['min_families']} distinct "
|
|
1003
|
+
f"vendor families",
|
|
1004
|
+
len(families) >= rule["min_families"],
|
|
1005
|
+
f"{where}: at least {rule['min_families']} "
|
|
1006
|
+
f"distinct vendor families required; saw "
|
|
1007
|
+
f"{len(families)}", enforce)
|
|
1008
|
+
if rule["require_tests"]:
|
|
1009
|
+
check(f"{scope}.require_tests",
|
|
1010
|
+
"test results recorded",
|
|
1011
|
+
tests_ran,
|
|
1012
|
+
f"{where}: test evidence is required but no "
|
|
1013
|
+
f"test command ran", enforce)
|
|
1014
|
+
if rule["require_tests_pass"]:
|
|
1015
|
+
check(f"{scope}.require_tests_pass",
|
|
1016
|
+
"all tests pass",
|
|
1017
|
+
tests_ok,
|
|
1018
|
+
f"{where}: " + tests_detail("passing tests"),
|
|
1019
|
+
enforce)
|
|
1020
|
+
|
|
1021
|
+
waiver_rules = policy["waivers"]
|
|
1022
|
+
waiver_list = list(waivers or [])
|
|
1023
|
+
if today is None:
|
|
1024
|
+
today = datetime.datetime.now(
|
|
1025
|
+
datetime.timezone.utc).date()
|
|
1026
|
+
for i, waiver in enumerate(waiver_list):
|
|
1027
|
+
if not isinstance(waiver, dict):
|
|
1028
|
+
violations.append({
|
|
1029
|
+
"rule": "waivers.shape",
|
|
1030
|
+
"detail": f"waiver #{i + 1} is not a record "
|
|
1031
|
+
f"(a waiver must name its finding, "
|
|
1032
|
+
f"reason, and date)",
|
|
1033
|
+
"enforce": "block"})
|
|
1034
|
+
continue
|
|
1035
|
+
fid = waiver.get("finding_id") or waiver.get("id") \
|
|
1036
|
+
or f"#{i + 1}"
|
|
1037
|
+
if not waiver_rules["allow"]:
|
|
1038
|
+
check("waivers.allow",
|
|
1039
|
+
"no waivers recorded",
|
|
1040
|
+
False,
|
|
1041
|
+
f"policy does not permit waivers, but finding "
|
|
1042
|
+
f"{fid} was waived")
|
|
1043
|
+
if waiver_rules["require_reason"]:
|
|
1044
|
+
check("waivers.require_reason",
|
|
1045
|
+
f"waiver for {fid} records a reason",
|
|
1046
|
+
bool(str(waiver.get("reason") or "").strip()),
|
|
1047
|
+
f"waiver for finding {fid} records no reason; "
|
|
1048
|
+
f"policy requires one")
|
|
1049
|
+
if waiver_rules["max_age_days"]:
|
|
1050
|
+
wdate = _waiver_date(waiver)
|
|
1051
|
+
if wdate is None:
|
|
1052
|
+
check("waivers.max_age_days",
|
|
1053
|
+
f"waiver for {fid} is at most "
|
|
1054
|
+
f"{waiver_rules['max_age_days']} day(s) old",
|
|
1055
|
+
False,
|
|
1056
|
+
f"waiver for finding {fid} carries no "
|
|
1057
|
+
f"usable date; policy expires waivers after "
|
|
1058
|
+
f"{waiver_rules['max_age_days']} day(s) and "
|
|
1059
|
+
f"an undated waiver cannot prove freshness")
|
|
1060
|
+
else:
|
|
1061
|
+
age = (today - wdate).days
|
|
1062
|
+
check("waivers.max_age_days",
|
|
1063
|
+
f"waiver for {fid} is at most "
|
|
1064
|
+
f"{waiver_rules['max_age_days']} day(s) old",
|
|
1065
|
+
age <= waiver_rules["max_age_days"],
|
|
1066
|
+
f"waiver for finding {fid} is {age} day(s) "
|
|
1067
|
+
f"old; policy expires waivers after "
|
|
1068
|
+
f"{waiver_rules['max_age_days']} day(s)")
|
|
1069
|
+
if age < -1:
|
|
1070
|
+
# A waiver dated in the future passes the
|
|
1071
|
+
# age check trivially (a negative age), so a
|
|
1072
|
+
# fat-fingered or fraudulent future date
|
|
1073
|
+
# waived findings forever (P4-10). One day
|
|
1074
|
+
# of slack absorbs the timezone skew
|
|
1075
|
+
# _waiver_date's date-only truncation
|
|
1076
|
+
# creates (a +14:00 timestamp reads as
|
|
1077
|
+
# tomorrow).
|
|
1078
|
+
check("waivers.max_age_days",
|
|
1079
|
+
f"waiver for {fid} is not dated in "
|
|
1080
|
+
f"the future",
|
|
1081
|
+
False,
|
|
1082
|
+
f"waiver for finding {fid} is dated "
|
|
1083
|
+
f"{wdate} — in the future; a waiver "
|
|
1084
|
+
f"cannot predate the finding it waives")
|
|
1085
|
+
|
|
1086
|
+
return {"applied": applied, "violations": violations}
|