Skip to content

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

Open
wshallwshall wants to merge 2 commits into
mainfrom
claude/asvs-promote-writer
Open

feat(asvs): promote the scorecard writer to scripts/asvs/apply.py, with tests#298
wshallwshall wants to merge 2 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
#: writing one into a security record where a later reader would copy the vocabulary forward.
_BANNED = re.compile(
"["
"\u26a0\u26d4\u2705\u2b50\u274c\u2714\u2716\u2717\u2718" # warning, no-entry, check, star, crosses
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
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