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:
Kjell Tore Guttormsen 2026-07-16 20:26:41 +02:00
commit 80a2fa1a77
9 changed files with 441 additions and 35 deletions

View file

@ -5,7 +5,7 @@ description = "Sibling implementation of the portfolio-optimiser method on the C
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"claude-agent-sdk>=0.2", "claude-agent-sdk>=0.2.111,<0.3",
"pydantic>=2", "pydantic>=2",
] ]

View file

@ -7,14 +7,20 @@ Role split (§3 Step 7, unwaivable): the system READS the inbox (tolerant load,
merge into the store); writing is the authoring primitive's job, used only by merge into the store); writing is the authoring primitive's job, used only by
the expert/persona side a run never persists its own captured verdict back. the expert/persona side a run never persists its own captured verdict back.
Decision vocabulary at this layer: the file carries the expert decision as a Decision vocabulary at this layer (C2.5, C-F7): loading polices the §4.2 set
plain string the run-path feedback contract (§4.1) and the promotion gate's {approved, rejected, approved_with_adjustment} with SKIP semantics an
accepted set (§6) are where the vocabulary is policed, not the raw file layer. unknown decision never reaches the store, but never raises either (the raw
layer is written out of band). The run-path feedback contract (§4.1) and the
promotion gate's accepted set (§6) police their own vocabularies on top.
Capacity is the exception to tolerance: an inbox over the file cap, or a
rationale over the length cap, FAILS FAST with a precise error a silent
skip there would silently drop expert knowledge (never a silent cut).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
@ -26,6 +32,23 @@ from portfolio_optimiser_claude.experience import (
mint_verdict_id, mint_verdict_id,
) )
# R-6 id grammar — mirrors ingest.py's _ID_RE: lowercase alphanumerics and
# hyphens only, so a verdict id can NEVER traverse paths (no dots, no
# separators). Minted ids (16 hex chars, §4.2) always match.
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
# §4.2: the file layer's full decision vocabulary.
_FILE_DECISIONS = frozenset({"approved", "rejected", "approved_with_adjustment"})
# Fail-fast capacity defaults — generous for any real expert inbox, small
# enough that a runaway writer cannot flood the fold.
_DEFAULT_MAX_FILES = 1_000
_DEFAULT_MAX_RATIONALE_CHARS = 20_000
class InboxLimitError(ValueError):
"""An inbox cap was exceeded — refusing to load (fail-fast, never a silent cut)."""
class ProposalFeatures(BaseModel): class ProposalFeatures(BaseModel):
"""§4.2 ``proposal_features``: the structural features of the judged candidate. """§4.2 ``proposal_features``: the structural features of the judged candidate.
@ -43,7 +66,7 @@ class ProposalFeatures(BaseModel):
class VerdictDocument(BaseModel): class VerdictDocument(BaseModel):
"""One verdict file (§4.2). A LOADED ``id`` is kept verbatim — never re-minted.""" """One verdict file (§4.2). A LOADED ``id`` is kept verbatim — never re-minted."""
id: str = Field(min_length=1) id: str = Field(min_length=1, pattern=_ID_RE.pattern)
decision: str = Field(min_length=1) decision: str = Field(min_length=1)
rationale: str = Field(min_length=1) rationale: str = Field(min_length=1)
proposal_features: ProposalFeatures proposal_features: ProposalFeatures
@ -90,8 +113,16 @@ def write_verdict(inbox_dir: Path, verdict: VerdictDocument) -> Path:
"""The authoring primitive (§5): ``{id}.json``, written deterministically. """The authoring primitive (§5): ``{id}.json``, written deterministically.
Creates the directory if needed; sorted keys, 2-space indent. The disk Creates the directory if needed; sorted keys, 2-space indent. The disk
layer is LAST-write-wins per file (§4.2). layer is LAST-write-wins per file (§4.2). The id grammar is re-checked
HERE (R-6): ``model_copy(update=...)`` bypasses model validation, so the
write seam fails closed on its own nothing is ever written outside
``inbox_dir``.
""" """
if _ID_RE.fullmatch(verdict.id) is None:
raise ValueError(
f"refusing to write verdict: id {verdict.id!r} violates the id grammar "
f"{_ID_RE.pattern!r} (R-6 path safety)"
)
inbox_dir.mkdir(parents=True, exist_ok=True) inbox_dir.mkdir(parents=True, exist_ok=True)
path = inbox_dir / f"{verdict.id}.json" path = inbox_dir / f"{verdict.id}.json"
payload = json.dumps(verdict.model_dump(), sort_keys=True, indent=2) payload = json.dumps(verdict.model_dump(), sort_keys=True, indent=2)
@ -99,34 +130,67 @@ def write_verdict(inbox_dir: Path, verdict: VerdictDocument) -> Path:
return path return path
def load_inbox(inbox_dir: Path) -> list[VerdictDocument]: def load_inbox(
inbox_dir: Path,
*,
max_files: int = _DEFAULT_MAX_FILES,
max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS,
) -> list[VerdictDocument]:
"""Tolerant load (§5): the raw layer is written out of band — skip, never raise. """Tolerant load (§5): the raw layer is written out of band — skip, never raise.
A missing folder yields zero verdicts; files that are not ``.json``, fail A missing folder yields zero verdicts; files that are not ``.json``, fail
to parse, or lack a required top-level key are SKIPPED. Deterministic to parse, lack a required top-level key, violate the id grammar, or carry
order: sorted by filename. a decision outside the §4.2 vocabulary are SKIPPED. Deterministic order:
sorted by filename. The caps are the one place tolerance ends: more
candidate files than ``max_files``, or a rationale longer than
``max_rationale_chars``, raises :class:`InboxLimitError` (C2.5).
""" """
if not inbox_dir.is_dir(): if not inbox_dir.is_dir():
return [] return []
paths = [
path
for path in sorted(inbox_dir.iterdir(), key=lambda p: p.name)
if path.suffix == ".json" and path.is_file()
]
if len(paths) > max_files:
raise InboxLimitError(
f"inbox {inbox_dir} holds {len(paths)} verdict files, over the cap of "
f"{max_files} — refusing to load"
)
verdicts: list[VerdictDocument] = [] verdicts: list[VerdictDocument] = []
for path in sorted(inbox_dir.iterdir(), key=lambda p: p.name): for path in paths:
if path.suffix != ".json" or not path.is_file():
continue
try: try:
verdicts.append(VerdictDocument.model_validate(json.loads(path.read_text("utf-8")))) document = VerdictDocument.model_validate(json.loads(path.read_text("utf-8")))
except (OSError, ValueError, ValidationError): except (OSError, ValueError, ValidationError):
continue continue
if document.decision not in _FILE_DECISIONS:
continue # §4.2 vocabulary (C-F7): unknown decision → SKIP, never the store
# Outside the tolerant try on purpose — a cap breach must NEVER be
# swallowed as one more skipped file.
if len(document.rationale) > max_rationale_chars:
raise InboxLimitError(
f"verdict file {path.name} carries a rationale of "
f"{len(document.rationale)} chars, over the cap of "
f"{max_rationale_chars} — refusing to load"
)
verdicts.append(document)
return verdicts return verdicts
def merge_inbox_into_store(store: VerdictStore, inbox_dir: Path) -> int: def merge_inbox_into_store(
store: VerdictStore,
inbox_dir: Path,
*,
max_files: int = _DEFAULT_MAX_FILES,
max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS,
) -> int:
"""Merge, never replace (§5): per-verdict add, first-write-wins per id. """Merge, never replace (§5): per-verdict add, first-write-wins per id.
Runs BEFORE the Step-1 fold, so a passed-in store's existing verdicts Runs BEFORE the Step-1 fold, so a passed-in store's existing verdicts
survive (cross-project threading) and repeated merges are idempotent. survive (cross-project threading) and repeated merges are idempotent.
Returns the number of inbox verdicts ingested; never writes anything. Returns the number of inbox verdicts ingested; never writes anything.
""" """
verdicts = load_inbox(inbox_dir) verdicts = load_inbox(inbox_dir, max_files=max_files, max_rationale_chars=max_rationale_chars)
for verdict in verdicts: for verdict in verdicts:
store.add(verdict.to_record()) store.add(verdict.to_record())
return len(verdicts) return len(verdicts)

