Skip to content

Commit 118048b

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/ci-margin-correction-a1007c
2 parents f566745 + a05103f commit 118048b

4 files changed

Lines changed: 332 additions & 12 deletions

File tree

‎pyproject.toml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,18 @@ webauthn = ["webauthn>=3.0.0,<4"]
170170
# HashiCorp Vault KeyProvider (ADR 0019 §3, BACKLOG #196): envelope-decrypt the store DEK via Vault
171171
# Transit. `hvac` is the OFFICIAL HashiCorp Vault Python client (Apache-2.0), named in ADR 0019 §3's
172172
# provider table. Lazy-imported (store/keyprovider_vault.py), so installs that never select
173-
# `[store].key_provider=vault` skip it and the base install still pulls ZERO Vault SDK. Floor >=2.3.0:
174-
# the current 2.x line (Python 3.8+, requests-based); a bare `>=` would silently jump a major on re-lock,
175-
# so keep the floor pinned to the 2.x series. Net-new transitives (dep-vet 2026-07-10): requests + urllib3
173+
# `[store].key_provider=vault` skip it and the base install still pulls ZERO Vault SDK. Bounded
174+
# >=2.3.0,<3: the current 2.x line (Python 3.8+, requests-based). The cap is the half that was missing —
175+
# the prose already said "a bare `>=` would silently jump a major on re-lock", which is exactly what
176+
# `hvac>=2.3.0` was, so the intent was documented and unenforced. hvac fronts the store DEK (ADR 0019
177+
# §3), and CI never installs the [vault] extra, so a major arriving through a re-lock would reach a
178+
# release without a single test exercising it. Deliberately NOT mirrored by a Dependabot ignore entry:
179+
# auto-merge already routes majors to manual review, and an ignore would suppress hvac's security track
180+
# for no gain. Net-new transitives (dep-vet 2026-07-10): requests + urllib3
176181
# (both ubiquitous, mature; the lock resolves urllib3>=2.7.0 — CVE-2025-50181/50182 SSRF-redirect fixes)
177182
# plus charset-normalizer/idna/certifi. hvac ships NO type stubs — mypy-strict containment lives inside
178183
# store/keyprovider_vault.py (a targeted typed local), never a repo-wide ignore.
179-
vault = ["hvac>=2.3.0"]
184+
vault = ["hvac>=2.3.0,<3"]
180185
# The browser ops console ([api].serve_ui, ADR 0065) is a separately-versioned second wheel
181186
# (messagefoundry-webconsole, in packaging/messagefoundry-webconsole/) mounted same-origin in-process via
182187
# mount_ui. It is deliberately NOT declared as a [webconsole] extra yet: the wheel isn't published to an

