Skip to content

feat(asvs): promote the scorecard writer to scripts/asvs/apply.py, with tests - #298

Merged
wshallwshall merged 7 commits into
mainfrom
claude/asvs-promote-writer
Aug 9, 2026
Merged

feat(asvs): promote the scorecard writer to scripts/asvs/apply.py, with tests#298
wshallwshall merged 7 commits into
mainfrom
claude/asvs-promote-writer

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

P0 of the ASVS tracking rework, per the design on the parallel session's branch.

The only tool that writes the record of record lived at docs/security/asvs-apply-cells.py in the vault: hardcoded absolute path, no argparse, zero tests, zero references, outside the CI path filter.

The old location was worse than untested

ci.yml's docs-only detector treats ^docs/ as non-code. So a PR touching only the writer set code=false and skipped install, lint, type-check and the entire pytest suite. The tool that can silently un-close an owner-closed cell was classified as documentation. Verified by running the workflow's own regex:

path classification
docs/security/asvs-apply-cells.py DOCS-ONLY — suite skipped
scripts/asvs/apply.py CODE — full suite runs

The hardcoded path pointed at the shared vault checkout that several sessions edit at once, so running it from a worktree rewrote a record the operator wasn't looking at. I hit that this session and worked around it with a patched copy. --scorecard is now required with no default — the one thing a writer must never guess is which record it is rewriting.

Both shipped invariants kept verbatim, and now proved

  • The non-allowlist. render() enumerates only what it orders; every other key survives by default. Test: a payload omitting decision_closed/decision_closed_by leaves both intact. That's the 7818991d incident, where an allowlist silently un-closed two owner-closed cells while every gate stayed green — an absent decision_closed is a valid False.
  • The set(was) - set(now) backstop, mutation-proved. render is replaced with one that drops the decision_* keys, reproducing the historical defect in the one function that could reintroduce it. The write must be refused, and the test asserts the refusal names those keys — a non-zero exit isn't evidence on its own, since several guards return 1 and a proof that trips an unrelated one proves nothing.

Also proved to fire: anchor_repair byte-identity on prose and verdict, the owner-closed rescore refusal, the glyph fail-closed check (which fired for real on 13.3.4 this session), unknown-cell refusal, dry-run-by-default.

Two mypy errors were sitting in this file and are fixed — it had never been type-checked, for the reason above.

Not in scope: deleting the vault copy. That's the two-repo consolidation and it needs the mirror (#297) settled first.

…h tests

P0 of the ASVS tracking rework. The only tool that WRITES the record of record
lived at docs/security/asvs-apply-cells.py in the vault: hardcoded absolute path,
no argparse, zero tests, zero references, and outside the CI path filter.

THE OLD LOCATION WAS WORSE THAN UNTESTED. ci.yml's docs-only detector treats
^docs/ as non-code, so a PR touching ONLY the writer set code=false and skipped
install, lint, type-check and the entire pytest suite. The tool that can silently
un-close an owner-closed cell was classified as documentation. Verified by
running the workflow's own regex against all three paths.

The hardcoded path pointed at the SHARED vault checkout, which several sessions
edit at once, so running it from a worktree rewrote a record the operator was not
looking at -- I hit that this session and worked around it with a patched copy.
--scorecard is now REQUIRED with no default: the one thing a writer must never
guess is which record it is rewriting.

BOTH SHIPPED INVARIANTS KEPT VERBATIM, as directed, and now proved rather than
asserted:

- The non-allowlist. render() enumerates only what it ORDERS; every other key on
  the live cell survives by default. Test: a payload omitting decision_closed and
  decision_closed_by leaves both intact. That is the 7818991d incident, where an
  ALLOWLIST silently un-closed two owner-closed cells while every gate stayed
  green, because an absent decision_closed is a valid False.
- The set(was) - set(now) backstop, MUTATION-PROVED: render is replaced with one
  that drops the decision_* keys, reproducing the historical defect in the one
  function that could reintroduce it, and the write must be refused. The test
  asserts the refusal names those keys, because a non-zero exit is not evidence --
  several guards return 1, and a mutation proof that trips an unrelated one proves
  nothing about the invariant it claims to test.

Also proved to fire: anchor_repair byte-identity on prose and verdict, the
owner-closed rescore refusal, the glyph fail-closed check (which fired for real on
13.3.4 this session), unknown-cell refusal, and dry-run-by-default.

Two mypy errors were sitting in this file and are fixed here -- it had never been
type-checked, because of the path filter above.

