feat(learning-harness): family detectors, verifiers and tags in a scan

Task 3.4. A standard scan now classifies each hotspot region and runs the
detectors of every admitted family plus the generalist, reading the
configurations round zero wrote. A scan never creates them: a missing directory
is a recorded reason and the stage is skipped, which is also what happens when
no chat endpoint resolves.

Every claim passes through its family's verifier and carries what produced it:
the family, the detector version and what the verifier said. Nothing rises above
suspicion here, because the verifier writes a tag and the evidence ladder
belongs to another boundary. Reports gain a section naming the three, SARIF
gains the matching properties, and the manifest gains a learning block with the
taxonomy version, the per-family counts, the classifier report and the round.

The feature is off unless a project turns it on, so an existing scan is
unchanged, and all three gate outputs stay byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-06 11:33:26 +02:00
co-authored by Claude Opus 5
parent e8e6bafa61
commit 127dbef8fc
4 changed files with 229 additions and 0 deletions
+23
View File
@@ -22,6 +22,7 @@ def write_markdown_report(
mechanisms: Mapping[str, Mapping[str, object]] | None = None,
obligations: Mapping[str, Mapping[str, object]] | None = None,
obligations_summary: Mapping[str, object] | None = None,
learning: Mapping[str, Mapping[str, object]] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications or [])
@@ -72,6 +73,8 @@ def write_markdown_report(
_append_mechanisms(lines, cited)
if cited_obligations:
_append_obligations(lines, cited_obligations, obligations_summary)
if learning:
_append_learning(lines, learning)
if overlay:
_append_overlay(lines, overlay)
if complexity_map is not None:
@@ -155,6 +158,7 @@ def write_sarif_report(
overlay: Sequence[object] | None = None,
mechanisms: Mapping[str, Mapping[str, object]] | None = None,
obligations: Mapping[str, Mapping[str, object]] | None = None,
learning: Mapping[str, Mapping[str, object]] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications)
@@ -179,6 +183,7 @@ def write_sarif_report(
overlay_by_id.get(finding.finding_id),
mechanisms=mechanisms,
obligation=(obligations or {}).get(finding.finding_id),
learning=(learning or {}).get(finding.finding_id),
)
for finding in findings
],
@@ -204,6 +209,7 @@ def write_manifest(
provenance: dict[str, object] | None = None,
variants: dict[str, object] | None = None,
obligations: dict[str, object] | None = None,
learning: dict[str, object] | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
verification_by_id = _verification_by_id(verifications)
@@ -244,6 +250,8 @@ def write_manifest(
payload["variants"] = variants
if obligations is not None:
payload["obligations"] = obligations
if learning:
payload["learning"] = learning
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
@@ -287,6 +295,7 @@ def _sarif_result(
*,
mechanisms: Mapping[str, Mapping[str, object]] | None = None,
obligation: Mapping[str, object] | None = None,
learning: Mapping[str, object] | None = None,
) -> dict[str, object]:
properties: dict[str, object] = {
"finding_id": finding.finding_id,
@@ -318,6 +327,10 @@ def _sarif_result(
properties["obligation_evidence"] = _strings_of(obligation, "evidence")
properties["obligation_known_fix"] = obligation.get("known_fix")
properties["obligation_intent"] = obligation.get("intent")
if learning is not None:
properties["family"] = str(learning.get("family", ""))
properties["detector"] = str(learning.get("detector", ""))
properties["verifier"] = str(learning.get("verifier", ""))
return {
"ruleId": _rule_id(finding),
"level": _sarif_level(finding.severity),
@@ -473,6 +486,16 @@ def _append_obligations(lines: list[str], cited: Mapping[str, Mapping[str, objec
lines.append("")
def _append_learning(lines: list[str], cited: Mapping[str, Mapping[str, object]]) -> None:
"""Which family found a claim, which detector version produced it, and what a verifier said about it."""
lines.extend(["## Classified detectors", "", "Findings from the per-family detectors, with what confirmed them.", ""])
for finding_id, info in sorted(cited.items()):
lines.append(
f"- `{finding_id}`: family `{info.get('family')}`, detector `{info.get('detector')}`, verifier `{info.get('verifier')}`"
)
lines.append("")
def _strings_of(info: Mapping[str, object], key: str) -> list[str]:
value = info.get(key)
return [str(item) for item in value] if isinstance(value, list | tuple) else []