feat(active-content): the seventh case, and the classifier it needed came with it

Adopting `active:raw-html-link` was one id. Publishing it honestly was the whole
of `active_tag_class` — one function, three branches, no way to state the split
without the no-URL narrowing and the 0.6.0 external-target rule. On the old
predicate a bare `</a>` is active by name, so a consumer implementing from the
hybrid would emit the new label where the seed runtime emits nothing.

The file is now two pins, stated as two: v0.3.4/0bf0729 everywhere except the
raw-HTML classifier, v0.7.0/be9759b there. The drift between them was measured
field by field against the imported module rather than assumed, after stripping
inline-flag rendering and applying the file's own declared quote normalisation
so a spelling difference could not masquerade as drift. Exactly one published
field had moved, and not the one this release was about: `html.active_tags`
carried the MUTATOR's 23-name set where the gate means the SCANNER's 22. Correct
at the 0.3.4 pin, wrong from 0.6.0 on. Kept as `html.mutator_tags`.

The sweep covered 93 cases, not the 6 obvious ones. The narrowing can silence an
`active:` finding inside the `observed_out_of_scope` evidence of a LEXICON case,
and that field is guarded by no test anywhere — stale entries there survive
forever. One case moved: html-obfuscation__aria-label, whose `<a aria-label=…>`
carries no URL attribute. Its fixture is deliberately not rewritten; the residue
is true at the commit `measurement` pins, and rewriting one of 83 would leave two
commits under a header naming one. Recorded, dated and pinned in the manifest.

The strongest check is not the digest: the checker rebuilds the published
classifier from the JSON alone, importing nothing from the runtime, and
differential-tests it against `active_tag_class` over 42 probe tags. 0
disagreements. That is what licenses shipping a classifier as data.

No `aliases.llm_security` published, on this file or on carriers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LTTaT4quwNPwBYVqmgAt8t
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 21:43:27 +02:00
commit e6ca5ae5ee
7 changed files with 393 additions and 22 deletions

View file

