feat(slots): Phase 3 task 6.3 — model-disabled, slot-contracted deterministic stages

Express the deterministic scan stages as model-disabled processors carrying work in
named slots under a read/write allow-list validated on every lifecycle hook.

- slot_contract.py (zero-dep): SlotContractError, SlotContractMixin (reads_slots/
  writes_slots/skip_model=True, validates reads pre-hook and writes post-hook),
  SlotPipeline (runs stages in order, enforces the model-disabled invariant, emits
  stage_start/stage_end lifecycle hooks). Preserves the ProcessorSpec.reads/writes
  provenance discipline without forcing deterministic stages through HarnessX's agent
  loop ("bridge, do not map").
- stage_processors.py: Preprocess/EntryPoint/Rank/QuickFindings/PolicyScoring/Score as
  skip_model slot processors; run_quick_pipeline is byte-identical to the quick path.
  host_under_harnessx is the lazy, capability-gated bridge that disables the model on
  HarnessX's BeforeModelEvent.skip_model (proven against the real API when the extra
  is present).
- 8.4 zero-dep guard hardened: now hermetic (subprocess) and covers the two new
  modules; the module-level import scan flags only column-0 imports so the intended
  lazy import inside host_under_harnessx is allowed.

The sync HarnessRuntime stays the top-level driver (design "stays through Phase 4");
these slot processors are the model-disabled representation, ready for Phase 5.