Not in scope: deleting the vault copy. That is the two-repo consolidation and it
needs the mirror settled first.
Comment thread scripts/asvs/apply.py Fixed
The one refusal in this writer against a WELL-FORMED payload. Every other guard
rejects malformed input; this rejects input that is valid and means more than its
author intended -- a verdict moving during a pass whose stated purpose was
mechanical (an anchor repair, a re-render, a bulk transform). That is this
writer's whole failure mode, so the safe thing is now the default and the
dangerous thing is explicit: --allow-verdict-change.

The refusal names the cell and BOTH verdicts, per review. A refusal that says
only 'verdict changed' leaves the operator's actual next question -- which cell,
and to what -- unanswered, and an unanswerable refusal gets re-run with the
override reflexively, which converts the guard into a speed bump.

BOTH HALVES TESTED, because the first commit of this guard passed all 11 existing
tests while nothing exercised it. Adding a guard no test drives is how a guard
that cannot fire ships looking green. So: the move is refused and the message
names 1.1.1 and 'partial' -> 'pass'; AND the flag actually lifts it. Without the
second, the flag could be misspelled, unwired or shadowed and the refusal test
would still pass.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 9, 2026 08:39
… range

CodeQL flagged the class as an overly permissive range: it analyses the character class in
UTF-16, where 1f000-1f2ff and 1f300-1faff both decompose to ranges sharing a high surrogate,
and reads that as an overlap.

The two ranges were adjacent (0x1F2FF + 1 == 0x1F300), so their union is exactly 1f000-1faff.
Proved rather than asserted: compared old against new over every one of the 1,112,064
non-surrogate codepoints -- zero behavioural differences, both matching the same 2,940 -- with
the comparison itself guarded against passing degenerately.

The seam is now a test. A later edit that re-splits the range and mistypes a bound, or
truncates it, leaves a hole exactly where the two halves used to meet, and a hole in a
FAIL-CLOSED guard goes red nowhere: nothing fails, a glyph simply starts getting written into
the security record. Mutation-proved in both directions -- truncating the range trips the
inside-bounds assertion, widening it trips the outside-bounds one.

The first mutation run was a false green. A heredoc collapsed the double backslash, so the
search string became the literal U+1F000 CHARACTER while the source carries the escape
SEQUENCE; the replace matched nothing, the file was never mutated, and the test "passed" over
unmodified code. The harness now refuses a no-op replacement and prints the before/after line,
because a mutation test that cannot prove its mutation landed is not evidence.
@wshallwshall
wshallwshall merged commit 639316d into main Aug 9, 2026
34 checks passed
@wshallwshall
wshallwshall deleted the claude/asvs-promote-writer branch August 9, 2026 23:34
wshallwshall added a commit that referenced this pull request Aug 10, 2026
… as the engine (#309)

test_connscale_smoke_end_to_end red on #298 with fd_count_monotonic:
"fixed_per_conn@N=24: 344 < prior 3.56e+04 * 0.75". The suspect number is the PRIOR.

Calibrated against this repo's own published at-scale run: ~3.0 handles/connection over an
~833 base (2333 @ N=500, 3835 @ N=1000, 5335 @ N=1500), so N=12 predicts ~870. The observed
344 at N=24 is in band. 35,600 at N=12 is 6.7x the reading for a FIFTEEN HUNDRED connection
engine, and no leak produces both minutes apart in one sweep from two separately-spawned
engines.

Mechanism: _walk_descendants validates nothing -- no creation time, no image name, no
cardinality bound. Windows keeps a stale ParentProcessId after the parent exits and recycles
PIDs, so any live process pointing at the engine's recycled PID is adopted with its whole
subtree. Measured on this box, walking one such stale ppid summed 144,688 handles. And in the
connscale smoke the engine spawns NO children, so every descendant the walk finds is by
construction not the engine.

max() then latches the poisoned tick permanently -- a mean would have diluted it.

My first reading was "handles has no PID-set gate like CPU does". An independent pass
sharpened it and corrected the remedy: copying #220's gate would be WRONG. Differencing sums
over different PID sets is arithmetically invalid, but an INSTANTANEOUS gauge over a genuinely
larger subtree is correct. The gap is that the number carries no record of the set it covered.
Validating the walk (reject a descendant older than its root) is the fix; gating the aggregate
is not.

Also recorded: #220's gate is a change-detector, not a binding check -- a first-and-only
resolution that adopts a wrong-but-stable subtree passes it. And the test that shipped #220's
fix asserts this exposure as correct, on a fixture that is physically unrealizable: it varies
cpu_pids while pinning handles=61, though the Windows probe reads both from the same
Get-Process rows.

Diagnosis only. The fix shape is proposed and ranked but not applied.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants