From c924a69514ca7220194bd78e777aedaca9b13b19 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:05:24 +0100 Subject: [PATCH] feat(detection-core): fact-pack admission and push-consumer contracts --- .kiro/specs/detection-core/tasks.md | 4 +- src/openultrasast/core/__init__.py | 9 ++++ src/openultrasast/core/consumer.py | 55 ++++++++++++++++++++++ src/openultrasast/core/factpack.py | 55 ++++++++++++++++++++++ tests/test_core_factpack_consumer.py | 69 ++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/openultrasast/core/consumer.py create mode 100644 src/openultrasast/core/factpack.py create mode 100644 tests/test_core_factpack_consumer.py diff --git a/.kiro/specs/detection-core/tasks.md b/.kiro/specs/detection-core/tasks.md index 7dd9cf5..580bbc2 100644 --- a/.kiro/specs/detection-core/tasks.md +++ b/.kiro/specs/detection-core/tasks.md @@ -101,13 +101,13 @@ _Requirement IDs use N.M where N is the requirement number in requirements.md an - _Depends: 4.1, 5.1_ - [ ] 6. Fact packs and consumer interfaces -- [ ] 6.1 Fact-pack admission contract +- [x] 6.1 Fact-pack admission contract - Ruleset loader admits family fact packs only with review evidence and provenance stamping; packs carry their family tag. - Done when an unreviewed pack is refused and a reviewed pack stamps provenance in a test. - _Requirements: 10.1_ - _Boundary: fact-pack contract_ - _Depends: 1.4_ -- [ ] 6.2 Consumer interfaces for push and proof +- [x] 6.2 Consumer interfaces for push and proof - Define the interfaces through which the pre-push transaction consumes traces/verdicts within its deadline and the proof layer attaches demonstrated evidence to a verdict; existing arms express behavior over the core contract. - Done when both interfaces are exercised by conformance tests from a mock consumer. - _Requirements: 9.2, 11.1, 11.2, 11.3_ diff --git a/src/openultrasast/core/__init__.py b/src/openultrasast/core/__init__.py index 11e72d3..5fefabf 100644 --- a/src/openultrasast/core/__init__.py +++ b/src/openultrasast/core/__init__.py @@ -11,7 +11,9 @@ from openultrasast.core.admission import ( validate_admission_manifest, ) from openultrasast.core.budget import Budget, BudgetGateFailure, Complexity, check_budget, derive_budget +from openultrasast.core.consumer import PushRequest, PushVerdict, consume_verdict from openultrasast.core.coverage import Coverage, InstrumentFailure, ResolutionOutcome, check_reads +from openultrasast.core.factpack import FactPack, FactPackError, admit_fact_pack, verify_fact_pack from openultrasast.core.probes import ( FittedComponentBlocked, ProbeResult, @@ -33,6 +35,8 @@ __all__ = [ "BudgetGateFailure", "Complexity", "Coverage", + "FactPack", + "FactPackError", "FittedComponentBlocked", "Guard", "Hop", @@ -41,18 +45,23 @@ __all__ = [ "ProbeResult", "ProvenClaim", "ProvenanceMismatch", + "PushRequest", + "PushVerdict", "ResolutionOutcome", "ShortcutDetected", "TraceRecord", "Witness", + "admit_fact_pack", "as_clean", "assert_no_shortcut", "check_budget", "check_reads", + "consume_verdict", "derive_budget", "require_admission_evidence", "reversed_pair_probe", "stamp", "validate_admission_manifest", + "verify_fact_pack", "verify_stamp", ] diff --git a/src/openultrasast/core/consumer.py b/src/openultrasast/core/consumer.py new file mode 100644 index 0000000..c0784d8 --- /dev/null +++ b/src/openultrasast/core/consumer.py @@ -0,0 +1,55 @@ +"""Consumer contracts for the detection core (Req 9, 11). + +The pre-push transaction consumes traces and verdicts through this interface within +its (measured) budget; the proof layer attaches demonstrated evidence to a verdict. +Every claim must carry a witness; every silence must carry a coverage reason. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from openultrasast.core.trace import TraceRecord +from openultrasast.core.verdict import BoundedSilence, ProvenClaim + + +@dataclass(frozen=True) +class PushRequest: + """One push transaction handed to the core.""" + + base: str + head: str + budget_class: str + + +@dataclass(frozen=True) +class PushVerdict: + """What the push consumer receives: proven claims plus bounded silence.""" + + claims: tuple[ProvenClaim, ...] + silence: tuple[BoundedSilence, ...] + digests: dict[str, str] + + +def consume_verdict( + request: PushRequest, + record: TraceRecord, + claims: Sequence[ProvenClaim], + silence: Sequence[BoundedSilence], +) -> PushVerdict: + """Validate and wrap one trace's verdict for the push consumer (Req 5, 11.1). + + A claim without a witness or a silence without a reason is refused: silence is + never a clean bill of health. + """ + for claim in claims: + if not claim.witness or not claim.path or claim.line <= 0: + raise ValueError(f"proven claim lacks witness or exact location: {claim}") + for entry in silence: + if not entry.reason: + raise ValueError(f"bounded silence lacks a coverage reason: {entry}") + return PushVerdict(claims=tuple(claims), silence=tuple(silence), digests=dict(record.digests)) + + +__all__ = ["PushRequest", "PushVerdict", "consume_verdict"] diff --git a/src/openultrasast/core/factpack.py b/src/openultrasast/core/factpack.py new file mode 100644 index 0000000..348478a --- /dev/null +++ b/src/openultrasast/core/factpack.py @@ -0,0 +1,55 @@ +"""Family fact-pack admission contract for the detection core (Req 10). + +New vulnerability families enter only as reviewed fact packs: the pack carries its +family tag and a review digest, and its fact-set digest is stamped so consumers can +refuse tampered content. The core never reads pool or admission labels. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + + +class FactPackError(ValueError): + """A fact pack is unreviewed or its content does not match its digest.""" + + +@dataclass(frozen=True) +class FactPack: + """A reviewed family fact pack with provenance.""" + + family: str + facts: tuple[dict[str, Any], ...] + review_digest: str + fact_set_digest: str + + +def _digest(payload: object) -> str: + return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:12] + + +def admit_fact_pack(family: str, facts: Sequence[Mapping[str, Any]], *, review_digest: str) -> FactPack: + """Admit a pack only with review evidence; stamp its fact-set digest (Req 10.1).""" + if not review_digest: + raise FactPackError("unreviewed fact pack refused: no review digest") + normalized = tuple(dict(fact) for fact in facts) + return FactPack( + family=family, + facts=normalized, + review_digest=review_digest, + fact_set_digest=_digest([family, normalized]), + ) + + +def verify_fact_pack(pack: FactPack, facts: Sequence[Mapping[str, Any]]) -> None: + """Refuse content that does not match the pack's stamped digest.""" + normalized = tuple(dict(fact) for fact in facts) + if _digest([pack.family, normalized]) != pack.fact_set_digest: + raise FactPackError(f"fact content does not match the stamped digest of pack {pack.family!r}") + + +__all__ = ["FactPack", "FactPackError", "admit_fact_pack", "verify_fact_pack"] diff --git a/tests/test_core_factpack_consumer.py b/tests/test_core_factpack_consumer.py new file mode 100644 index 0000000..4f823d6 --- /dev/null +++ b/tests/test_core_factpack_consumer.py @@ -0,0 +1,69 @@ +"""Fact-pack and consumer contract tests for detection-core tasks 6.1-6.2 (Req 9, 10, 11).""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from openultrasast.core.consumer import PushRequest, consume_verdict # noqa: E402 +from openultrasast.core.factpack import FactPackError, admit_fact_pack, verify_fact_pack # noqa: E402 +from openultrasast.core.trace import TraceRecord # noqa: E402 +from openultrasast.core.verdict import BoundedSilence, Judgment, ProvenClaim # noqa: E402 + +FACTS = ({"id": "obligation:protected_read", "language": "java"},) + + +def test_unreviewed_fact_pack_is_refused() -> None: + try: + admit_fact_pack("access_control", FACTS, review_digest="") + raise AssertionError("unreviewed pack must be refused") + except FactPackError: + pass + + +def test_reviewed_fact_pack_stamps_provenance() -> None: + pack = admit_fact_pack("access_control", FACTS, review_digest="review-2026-10-30") + assert pack.family == "access_control" + assert pack.review_digest == "review-2026-10-30" + assert len(pack.fact_set_digest) == 12 + verify_fact_pack(pack, FACTS) + try: + verify_fact_pack(pack, FACTS + ({"id": "tampered"},)) + raise AssertionError("tampered facts must be refused") + except FactPackError: + pass + + +def _record() -> TraceRecord: + return TraceRecord( + entry="src/app.py::handler", + hops=(), + operation_kind="protected_read", + resource="credentials", + authority="admin", + judgment=Judgment.UNGUARDED, + digests={"input": "abc"}, + ) + + +def test_consumer_accepts_witnessed_claim_and_reasoned_silence() -> None: + claim = ProvenClaim(trace_id="t1", witness="w1", path="src/app.py", line=10, family="access_control") + silence = BoundedSilence(path="src/other.py", reason="unreached", coverage={"entry_points": 0}) + verdict = consume_verdict(PushRequest(base="b", head="h", budget_class="dev-pool"), _record(), (claim,), (silence,)) + assert verdict.claims == (claim,) + assert verdict.silence == (silence,) + + +def test_consumer_refuses_claim_without_witness_or_silence_without_reason() -> None: + bad_claim = ProvenClaim(trace_id="t1", witness="", path="src/app.py", line=10, family="access_control") + try: + consume_verdict(PushRequest(base="b", head="h", budget_class="c"), _record(), (bad_claim,), ()) + raise AssertionError("witnessless claim must be refused") + except ValueError: + pass + bad_silence = BoundedSilence(path="src/other.py", reason="", coverage={}) + try: + consume_verdict(PushRequest(base="b", head="h", budget_class="c"), _record(), (), (bad_silence,)) + raise AssertionError("reasonless silence must be refused") + except ValueError: + pass