‎scripts/asvs/scorecard.py‎

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,22 @@ class Absence:
7777
absence claims survived for weeks. So an absence claim is only admissible with a
7878
``positive_control`` that must still match; if the control goes quiet the search has gone blind and
7979
the claim is void, regardless of what the pattern returns.
80+
81+
``mutation`` closes the hole the control does not: it is the realistic REINTRODUCTION this pattern
82+
claims to exclude, and the pattern must actually fire on it. A whole chapter of claims was once
83+
authored as prose narrations of shell commands — ``"rg -n 'tar.extractall' -> exit 1 (zero hits)"``
84+
where the field wanted ``tar\\.extractall\\(``. The field's type is ``str`` and prose is a valid
85+
``str``, so the shape permitted it and only a detector caught it. Requiring the pattern to match a
86+
stated reintroduction makes prose *unwritable* rather than merely detectable.
87+
88+
Do NOT derive ``mutation`` from ``pattern``. A value generated from the thing it validates
89+
satisfies the check by construction, which would make this the most authoritative-looking vacuous
90+
gate in the file — the same defect class it exists to close, arriving through the fix.
8091
"""
8192

8293
pattern: str
8394
positive_control: str
95+
mutation: str
8496

8597

8698
@dataclass(frozen=True)
@@ -171,6 +183,21 @@ def load_scorecard(path: Path) -> list[Cell]:
171183
"recording the reason for non-applicability is the one MUST in ASVS 5.0's assessment "
172184
"chapter (docs/ASVS-ASSESSMENT-METHOD.md §1)"
173185
)
186+
for a in raw.get("absence", []):
187+
if not str(a.get("mutation", "")).strip():
188+
raise ScorecardError(
189+
# NO LITERAL CODE EXAMPLE HERE. This module is inside the corpus that absence
190+
# patterns are searched over, so an illustrative call in this string becomes a
191+
# real corpus hit and reads as FALSE. The first draft used one and broke two
192+
# live claims (5.2.5, 5.3.3) the moment they were backfilled: the guidance for
193+
# a check contaminated the check. The Absence docstring carries the example in
194+
# escaped-regex form, which cannot self-match.
195+
f"cell {raw.get('id')!r}: absence claim {a.get('pattern')!r} has no `mutation` — "
196+
"state the realistic reintroduction this pattern excludes, so the pattern can "
197+
"be proved capable of firing. Author it from what the code would look like if "
198+
"the thing came back; do NOT derive it from the pattern, which makes the check "
199+
"vacuous. See the Absence docstring for a worked example"
200+
)
174201
cells.append(
175202
Cell(
176203
id=str(raw["id"]),
@@ -186,7 +213,13 @@ def load_scorecard(path: Path) -> list[Cell]:
186213
for e in raw.get("evidence", [])
187214
),
188215
absence=tuple(
189-
Absence(pattern=str(a["pattern"]), positive_control=str(a["positive_control"]))
216+
Absence(
217+
pattern=str(a["pattern"]),
218+
positive_control=str(a["positive_control"]),
219+
# No default. A missing mutation must be authored, not inferred — see the
220+
# Absence docstring on why deriving one from the pattern is worse than none.
221+
mutation=str(a["mutation"]),
222+
)
190223
for a in raw.get("absence", [])
191224
),
192225
)
@@ -231,25 +264,65 @@ def check_completeness(cells: list[Cell], corpus: dict[str, int]) -> list[str]:
231264
want = corpus.get(c.id)
232265
if want is not None and c.level != want:
233266
problems.append(f"{c.id}: level {c.level} but the corpus says L{want}")
267+
268+
# A DECIDED verdict with no evidence at all is the conflation this whole tool exists to prevent:
269+
# a guess wearing a verdict's clothes. The method is explicit — every non-`unverified` cell carries
270+
# at least one anchor — but nothing enforced it, because `check_anchors` iterates the evidence a
271+
# cell HAS. A cell with none is not checked and fails nothing; the gate could only ever validate
272+
# evidence that existed, never assert that it must. Measured when this landed: 14 of 59 decided
273+
# cells carried zero anchors AND zero absence claims, several inherited from the prose lineage
274+
# where the verdict was reached but never anchored.
275+
unevidenced = sorted(
276+
(c.id for c in cells if c.verdict in DECIDED_VERDICTS and not c.evidence and not c.absence),
277+
key=_sort_key,
278+
)
279+
if unevidenced:
280+
problems.append(
281+
f"evidence: {len(unevidenced)} decided cell(s) carry NO anchor and NO absence claim, so "
282+
"nothing about them is verified — either anchor them or return them to `unverified`, "
283+
f"which is what an unevidenced verdict actually is: {', '.join(unevidenced)}"
284+
)
234285
return problems
235286

236287

237288
def check_anchors(cells: list[Cell], root: Path, findings: Findings) -> None:
238-
"""Open every evidence anchor and assert its token still resolves.
289+
"""Open every evidence anchor and assert its token still resolves, and resolves UNAMBIGUOUSLY.
239290
240291
When the code moves, this reds a test — instead of the sentence rotting in place and the next
241292
session funding work that is already done.
293+
294+
**Uniqueness is not pedantry; it is what makes the resolution mean anything.** An ``expect`` that
295+
occurs many times in its file resolves from almost anywhere: with ``await conn.rollback()``
296+
appearing 101 times in one module, *any* line number in that file lands within ±40 of some
297+
occurrence, so the anchor cannot fail and certifies nothing. Two such anchors sat in this scorecard
298+
as evidence for weeks.
299+
300+
It also closes a defect in the REPAIR path rather than the detection path. When code moves, the
301+
check correctly reports it — but a re-anchor to the nearest occurrence can silently install a
302+
*stale-but-resolving* anchor that passes forever. That happened live: after ADR 0154 landed,
303+
``UPDATE sessions SET revoked_at=`` had two occurrences 19 lines apart — one the keep-N revoke, one
304+
a different method entirely — each inside the other's window, so the check would have accepted the
305+
wrong one. A repair is exactly where suspicion lapses, because the tool has just proved it works.
242306
"""
243307
for c in cells:
244308
for a in c.evidence:
245309
target = root / a.path
246310
if not target.is_file():
247311
findings.problems.append(f"{c.id}: evidence path {a.path} does not exist")
248312
continue
249-
lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
313+
text = target.read_text(encoding="utf-8", errors="replace")
314+
lines = text.splitlines()
250315
lo = max(0, a.line - 1 - ANCHOR_WINDOW)
251316
hi = min(len(lines), a.line + ANCHOR_WINDOW)
252317
findings.checked_anchors += 1
318+
occurrences = text.count(a.expect)
319+
if occurrences > 1:
320+
findings.problems.append(
321+
f"{c.id}: {a.path}:{a.line} anchor is AMBIGUOUS — {a.expect!r} occurs "
322+
f"{occurrences} times in the file, so the line number is not load-bearing and a "
323+
"re-anchor cannot be checked. Cite a longer token that appears exactly once"
324+
)
325+
continue
253326
if a.expect in "\n".join(lines[lo:hi]):
254327
continue
255328
where = " (found elsewhere in the file)" if a.expect in "\n".join(lines) else ""
@@ -265,6 +338,14 @@ def check_absences(cells: list[Cell], root: Path, findings: Findings) -> None:
265338
for c in cells:
266339
for a in c.absence:
267340
findings.checked_absences += 1
341+
# Before asking what the corpus says, ask whether the pattern is a pattern at all. A prose
342+
# narration greps to nothing and is indistinguishable from a true absence.
343+
if not re.search(a.pattern, a.mutation):
344+
findings.problems.append(
345+
f"{c.id}: absence claim is INERT — {a.pattern!r} does not match its own stated "
346+
f"reintroduction {a.mutation!r}, so it would stay quiet if the thing came back"
347+
)
348+
continue
268349
control = _grep_count(a.positive_control, corpus_files)
269350
if control == 0:
270351
findings.problems.append(
@@ -328,6 +409,17 @@ def check_pinning(scorecard: Path, corpus: Path) -> list[str]:
328409
"pinning: [scorecard].asvs_version is missing — bare requirement ids re-point across "
329410
"ASVS versions, so the scorecard must say which version its ids mean"
330411
)
412+
anchor = str(meta.get("anchor_commit", "")).strip()
413+
# actions/checkout resolves `ref:` as a BRANCH OR TAG name unless it is a full 40-char hash, so an
414+
# abbreviated anchor fails with "A branch or tag with the name ... could not be found" -- a gate
415+
# failing for a reason with nothing to do with what it measures. Caught in CI twice; refused here.
416+
if anchor and not re.fullmatch(r"[0-9a-f]{40}", anchor):
417+
problems.append(
418+
f"pinning: [scorecard].anchor_commit {anchor!r} must be a FULL 40-character SHA -- an "
419+
"abbreviated hash is not resolvable by actions/checkout, which treats a short ref as a "
420+
"branch or tag name"
421+
)
422+
331423
declared = str(meta.get("corpus_sha256", "")).strip()
332424
actual = corpus_digest(corpus)
333425
if not declared:

0 commit comments

Comments
 (0)