tests/test_slot_contract.py (8 tests). 153 passed, 3 skipped; ruff + mypy clean;
detection gate PASS.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-06-22 18:39:09 +02:00
co-authored by Claude Opus 4.8
parent bebfc72cf7
commit b9caeeb835
6 changed files with 410 additions and 13 deletions
+93
View File
@@ -0,0 +1,93 @@
"""Slot-contract discipline for model-disabled deterministic stages (task 6.3).
The deterministic scan stages carry their work in named *slots* and run with model
invocation disabled (``skip_model = True``). Each stage declares a read/write slot
allow-list that is validated on every lifecycle hook, preserving the
``ProcessorSpec.reads/writes`` provenance discipline of the sync ``HarnessRuntime``
(the top-level driver) — without forcing the deterministic stages through HarnessX's
agent loop ("bridge, do not map", per the spec design).
This is the zero-dependency substrate. When the optional ``openultrasast[harnessx]``
extra is present, the same model-disabled processors can be hosted under a HarnessX
``MultiHookProcessor`` (see ``stage_processors.host_under_harnessx``); the
``skip_model`` marker maps onto HarnessX's ``BeforeModelEvent.skip_model`` so no
deterministic stage incurs a model call.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Protocol
from .harness import HarnessContractError
class SlotContractError(HarnessContractError):
"""Raised when a stage reads or writes a slot outside its declared allow-list."""
class SlotContractMixin:
"""A model-disabled stage that carries work in slots under a read/write allow-list."""
name: str = "slot-stage"
version: str = "1"
reads_slots: tuple[str, ...] = ()
writes_slots: tuple[str, ...] = ()
skip_model: bool = True # deterministic: never invokes a model
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
raise NotImplementedError
def validate_reads(self, slots: Mapping[str, Any]) -> None:
missing = sorted(slot for slot in self.reads_slots if slot not in slots)
if missing:
raise SlotContractError(f"{self.name}: missing read slots: {', '.join(missing)}")
def validate_writes(self, updates: Mapping[str, Any]) -> None:
undeclared = sorted(set(updates) - set(self.writes_slots))
if undeclared:
raise SlotContractError(f"{self.name}: undeclared write slots: {', '.join(undeclared)}")
def execute(self, slots: Mapping[str, Any]) -> dict[str, Any]:
"""Validate the read allow-list, run the stage, validate the write allow-list."""
self.validate_reads(slots) # pre-hook
updates = self.run(slots) or {}
if not isinstance(updates, dict):
raise SlotContractError(f"{self.name}: stage returned a non-dict update")
self.validate_writes(updates) # post-hook
return updates
class _Emitter(Protocol):
def emit(self, event_type: Any, stage: str | None, payload: dict[str, Any]) -> None: ...
class SlotPipeline:
"""Runs slot processors in order, validating contracts and enforcing model-disable."""
def __init__(self, processors: list[SlotContractMixin]) -> None:
for proc in processors:
if not proc.skip_model:
raise SlotContractError(f"{proc.name}: deterministic stage must be model-disabled (skip_model=True)")
self._processors = processors
@property
def processors(self) -> list[SlotContractMixin]:
return list(self._processors)
def run(self, initial_slots: Mapping[str, Any], *, runtime: _Emitter | None = None) -> dict[str, Any]:
"""Execute every stage; with ``runtime`` set, emit stage_start/stage_end lifecycle hooks."""
slots: dict[str, Any] = dict(initial_slots)
for proc in self._processors:
if runtime is not None:
runtime.emit(
"stage_start", proc.name, {"reads": list(proc.reads_slots), "writes": list(proc.writes_slots), "skip_model": True}
)
updates = proc.execute(slots)
slots.update(updates)
if runtime is not None:
runtime.emit("stage_end", proc.name, {"status": "succeeded", "wrote": sorted(updates)})
return slots
__all__ = ["SlotContractError", "SlotContractMixin", "SlotPipeline"]
+170
View File
@@ -0,0 +1,170 @@
"""Deterministic scan stages as model-disabled, slot-contracted processors (task 6.3).
Each stage is a :class:`slot_contract.SlotContractMixin` with ``skip_model = True`` and
a declared read/write slot allow-list. Running them through a :class:`SlotPipeline`
reproduces the deterministic quick-scan path byte-for-byte while enforcing the slot
contract on every lifecycle hook. The hunter and verifier stages are *not* here —
they are the agentic plane (``hunter_harness``/``verify_judge``); this module is the
deterministic, model-free remainder.
``host_under_harnessx`` is the optional, lazy bridge: it wraps a model-disabled stage
as a HarnessX ``MultiHookProcessor`` that disables the model call on ``before_model``.
"""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from .findings import quick_scan_findings
from .mapping import analyze_entry_points, attach_reachability_hints
from .policy import load_policy
from .preprocess import preprocess_repository
from .rank import rank_targets
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
from .scoring import build_score_artifact
from .slot_contract import SlotContractMixin, SlotPipeline
class PreprocessProcessor(SlotContractMixin):
name = "preprocess"
reads_slots = ("root",)
writes_slots = ("snapshot", "targets")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
snapshot, targets = preprocess_repository(slots["root"])
return {"snapshot": snapshot, "targets": targets}
class EntryPointProcessor(SlotContractMixin):
name = "entry_point"
reads_slots = ("root", "targets")
writes_slots = ("targets", "entry_points")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
entry_points = analyze_entry_points(slots["root"], slots["targets"])
targets = attach_reachability_hints(slots["targets"], entry_points)
return {"targets": targets, "entry_points": entry_points}
class RankProcessor(SlotContractMixin):
name = "rank"
reads_slots = ("targets",)
writes_slots = ("rankings",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
return {"rankings": rank_targets(slots["targets"])}
class QuickFindingsProcessor(SlotContractMixin):
name = "quick_findings"
reads_slots = ("root", "targets", "rankings", "ruleset", "policy")
writes_slots = ("findings",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
findings = quick_scan_findings(slots["root"], slots["targets"], slots["rankings"], slots["ruleset"], slots["policy"])
return {"findings": findings}
class PolicyScoringProcessor(SlotContractMixin):
name = "policy_scoring"
reads_slots = ("findings",)
writes_slots = ("reported_findings", "shadow_findings")
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
findings = slots["findings"]
shadow = [finding for finding in findings if finding.status == "shadow"]
reported = [finding for finding in findings if finding.status != "shadow"]
return {"reported_findings": reported, "shadow_findings": shadow}
class ScoreProcessor(SlotContractMixin):
name = "score"
reads_slots = ("reported_findings", "ruleset", "policy")
writes_slots = ("score",)
def run(self, slots: Mapping[str, Any]) -> dict[str, Any]:
cwe_by_rule = {rule.rule_id: rule.cwe for rule in slots["ruleset"]}
reported = slots["reported_findings"]
rule_cwe_by_id = {f.finding_id: cwe_by_rule.get(f.finding_id.split(":", 1)[0], "") for f in reported}
return {"score": build_score_artifact(reported, rule_cwe_by_id, slots["policy"])}
def deterministic_processors() -> list[SlotContractMixin]:
"""The ordered deterministic stages (preprocess → entry-point → rank → findings → policy → score)."""
return [
PreprocessProcessor(),
EntryPointProcessor(),
RankProcessor(),
QuickFindingsProcessor(),
PolicyScoringProcessor(),
ScoreProcessor(),
]
def build_deterministic_pipeline() -> SlotPipeline:
return SlotPipeline(deterministic_processors())
def run_quick_pipeline(
root: Path | str,
*,
ruleset: Any = None,
policy: Any = None,
runtime: Any = None,
) -> dict[str, Any]:
"""Run the deterministic quick-scan pipeline and return the final slot map.
Defaults to the bundled ruleset and the vendored policy, which makes the
``reported_findings`` slot byte-identical to ``findings.quick_scan_findings`` on
the same target.
"""
resolved_ruleset = ruleset if ruleset is not None else load_ruleset(DEFAULT_RULESET_DIR)
resolved_policy = policy if policy is not None else load_policy()
initial = {"root": Path(root), "ruleset": resolved_ruleset, "policy": resolved_policy}
return build_deterministic_pipeline().run(initial, runtime=runtime)
def host_under_harnessx(processor: SlotContractMixin) -> Any:
"""Host a model-disabled deterministic stage under a HarnessX ``MultiHookProcessor``.
Lazy + capability-guarded (imports ``harnessx`` only when called). On the
``before_model`` hook the hosted stage yields a ``BeforeModelEvent`` with
``skip_model=True``, so no deterministic stage incurs a model call when it runs on
the HarnessX runloop. Raises :class:`HarnessXUnavailableError` when the extra is absent.
"""
import dataclasses
from .harness_ext import require_harnessx
require_harnessx()
from harnessx.core.events import BeforeModelEvent
from harnessx.core.processor import MultiHookProcessor
class _ModelDisabledStage(MultiHookProcessor): # type: ignore[misc, valid-type]
_singleton_group = "ousast_deterministic"
_order = 50
def __init__(self, stage: SlotContractMixin) -> None:
self.stage = stage
self.name = stage.name
async def on_before_model(self, event: BeforeModelEvent): # type: ignore[no-untyped-def]
yield dataclasses.replace(event, skip_model=True, synthetic_output=f"[deterministic stage {self.stage.name}: model disabled]")
return _ModelDisabledStage(processor)
__all__ = [
"EntryPointProcessor",
"PolicyScoringProcessor",
"PreprocessProcessor",
"QuickFindingsProcessor",
"RankProcessor",
"ScoreProcessor",
"build_deterministic_pipeline",
"deterministic_processors",
"host_under_harnessx",
"run_quick_pipeline",
]