feat(inbox): C2.5 — inbox hardening + SDK version guard (closes C-F7, C-N3, R-6)
- File-layer decision vocabulary (§4.2 set) with SKIP semantics — an unknown decision never reaches the store (C-F7, the review's run proof is the fixture) - Fail-fast caps (max_files / max_rationale_chars) via InboxLimitError raised OUTSIDE the tolerant try — a cap breach is never swallowed as a skip - R-6 id grammar (mirrors ingest _ID_RE) as a pydantic pattern on VerdictDocument.id AND re-checked in write_verdict, since model_copy(update=) bypasses model validation — traversal ids can no longer write outside the inbox - promotion._filename_token: any sanitised id maps to a content hash — 'e/vil' can no longer clobber the distinct id 'evil' (restarbeid-funn 2) - SDK pinned >=0.2.111,<0.3 + version guard test naming the sdk_client.py attribute premises; resolved 0.2.120, all premises re-verified against it - sdk_client read loop bound offline with REAL SDK message types (R-4/R-5): text aggregation, error fail-paths, usage/cost extraction, _total_tokens fail-closed, non-positive budget guard - test_sdk_isolation comment no longer claims the --system-prompt "" serialization the test body does not bind (honesty rule §1) Guard-G2 assessment (guard-plan §4): the allowlist + caps + id grammar landed here are G2's necessary part; an optional scan_output depth pass over rationale (still a verbatim prose channel into the fold prompt, R-9) remains relevant as a later additive session — the trigger picture is unchanged. 4 detach proofs red → restored green. Full gate: 389 passed (365→389), ruff+format+mypy clean; golden + shared/ + runs/s10/ byte-untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e7ce6b0a31
commit
80a2fa1a77
9 changed files with 441 additions and 35 deletions
|
|
@ -13,7 +13,9 @@ from __future__ import annotations
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from _scripted import ScriptedClient, reply
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser_claude.budget import BudgetMeter
|
||||
from portfolio_optimiser_claude.contracts import TerminationContract
|
||||
|
|
@ -25,6 +27,7 @@ from portfolio_optimiser_claude.experience import (
|
|||
mint_verdict_id,
|
||||
)
|
||||
from portfolio_optimiser_claude.inbox import (
|
||||
InboxLimitError,
|
||||
ProposalFeatures,
|
||||
VerdictDocument,
|
||||
load_inbox,
|
||||
|
|
@ -162,6 +165,98 @@ class TestTolerantLoad:
|
|||
assert [d.id for d in load_inbox(tmp_path)] == sorted([late.id, early.id])
|
||||
|
||||
|
||||
class TestFileLayerVocabulary:
|
||||
"""C2.5 (C-F7): the file layer polices the §4.2 decision set — SKIP, never raise."""
|
||||
|
||||
def test_an_unknown_decision_never_reaches_the_store(self, tmp_path: Path) -> None:
|
||||
# Fixture = the review's RUN C-F7 proof: before C2.5 this string
|
||||
# entered the store and its rationale folded into the next prompt.
|
||||
doc = _document().model_copy(update={"decision": "hva-som-helst"})
|
||||
(tmp_path / f"{doc.id}.json").write_text(json.dumps(doc.model_dump()), encoding="utf-8")
|
||||
store = VerdictStore()
|
||||
assert merge_inbox_into_store(store, tmp_path) == 0
|
||||
assert len(store) == 0
|
||||
|
||||
def test_the_full_file_layer_vocabulary_loads(self, tmp_path: Path) -> None:
|
||||
# Control: exactly the §4.2 set {approved, rejected,
|
||||
# approved_with_adjustment} passes the file layer.
|
||||
decisions = ("approved", "rejected", "approved_with_adjustment")
|
||||
for index, decision in enumerate(decisions):
|
||||
write_verdict(
|
||||
tmp_path, _document(_features(codes=frozenset({f"C{index}"})), decision=decision)
|
||||
)
|
||||
assert {d.decision for d in load_inbox(tmp_path)} == set(decisions)
|
||||
|
||||
|
||||
class TestInboxCaps:
|
||||
"""C2.5: configurable caps fail FAST with a precise error — never a silent cut."""
|
||||
|
||||
def test_rationale_over_the_cap_fails_fast_with_a_precise_error(self, tmp_path: Path) -> None:
|
||||
doc = _document(rationale="x" * 201)
|
||||
write_verdict(tmp_path, doc)
|
||||
with pytest.raises(InboxLimitError) as err:
|
||||
load_inbox(tmp_path, max_rationale_chars=200)
|
||||
message = str(err.value)
|
||||
assert doc.id in message
|
||||
assert "201" in message
|
||||
assert "200" in message
|
||||
|
||||
def test_rationale_at_the_cap_loads(self, tmp_path: Path) -> None:
|
||||
write_verdict(tmp_path, _document(rationale="x" * 200))
|
||||
assert len(load_inbox(tmp_path, max_rationale_chars=200)) == 1
|
||||
|
||||
def test_more_files_than_the_cap_fails_fast(self, tmp_path: Path) -> None:
|
||||
for index in range(3):
|
||||
write_verdict(tmp_path, _document(_features(codes=frozenset({f"C{index}"}))))
|
||||
with pytest.raises(InboxLimitError) as err:
|
||||
load_inbox(tmp_path, max_files=2)
|
||||
message = str(err.value)
|
||||
assert "3" in message
|
||||
assert "2" in message
|
||||
|
||||
def test_merge_threads_the_caps_through(self, tmp_path: Path) -> None:
|
||||
write_verdict(tmp_path, _document(rationale="x" * 201))
|
||||
with pytest.raises(InboxLimitError):
|
||||
merge_inbox_into_store(VerdictStore(), tmp_path, max_rationale_chars=200)
|
||||
|
||||
|
||||
class TestIdGrammar:
|
||||
"""C2.5 (R-6): the id grammar is policed BEFORE ``write_verdict`` touches disk."""
|
||||
|
||||
def test_a_traversal_id_is_rejected_at_construction(self) -> None:
|
||||
# The security agent's RUN R-6 proof: '../../escaped' used to
|
||||
# construct fine and write outside the inbox.
|
||||
with pytest.raises(ValidationError):
|
||||
VerdictDocument(
|
||||
id="../../escaped",
|
||||
decision="approved",
|
||||
rationale=RATIONALE,
|
||||
proposal_features=ProposalFeatures(
|
||||
affected_codes=["E01"],
|
||||
measure_type="led-retrofit",
|
||||
claimed_saving_nok=25000.0,
|
||||
description="surface text",
|
||||
),
|
||||
)
|
||||
|
||||
def test_write_verdict_refuses_an_escaping_id_writing_nothing(self, tmp_path: Path) -> None:
|
||||
# model_copy(update=...) bypasses model validation — the write seam
|
||||
# must fail closed on its own (detach the grammar here → a file lands
|
||||
# OUTSIDE the inbox → red).
|
||||
inbox = tmp_path / "inbox"
|
||||
doc = _document().model_copy(update={"id": "../../escaped"})
|
||||
with pytest.raises(ValueError):
|
||||
write_verdict(inbox, doc)
|
||||
assert not (tmp_path / "escaped.json").exists()
|
||||
assert not inbox.exists()
|
||||
|
||||
def test_a_loaded_file_with_an_escaping_id_is_skipped(self, tmp_path: Path) -> None:
|
||||
# Tolerant load stays tolerant: a hostile id is SKIPPED, never raised.
|
||||
doc = _document().model_copy(update={"id": "../../escaped"})
|
||||
(tmp_path / "escaped.json").write_text(json.dumps(doc.model_dump()), encoding="utf-8")
|
||||
assert load_inbox(tmp_path) == []
|
||||
|
||||
|
||||
class TestMergeIntoStore:
|
||||
"""§5: merge, never replace — first-write-wins per id, idempotent, read-only."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue