core/core/proposal_review/safety.py
Shay cc3003f425 feat(proposal-review): safety dry-check + read-only CLI (RPT-c)
core/proposal_review/safety.py: dry_check INDEPENDENTLY verifies (without trusting the emitter) that every artifact is inert — status==proposal_only, mounted==false, requires_review==true, content-address consistent (filename == sha256(family:problem_text_sha256)), path under the sink, no malformed (unverifiable) file — and that no serving-path module reads the sink. Any failure is a violation.

__main__.py: python -m core.proposal_review [comprehension-failures] [--json] [--root] — read-only report + dry-check; exit 0 iff safe. Mutates nothing. 8 tests, each safety assertion proven meaningful-fail; serving-unconsumed verified on the real tree.
2026-06-07 21:43:18 -07:00

102 lines
3.5 KiB
Python

"""Safety dry-check over the proposal sink (RPT-c) — the load-bearing part of the reporter.
The reporter's value is not just visibility; it is **independent safety verification**. The
dry-check confirms — without trusting the emitter — that every artifact in the sink is inert:
```text
status == "proposal_only"
mounted == false
requires_review == true
content-address consistent: filename == sha256(failure_family : problem_text_sha256)
path under the sink
no malformed artifact (an unverifiable file in a safety-critical sink is a violation)
serving never imports/reads the sink
```
Any failure is a violation; the CLI exits non-zero. **Pure read** — verifies, never repairs.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from core.proposal_review.model import MalformedArtifact, PendingProposal
from core.proposal_review.scan import DEFAULT_SINK
#: Serving-path roots that must never read the proposal sink (CLAUDE.md forbidden/serving sites).
_SERVING_TARGETS = (
("generate", "stream.py"),
("field", "propagate.py"),
("vault", "store.py"),
("generate", "derivation"),
("core", "reliability_gate"),
)
_SINK_MARKER = "comprehension_failures"
@dataclass(frozen=True, slots=True)
class SafetyVerdict:
"""The outcome of the dry-check: ``ok`` iff there are no violations."""
ok: bool
violations: tuple[str, ...]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _serving_references_sink(repo_root: Path) -> list[str]:
violations: list[str] = []
for parts in _SERVING_TARGETS:
target = repo_root.joinpath(*parts)
if not target.exists():
continue
files = [target] if target.is_file() else sorted(target.rglob("*.py"))
for f in files:
try:
if _SINK_MARKER in f.read_text(encoding="utf-8"):
violations.append(f"serving reads the sink: {f.relative_to(repo_root)}")
except (UnicodeDecodeError, OSError): # pragma: no cover - defensive
continue
return violations
def dry_check(
proposals: list[PendingProposal],
malformed: list[MalformedArtifact],
*,
root: Path | None = None,
repo_root: Path | None = None,
) -> SafetyVerdict:
"""Verify every artifact is inert and the sink is serving-unconsumed. Returns a SafetyVerdict."""
base = (root if root is not None else DEFAULT_SINK).resolve()
violations: list[str] = []
for p in proposals:
tag = p.content_hash
if p.status != "proposal_only":
violations.append(f"{tag}: status={p.status!r} (must be 'proposal_only')")
if p.mounted:
violations.append(f"{tag}: mounted=True (must be False)")
if not p.requires_review:
violations.append(f"{tag}: requires_review=False (must be True)")
expected = hashlib.sha256(
f"{p.failure_family}:{p.problem_text_sha256}".encode("utf-8")
).hexdigest()
if p.content_hash != expected:
violations.append(f"{tag}: content-address mismatch (expected {expected})")
if not str(Path(p.path).resolve()).startswith(str(base)):
violations.append(f"{tag}: path outside the sink")
for m in malformed:
violations.append(f"malformed (unverifiable): {m.path}{m.reason}")
violations.extend(_serving_references_sink(repo_root if repo_root is not None else _repo_root()))
return SafetyVerdict(not violations, tuple(violations))
__all__ = ["SafetyVerdict", "dry_check"]