View file

@ -32,10 +32,12 @@ class PromotionError(ValueError):
def _filename_token(verdict_id: str) -> str: def _filename_token(verdict_id: str) -> str:
# Path-safe, fail-closed against escaping names: sanitise to the safe # Path-safe, fail-closed against escaping names: an id that survives the
# alphabet; a degenerate token falls back to a content hash. # safe alphabet unchanged keeps its name; ANY id the sanitiser had to
# touch maps to a content hash instead (C2.5) — stripping alone let
# 'e/vil' collide onto the same promoted file as the distinct id 'evil'.
token = _TOKEN_UNSAFE.sub("", verdict_id) token = _TOKEN_UNSAFE.sub("", verdict_id)
if not token.strip("."): if token != verdict_id or not token.strip("."):
token = hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16] token = hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16]
return token return token

View file

@ -9,11 +9,13 @@ turn, a first-class USD cap (``ClaudeAgentOptions.max_budget_usd``) ON TOP of
the §8 token/round meter that the loop already charges, and NO filesystem the §8 token/round meter that the loop already charges, and NO filesystem
settings (``setting_sources=[]``). settings (``setting_sources=[]``).
Verified against claude-agent-sdk 0.2.110: ``query()`` yields Verified against claude-agent-sdk 0.2.110 at source level and release notes
``AssistantMessage`` (text blocks + real model id) and a closing through 0.2.120 pinned ``>=0.2.111,<0.3`` with a version guard
``ResultMessage`` (provider-reported ``usage`` + ``total_cost_usd``). A reply (``tests/test_sdk_version_guard.py``) that forces re-verification of these
without usage is passed through as ``None`` so the meter fails CLOSED (§8) premises before any widening: ``query()`` yields ``AssistantMessage`` (text
this client never invents a count. blocks + real model id) and a closing ``ResultMessage`` (provider-reported
``usage`` + ``total_cost_usd``). A reply without usage is passed through as
``None`` so the meter fails CLOSED (§8) this client never invents a count.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -17,10 +17,11 @@ from __future__ import annotations
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import pytest import pytest
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock, ThinkingBlock
from portfolio_optimiser_claude import sdk_client from portfolio_optimiser_claude import sdk_client
from portfolio_optimiser_claude.contracts import ModelMapContract from portfolio_optimiser_claude.contracts import ModelMapContract
from portfolio_optimiser_claude.sdk_client import SdkModelClient, build_call_options from portfolio_optimiser_claude.sdk_client import SdkModelClient, _total_tokens, build_call_options
class TestBuildCallOptions: class TestBuildCallOptions:
@ -32,8 +33,11 @@ class TestBuildCallOptions:
assert options.setting_sources == [] assert options.setting_sources == []
def test_the_system_prompt_is_empty(self) -> None: def test_the_system_prompt_is_empty(self) -> None:
# None serializes to --system-prompt "" (verified against 0.2.110): # Pins the OPTION value: None, not the Claude Code preset. That None
# no Claude Code preset, no appended operator instructions. # reaches the spawned CLI as --system-prompt "" was verified by
# READING subprocess_cli.py (0.2.1100.2.120) — this test does NOT
# bind that transport serialization; doing so would couple the suite
# to SDK-private API (the F11 fragility this repo retired).
options = build_call_options("model-x", max_budget_usd=0.25) options = build_call_options("model-x", max_budget_usd=0.25)
assert options.system_prompt is None assert options.system_prompt is None
@ -75,3 +79,148 @@ class TestCompleteThreadsIsolatedOptions:
assert captured["options"].model == "model-default" assert captured["options"].model == "model-default"
# No usage surfaced by the fake → the reply fails CLOSED (§8). # No usage surfaced by the fake → the reply fails CLOSED (§8).
assert reply.usage_tokens is None assert reply.usage_tokens is None
def _stream_of(*messages: Any) -> Any:
"""A fake ``query`` yielding a scripted stream of REAL SDK message objects."""
def fake_query(*, prompt: str, options: Any) -> AsyncIterator[Any]:
async def _stream() -> AsyncIterator[Any]:
for message in messages:
yield message
return _stream()
return fake_query
def _assistant(*blocks: Any, model: str = "model-real", error: Any = None) -> AssistantMessage:
return AssistantMessage(content=list(blocks), model=model, error=error)
def _result(
usage: dict[str, Any] | None = None,
total_cost_usd: float | None = None,
is_error: bool = False,
subtype: str = "success",
errors: list[str] | None = None,
) -> ResultMessage:
return ResultMessage(
subtype=subtype,
duration_ms=1,
duration_api_ms=1,
is_error=is_error,
num_turns=1,
session_id="s",
usage=usage,
total_cost_usd=total_cost_usd,
errors=errors,
)
def _client() -> SdkModelClient:
return SdkModelClient(ModelMapContract(profiles={"anthropic": {"default": "model-default"}}))
_FULL_USAGE = {
"input_tokens": 10,
"output_tokens": 5,
"cache_creation_input_tokens": 3,
"cache_read_input_tokens": 2,
}
class TestCompleteAsyncStreamBinding:
"""C2.5 (R-4/R-5): the read loop is BOUND offline with real SDK message types.
Before C2.5 nothing in the suite executed sdk_client's aggregation,
error, usage or cost branches the fake stream (real ``AssistantMessage``
/ ``ResultMessage`` / ``TextBlock`` objects, so constructor drift also
goes red) binds every branch without a key or the network.
"""
def test_text_aggregates_and_non_text_blocks_are_ignored(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
sdk_client,
"query",
_stream_of(
_assistant(TextBlock("{"), ThinkingBlock(thinking="hmm", signature="sig")),
_assistant(TextBlock("}")),
_result(usage=_FULL_USAGE, total_cost_usd=0.01),
),
)
client = _client()
reply = client.complete("p", role="proposer")
assert reply.text == "{}"
assert reply.model == "model-real"
assert client.last_model == "model-real"
def test_usage_tokens_sum_the_four_provider_fields(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
sdk_client,
"query",
_stream_of(_assistant(TextBlock("ok")), _result(usage=_FULL_USAGE)),
)
assert _client().complete("p", role="proposer").usage_tokens == 20
def test_cost_accumulates_across_calls(self, monkeypatch: pytest.MonkeyPatch) -> None:
client = _client()
for cost in (0.01, 0.02):
monkeypatch.setattr(
sdk_client,
"query",
_stream_of(_assistant(TextBlock("ok")), _result(total_cost_usd=cost)),
)
client.complete("p", role="proposer")
assert client.total_cost_usd == pytest.approx(0.03)
def test_an_assistant_error_fails_the_call(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
sdk_client, "query", _stream_of(_assistant(TextBlock("x"), error="rate_limit"))
)
with pytest.raises(RuntimeError, match="rate_limit"):
_client().complete("p", role="proposer")
def test_a_result_error_fails_the_call_naming_subtype_and_errors(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
sdk_client,
"query",
_stream_of(
_assistant(TextBlock("x")),
_result(is_error=True, subtype="error_during_execution", errors=["boom"]),
),
)
with pytest.raises(RuntimeError, match="error_during_execution.*boom"):
_client().complete("p", role="proposer")
class TestTotalTokensFailsClosed:
"""§8: the meter is never fed an invented count — no usage stays ``None``."""
def test_no_usage_dict_is_none(self) -> None:
assert _total_tokens(None) is None
def test_an_empty_usage_dict_is_none(self) -> None:
assert _total_tokens({}) is None
def test_non_int_fields_are_ignored_not_coerced(self) -> None:
assert _total_tokens({"input_tokens": "10"}) is None
assert _total_tokens({"input_tokens": 10, "output_tokens": "x"}) == 10
class TestBudgetGuard:
"""§8: a non-positive per-call USD cap is refused at construction."""
@pytest.mark.parametrize("cap", [0.0, -0.5])
def test_non_positive_caps_are_rejected(self, cap: float) -> None:
with pytest.raises(ValueError):
SdkModelClient(
ModelMapContract(profiles={"anthropic": {"default": "m"}}),
max_budget_usd_per_call=cap,
)

