llm-security/spec/conformance-corpus.md
Kjell Tore Guttormsen 9b95fc607e Squashed 'scanners/commons/' changes from 0ffee85..532d70d
532d70d release(0.2.0): the contract gained a MUST, so the tag has to move
946f51d fix(active-content,conformance): cite line numbers per commit — they do not resolve at the pin
bdcb1f1 feat(conformance): ship the six active-content cases; the id space already existed
807c0d4 feat(spec): add not-applicable, so a single-runtime table stops reading as 7 defects
a1578e6 fix(conformance): record the guard's internal-surface position on _LEX_PAYLOADS
4d351d2 fix(mapping): state that three of four OWASP maps have no production consumer
f082a91 fix(lexicon,docs): retract the claim that the guard's port cites severity.mjs

git-subtree-dir: scanners/commons
git-subtree-split: 532d70d5ed2f9b23a8efad760ef490356cf52ada
2026-08-11 13:02:33 +02:00

14 KiB

Conformance corpus

Status: normative. This document defines how the files under conformance/ are to be read. Where it disagrees with any informative document in docs/, this wins.

RFC 2119 language is used deliberately: MUST, MUST NOT, SHOULD, MAY.

1. What a case is

A case is a directory under conformance/ holding exactly two files:

conformance/<case-id>/input.txt        the exact bytes to scan
conformance/<case-id>/expected.json    the findings a conforming runtime produces

A runtime claiming conformance MUST run every case. It MUST NOT skip a case because its engine rejects the pattern behind it — a silently skipped case is an invisible false negative, which is the failure mode this corpus exists to catch. A case that cannot be run MUST be reported as an error, not as a pass.

1.1 Declared tables and not-applicable

The runtimes that share this core do not implement the same set of data files, and some never will: a table can be meaningful in an output gate and meaningless in the surface next to it. §1 as stated would make every such case a permanent failure, which reports an architectural difference as a defect and tells a reader nothing.

A runtime claiming conformance therefore MUST declare the set of commons data files it implements, and MUST publish that set alongside its conformance result. A case whose scope (§4) names a data file outside the declared set MUST be reported as not-applicable — a third verdict beside pass and fail, and a different thing from the error of §1. §1's error means this runtime tried and could not; not-applicable means this question was never addressed to this runtime.

A not-applicable case MUST still be enumerated in the result. It MUST NOT be counted as a pass, and MUST NOT be silently dropped from the denominator: 76/83 passed and 76 passed, 7 not-applicable describe different runtimes, and only the second one can be checked.

Two constraints keep the verdict from becoming an exit:

  • A runtime MUST NOT withdraw a table from its declared set in order to convert failing cases into not-applicable ones. The declaration describes what the runtime implements, not what it would prefer to be asked. Narrowing it is a visible change to a published claim, and it MUST be reported as such rather than made quietly between runs.
  • not-applicable attaches to a table, never to a case. A runtime that declares a table MUST run every case scoped to it, including the ones it fails. Per-case opt-out is exactly the silent skip §1 forbids.

What the verdict does not say. not-applicable records that a runtime does not implement a named commons table. It is not a statement that the runtime is blind to the phenomenon behind it — a runtime may well detect the same construct through some other mechanism that this corpus does not constrain, and reading the verdict as a coverage gap would assert something nobody measured. See §8.

2. input.txt is bytes, not text

input.txt holds the input verbatim, UTF-8 encoded. A runtime MUST read it as bytes and MUST NOT strip, trim, normalise or re-encode anything before scanning.

There is no trailing newline. A trailing newline would be part of the input, and no case wants one. Two of the current cases carry characters that are invisible on screen — a zero-width space and a Cyrillic homoglyph — so an editor that "cleans up" a fixture can change what it tests without changing how it looks. For that reason every expected.json records the input's byte length and SHA-256, and a runtime SHOULD verify both before scanning. A digest mismatch means the fixture has been altered; the result of scanning it means nothing.

