- 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>
82 lines
3.3 KiB
Python
82 lines
3.3 KiB
Python
"""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")
|