View file

@ -0,0 +1,82 @@
"""SDK version guard (C2.5, closes C-N3) — LOAD-BEARING (§11).
The seam this file keeps alive: every SDK attribute premise in
``sdk_client.py`` was verified against a CONCRETE version range
(0.2.110 read at source level, release notes through 0.2.120; sdk-review
2026-07-16). The pin ``claude-agent-sdk>=0.2.111,<0.3`` freezes that range
this guard makes an upgrade outside it a RED test naming exactly which
premises must be re-verified, instead of a silent behaviour drift.
Offline-safe: reads installed package metadata only no key, no network.
"""
from __future__ import annotations
import importlib.metadata
from pathlib import Path
import pytest
_PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml"
# The verified range — MUST match the pyproject pin (bound below).
_VERIFIED_FLOOR = (0, 2, 111)
_VERIFIED_CEILING = (0, 3)
_PIN = "claude-agent-sdk>=0.2.111,<0.3"
# The sdk_client.py attribute premises the verified range vouches for
# (sdk-review 2026-07-16, verified against package source through 0.2.120).
_SDK_PREMISES = (
"AssistantMessage.error/.model/.content",
"ResultMessage.usage/.total_cost_usd/.is_error/.subtype/.errors",
"ClaudeAgentOptions.max_budget_usd/.setting_sources/.system_prompt/.max_turns/.model/.tools",
"TextBlock.text",
"query() yields AssistantMessage then a closing ResultMessage",
)
def _parse(raw: str) -> tuple[int, ...]:
return tuple(int(part) for part in raw.split(".")[:3])
def check_sdk_version() -> str:
"""Fail if the installed SDK is outside the verified range — naming the premises."""
raw = importlib.metadata.version("claude-agent-sdk")
if not (_VERIFIED_FLOOR <= _parse(raw) < _VERIFIED_CEILING):
raise AssertionError(
f"claude-agent-sdk {raw} is OUTSIDE the verified range >=0.2.111,<0.3. "
"Re-verify the sdk_client.py attribute premises against the new version "
"BEFORE widening the pin (pyproject.toml + this guard together): "
+ "; ".join(_SDK_PREMISES)
)
return raw
class TestSdkVersionGuard:
def test_the_installed_sdk_is_within_the_verified_range(self) -> None:
assert check_sdk_version() == importlib.metadata.version("claude-agent-sdk")
def test_a_version_above_the_ceiling_trips_the_guard(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.3.0")
with pytest.raises(AssertionError) as err:
check_sdk_version()
message = str(err.value)
assert "sdk_client.py" in message
for premise in _SDK_PREMISES:
assert premise in message
def test_a_version_below_the_floor_trips_the_guard(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# 0.2.110 works but misses the 0.2.111 read-loop fixes (NDJSON
# whitespace on >64 KiB lines, plain-string content TypeError,
# zombie subprocess on cancellation) — below the floor is unverified.
monkeypatch.setattr(importlib.metadata, "version", lambda name: "0.2.110")
with pytest.raises(AssertionError):
check_sdk_version()
def test_the_pyproject_pin_matches_the_verified_range(self) -> None:
# Detach-proof: the pin and this guard cannot drift apart silently.
assert _PIN in _PYPROJECT.read_text(encoding="utf-8")

View file

@ -13,7 +13,9 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
import pytest
from _scripted import ScriptedClient, reply from _scripted import ScriptedClient, reply
from pydantic import ValidationError
from portfolio_optimiser_claude.budget import BudgetMeter from portfolio_optimiser_claude.budget import BudgetMeter
from portfolio_optimiser_claude.contracts import TerminationContract from portfolio_optimiser_claude.contracts import TerminationContract
@ -25,6 +27,7 @@ from portfolio_optimiser_claude.experience import (
mint_verdict_id, mint_verdict_id,
) )
from portfolio_optimiser_claude.inbox import ( from portfolio_optimiser_claude.inbox import (
InboxLimitError,
ProposalFeatures, ProposalFeatures,
VerdictDocument, VerdictDocument,
load_inbox, load_inbox,
@ -162,6 +165,98 @@ class TestTolerantLoad:
assert [d.id for d in load_inbox(tmp_path)] == sorted([late.id, early.id]) 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: class TestMergeIntoStore:
"""§5: merge, never replace — first-write-wins per id, idempotent, read-only.""" """§5: merge, never replace — first-write-wins per id, idempotent, read-only."""

View file

@ -203,6 +203,18 @@ class TestPathSafety:
assert not (tmp_path / "evil").exists() assert not (tmp_path / "evil").exists()
assert "/" not in path.name and "\\" not in path.name assert "/" not in path.name and "\\" not in path.name
def test_sanitised_ids_never_collide_with_a_distinct_id_owning_the_name(
self, bundle: Path
) -> None:
# C2.5 restarbeid-funn 2: 'e/vil' used to sanitise to 'evil' — the
# SAME promoted filename as the distinct id 'evil', so the second
# promotion silently overwrote the first curated verdict.
first = _promote(_document(verdict_id="e/vil", rationale=f"one ({MARKER})"), bundle)
second = _promote(_document(verdict_id="evil", rationale=f"two ({MARKER})"), bundle)
assert first != second
assert parse_concept_file(first).frontmatter["verdict_id"] == "e/vil"
assert parse_concept_file(second).frontmatter["verdict_id"] == "evil"
def test_degenerate_token_falls_back_to_a_content_hash(self, bundle: Path) -> None: def test_degenerate_token_falls_back_to_a_content_hash(self, bundle: Path) -> None:
path = _promote(_document(verdict_id="///"), bundle) path = _promote(_document(verdict_id="///"), bundle)
assert path.parent == bundle assert path.parent == bundle

18
uv.lock generated
View file

@ -177,7 +177,7 @@ wheels = [
[[package]] [[package]]
name = "claude-agent-sdk" name = "claude-agent-sdk"
version = "0.2.110" version = "0.2.120"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
@ -185,13 +185,13 @@ dependencies = [
{ name = "sniffio" }, { name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/bb/98/8fdab35ed9e1a36bc7afab4d390cc5002094a4950996c079da9aa4541cc4/claude_agent_sdk-0.2.110.tar.gz", hash = "sha256:538b548bac07a22f65686abab063a902ac76ba35989d0f073c942f96248e9fa3", size = 255632, upload-time = "2026-06-24T22:11:52.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/eb/7f/7b69aed292a4edecae132e4dbe6b6decb4e88ec142fc91d117b19058c9e0/claude_agent_sdk-0.2.120.tar.gz", hash = "sha256:e428552f79a76e0d85789369eeb58249b33f350200124e5fc86b24168bd00805", size = 268639, upload-time = "2026-07-15T23:18:50.997Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/93/29d4fdaa13e69034faf8d3503df915b07c820e2c08e3d6a7515149cde5bb/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fed0e0f4804d9f9cff80ab7d1b44142ebd1046cdd29ca74caef4c92c35fff8d8", size = 64924533, upload-time = "2026-06-24T22:11:55.612Z" }, { url = "https://files.pythonhosted.org/packages/80/85/5e8958704db0f8195e63f8ec4a80c5fb14756edc785bb3535e0dc5d91104/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ead9fb4bdaf70069978703ec6d74b30bd269e9632a4aea4dc8c4e999ac3a1b", size = 71110966, upload-time = "2026-07-15T23:18:54.743Z" },
{ url = "https://files.pythonhosted.org/packages/aa/03/b40bb673cd93cdc3928262c1be75fde34a7bed4bf2c2c20e04218e2005ea/claude_agent_sdk-0.2.110-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:62b23869d46cef6f6ff1d00ceaa5e846e2f1d297478421c835efb8fe99369d4f", size = 69704449, upload-time = "2026-06-24T22:11:59.149Z" }, { url = "https://files.pythonhosted.org/packages/c1/53/c6cdad82ac100c8a45887999614e9fe206b77b43790dacc6de42b156ad4c/claude_agent_sdk-0.2.120-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:1248591c7bffeb6e10e8cd169e0766854951fba8816e3e7d81a003f8bfca6f08", size = 76067995, upload-time = "2026-07-15T23:18:58.398Z" },
{ url = "https://files.pythonhosted.org/packages/f9/18/ab67cb5ce641333385bed55ed8e9665c00f7d30d1f6ab12f8463ddb7695f/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:324e49553c6303d6b267217dc2912652b97af2bc96503efd12095ae915b46b83", size = 74879555, upload-time = "2026-06-24T22:12:03.25Z" }, { url = "https://files.pythonhosted.org/packages/bf/f8/248e3f58d0f0aa7d76bd34b11b18135cc124f5b9a9b55219cc1ca03d662a/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:abc73ccdf3decca566cd18084e74bc2f2d10b8b77cc1fd5ed4299d5c15e5078b", size = 81027434, upload-time = "2026-07-15T23:19:03.318Z" },
{ url = "https://files.pythonhosted.org/packages/91/88/3627d7d14310cfec66977551263e219365244a906fc7ca1209fb0c3a6cec/claude_agent_sdk-0.2.110-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:56371dd7a2c66c0bd497dc0b3cab4193a228b196f600676393d69c0ecee37cfb", size = 75924237, upload-time = "2026-06-24T22:12:07.183Z" }, { url = "https://files.pythonhosted.org/packages/11/59/6adb0c53534646f1d5ddc41226ff37b2adc413a472e2f87a9011548a137f/claude_agent_sdk-0.2.120-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:888070c246c92e102c52001d26532cd3646a700656d7c368f2f91a1d3c16b534", size = 82084704, upload-time = "2026-07-15T23:19:08.729Z" },
{ url = "https://files.pythonhosted.org/packages/49/79/c9066c5c387d42c19a4b675ec1ff5219f8920cfda8ff8b527119fd69b774/claude_agent_sdk-0.2.110-py3-none-win_amd64.whl", hash = "sha256:4235d4de6d685a189c12612095ab192b759280ede1f3aed0c3e784d52c3555f9", size = 75448209, upload-time = "2026-06-24T22:12:11.283Z" }, { url = "https://files.pythonhosted.org/packages/2a/06/036b8dce1e86ecd5e2e1ddc281736cdb33bad6d24e748b9553e235b028fe/claude_agent_sdk-0.2.120-py3-none-win_amd64.whl", hash = "sha256:bc1441c94f60c9e7b4b8c641742fedf68451f573062ef69dd43d86a61b1fb219", size = 81958518, upload-time = "2026-07-15T23:19:13.082Z" },
] ]
[[package]] [[package]]
@ -277,7 +277,7 @@ name = "exceptiongroup"
version = "1.3.1" version = "1.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [ wheels = [
@ -601,7 +601,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "claude-agent-sdk", specifier = ">=0.2" }, { name = "claude-agent-sdk", specifier = ">=0.2.111,<0.3" },
{ name = "pydantic", specifier = ">=2" }, { name = "pydantic", specifier = ">=2" },
] ]