3. expected.json

{
  "case_id": "hybrid-xss__script-tag",
  "input": { "file": "input.txt", "bytes": 24, "sha256": "…" },
  "scope": ["lexicon/injection-lexicon.json"],
  "match": "exact-within-scope",
  "findings": [{ "pattern_id": "hybrid-xss:script-tag" }],
  "observed_out_of_scope": { "llm_ingestion_guard": ["active:raw-html"] }
}
Field Normative? Meaning
$comment no Provenance prose. Carries no contract.
case_id yes Equals the directory name.
input yes Integrity of the input file. See §2.
scope yes Which commons data files this case constrains. See §4.
match yes How findings is to be compared. See §4.
findings yes The expected findings, named by commons pattern_id. See §3.1.
observed_out_of_scope no — informative What a named runtime was measured emitting outside scope. Evidence, never expectation. See §5.

A finding carries a pattern_id and nothing else. Severity, OWASP anchor and human label are properties of the pattern, published once in lexicon/injection-lexicon.json and looked up there by that id. They MUST NOT be restated in a fixture: a value written in two places is a value that will eventually disagree with itself.

3.1 pattern_id is not the finding id

The field is called pattern_id and not id because this repository already publishes a different id, and confusing the two produces a runtime that fails every case for a reason unrelated to detection.

Identifies Stable?
pattern_id — here; families[].patterns[].id in the lexicon the detection rule Yes. A stable cross-runtime contract, ratified by both seeding runtimes.
id — in schema/finding.schema.json one emitted finding in one run No. DS-<scanner>-<counter>, from a process-global counter: not stable across runs, not unique across processes. The schema says so itself.

A runtime therefore MUST NOT compare a serialized finding's id to a fixture's pattern_id. They answer different questions.

Getting from a runtime's own label to a pattern_id. Nothing requires a runtime to name its patterns the way this repository does, and the two seeding runtimes do not: the same rule is override: ignore previous instructions in one and override:ignore-previous in the other. The mapping is published, per pattern, in the lexicon's aliases object:

{
  "id": "override:ignore-previous",
  "aliases": {
    "llm_security": "override: ignore previous instructions",
    "llm_ingestion_guard": "override:ignore-previous"
  }
}