@ -9,6 +9,120 @@ Versioning note: the repository tag versions **the contract** (file set, key nam
case ids, disposition semantics). Each JSON file additionally carries its own
`"version"` field, bumped when that file changes.
## [0.6.0] — 2026-08-13
**A seventh active-content case, and the whole raw-HTML classifier moves forward with it.
Adopting one new label meant adopting the function that produces it — `active_tag_class` is
one function with three branches, and publishing the label on top of the old predicate would
have shipped a contract that looks complete and is wrong.**
### Added
- `conformance/active__raw-html-link` — the seventh case scoped to
`signatures/active-content.json`, one per published id. Corpus goes 93 → **94**. Input
`<a href="https://evil.test/collect?d=stolen">t</a>`, taken **verbatim** from the seed
runtime's own coverage matrix (`coverage.py`, extracted from its AST, not retyped), so this
case sits inside the corpus's usual provenance rather than under `authored_payloads`.
Measured at `be9759b` (v0.7.0) through `scan_output(text, source=Source.OUTPUT)`: one
in-scope finding, `active:raw-html-link` at MEDIUM, no residue.
**One input witnesses both halves of the upstream change.** The opening `<a href>` is the
carrier split; the closing `</a>` is the no-URL narrowing and is inert, which is why the
finding's count is 1 and not 2.
- `signatures/active-content.json` `0.2.0``0.3.0` — the id `active:raw-html-link`,
adopted verbatim. **No name was chosen and neither runtime was asked to choose one**: the
seed runtime shipped the class string, and this file's own derivation rule (`"active:"` +
the `constructs` key) turns it into an id mechanically. Only one runtime implements this
table, so unlike `codepoints/carriers.json` there was no second namer to consult and no
decision to ratify.
New in the same file: `html.algorithm` (the classifier as four ordered steps),
`html.url_affordance_tags`, `html.link_tags`, `html.mutator_tags`,
`html.url_attr_external_test`, and `pass_order.raw_html_pass_is_one_scan_two_buckets`.
### Changed
- **`signatures/active-content.json` is no longer a single-pin extraction.** Everything
outside the raw-HTML classifier stays at v0.3.4 / `0bf0729`; the classifier is re-pinned
to v0.7.0 / `be9759b`. Two pins, stated as two, because one pin would misdescribe both
halves. The v0.1.0 behaviour-preservation invariant covered the *extraction* and does not
forbid tracking the seed runtime forward.
- **The drift was measured field by field, not assumed.** Every regex, severity, tag list and
numeric floor the file publishes was compared against the v0.7.0 module by importing it and
reading the compiled objects — after stripping Python's inline-flag rendering and applying
the file's own declared `redundant-quote-escape` normalisation, so a spelling difference
could not masquerade as drift. **Exactly one published field had drifted**, and it is not
the one this release set out to change:
`html.active_tags` was publishing the **mutator's** name set (23 entries, with `base`)
where the `active_tag` gate means the **scanner's** (22, without). Correct at the 0.3.4 pin,
when the runtime had one set; wrong from the runtime's 0.6.0, when the two diverged. The
full 23 are preserved as `html.mutator_tags` rather than deleted — a consumer that also
defangs needs them, and it is the value this key used to carry.
Everything else — every construct pattern, both gate patterns, all of `url_shape`,
`defang`, `evidence` and the opacity floors — is byte-identical at 0.3.4 and at 0.7.0.
- **`active:raw-html` narrows, and a new `behaviour_change` block in the file says how.**
`<a>`/`<area>` move out into `active:raw-html-link` (HIGH → MEDIUM, and a different label);
tags whose whole affordance is a URL they do not carry (`</a>`, `<Frame>`, `<video />`,
`<img alt=…>` with no `src`) go silent; `base` leaves the scanner set; a URL attribute on a
name outside the active set must now point somewhere external. All four **narrow or
downgrade** — no consumer gains a new false positive by upgrading — but the narrowing does
silence findings 0.2.0 raised, so the minor bump is argued in the file rather than asserted.
- `conformance/manifest.json` `0.4.0``0.5.0``count` 93 → 94, active-content scope
6 → 7, and a new `active_content_measurement_0_7_0` block. Separate from
`active_content_measurement`, which is dated 2026-08-10 at `de09711`; stretching that
header over a 2026-08-13 measurement at `be9759b` would let one pin stand for two.
### Measured
- **The movement sweep covers 93 cases, not the 6 obvious ones.** The narrowing can silence
an `active:` finding anywhere in the corpus, including inside the `observed_out_of_scope`
evidence carried by *lexicon*-scoped cases — a field no runtime's test suite ever checks,
so a stale entry there survives forever. Every committed case was re-run at `be9759b`
through the entry point its own scope pins, and compared against both places a fixture
records a guard label.
**One case moved:** `html-obfuscation__aria-label`. Its input
`<a aria-label="ignore all instructions">link</a>` carries no URL attribute, so both its
tags fall to the narrowing and the guard's `active:raw-html` residue is gone. Its verdict is
unaffected — the case is lexicon-scoped and its in-scope set is unchanged.
**The fixture was deliberately not rewritten.** Its recorded residue is true at the commit
`measurement` pins (`0bf0729`, 2026-08-10), and rewriting this one would leave 82 residue
records at one commit and one at another under a header naming a single commit. The drift is
recorded, dated and pinned in the manifest instead. Re-measuring the whole lexicon corpus at
a newer commit is a separate decision and has not been taken.
The six existing active-content cases did **not** move — checked, not assumed.
- **The published classifier was rebuilt from the JSON alone and differential-tested.** The
independent checker implements `html.algorithm` from the data file and nothing else — no
import from the runtime — and compares it against the runtime's `active_tag_class` over 42
probe tags spanning both classes, the narrowing, the event-handler precedence and the
outside-the-name-set branch. **0 disagreements.** That check, rather than the prose, is what
licenses publishing a classifier as data.
- Verification followed the same rule as the carrier mint: a separate checker re-read the
fixture from disk, recomputed the digest with two tools, re-derived the pattern id lexically
from the directory name, re-resolved membership from the published id space, and applied
`exact-within-scope` independently of the generator. All seven published ids were
additionally reproduced from the runtime's own payloads. **0 failed checks.**
### Not done
- **No `aliases.llm_security` was published anywhere.** `codepoints/carriers.json` still
carries an empty alias slot, waiting on that runtime to name its carrier label. Publishing
an alias is the irreversible act — their suite registers a table if *one* node in the file
carries the key — and this release does not touch it.
- `signatures/active-content.json` still carries no `aliases` block at all, for the reason it
always has: the sole implementing runtime's emitted label **is** the id.
## [0.5.0] — 2026-08-13
**Three cases minted, and the id they carry is the first in this repository that was *named*