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/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}