(The lexicon's own field is spelled id, because that name is ratified and renaming it would break both consumers. pattern_id here refers to exactly that value.)

A runtime SHOULD carry that id directly on its own pattern table, which makes the comparison a lookup rather than a translation. A runtime that instead maps through aliases MUST register its own alias there — a runtime absent from that object has no published way to be compared, and a mapping maintained privately in a consumer is the drift this repository exists to prevent.

Fixture files carry no individual version field, which is the one place this repository's "every JSON file has a top-level version" convention does not apply. The corpus is versioned as a whole in conformance/manifest.json. The exception is deliberate and stated here rather than left to be discovered.

4. exact-within-scope

This is the only match value defined in this version, and it means:

Restricted to findings whose id belongs to a data file named in scope, the runtime's finding set MUST equal findings exactly. Findings outside that scope MUST be ignored by the comparison — neither required nor forbidden.

So a runtime MUST NOT raise a lexicon finding this case does not list, and MUST raise every one it does. Whether the same runtime also flags the input as active content, as a carrier, or as anything else is not this case's business.

"Belongs to" means published in that file, never "shares its prefix." A runtime MUST decide membership by looking the id up in the scoped data file, and MUST NOT infer it from the id's leading segment. The two are not the same set, and the difference is not hypothetical: one seeding runtime emits active:oversize-input, a self-safety flag about its own scan cap, which carries the active: prefix but is no construct in signatures/active-content.json and is therefore out of scope for a case scoped there. A prefix-matching implementation would pull it into the comparison and fail a case over a finding the corpus makes no claim about.

The scoping is what makes the exactness safe. The two seeding runtimes do not implement the same set of tables — one has no active-content table at all — so a whole-report comparison would fail for reasons that have nothing to do with the pattern under test. Scoping the comparison to one published table asks a question about that table alone.

Through version 0.1.1 this paragraph ended "…asks a question both can answer", which held only while every case was scoped to the one table both runtimes implement. It stopped being true the moment a case was scoped to signatures/active-content.json, which only one of them has. Scope narrows what is compared; it does not guarantee that every runtime is a valid addressee of the comparison. That second question is answered by the declared table set in §1.1, and a case scoped outside a runtime's declared set is not-applicable there rather than failed.

The exactness is what makes the corpus worth running. A corpus that only checks that the expected finding is present is passed by a runtime that flags everything.

A future case MAY declare a different scope or a different match. A runtime MUST reject a match value it does not implement rather than degrade to a weaker comparison.

5. observed_out_of_scope is evidence, not expectation

This block records what a named runtime, at a named commit, was measured emitting outside the case's scope. It exists so that a reader can see what was left out of the contract instead of having to trust that nothing was.

A runtime MUST NOT be failed for disagreeing with it, and MUST NOT be required to reproduce it.

Absence of a runtime's key means unmeasured, not measured-empty. The current corpus carries no llm_security key anywhere, because that runtime was measured through its injection-scanner entry point, which cannot emit an out-of-scope finding by construction. Reading that absence as "this runtime emits nothing else" would be a claim nobody made.

6. Case ids

case_id  = pattern_id with ":" replaced by "__"
pattern_id = case_id with "__" replaced by ":"

: is not a legal filename character on Windows, and fork-and-own is a supported use of this repository, so the id space cannot reach the filesystem unchanged. __ does not occur anywhere in the ratified id space, so the transform is one-to-one — verified collision-free across all cases rather than assumed.

The transform carries a consequence that is easy to miss: a case id is derived from a pattern id alone, so the corpus holds at most one case per pattern_id in single-finding scopes. There is nowhere in the name to put a second one. That is a real constraint, not a formality — a source runtime's own test matrix may well drive two payloads at the same pattern, as one of the seeding runtimes does for active:markdown-image. When it does, the two payloads MUST be compared within the case's scope before a second case is minted: if their in-scope finding sets are equal, the second case cannot fail in any way the first does not, and its distinguishing signal lies outside the scope where this corpus makes no claim. Such a payload MUST NOT be given a discriminated case id; the derivation rule is the contract, and a suffix would break the reverse transform. It SHOULD instead be recorded as a named omission in conformance/manifest.json, so the drop is visible rather than inferred from a count.

A case id is a stable identifier. Changing one is a BREAKING change and requires a major bump of the corpus version, exactly like changing a pattern id. Consumers name cases in their own test suites; a renamed case is a silently disappeared test.

7. When a runtime and a fixture disagree

The fixture is ground truth. A runtime that disagrees is wrong — unless the fixture itself is proven wrong, in which case the fixture changes in its own commit, with the reason written down, and the corpus version is bumped.

This ordering is the whole point of the repository. Two implementations that return different verdicts on the same input are not holding different opinions; one of them has a bug.

8. What conformance does and does not prove

Passing this corpus proves that a runtime agrees with the other runtimes that pass it, on these inputs. It does not prove the fixtures are right. A wrong expected.json makes every passing runtime wrong in the same way, which is a real failure mode and not a rhetorical one — it is why each case records how it was produced.

Nor is the corpus a coverage claim. It covers the pattern tables named in conformance/manifest.json under scope_covered, and the tables under scope_planned are named there precisely so that their absence is visible rather than inferred.

And a result is only readable next to the declared table set of §1.1. A pass count on its own does not say which questions were asked: a runtime that implements one table and one that implements four can print the same number. The declared set is what turns the number back into a claim — which is also why a not-applicable verdict proves nothing in either direction about the runtime's actual detection. It says the corpus did not ask.