Security and performance fixes addressing a comprehensive review: - Server-issued HMAC-signed session cookies; client-supplied session_id ignored. Prevents session hijacking via body substitution. - Sliding-window rate limiter per IP and per session. - SessionStore with LRU eviction, idle TTL, per-session threading locks, and a hard turn cap. Bounds memory and serializes concurrent turns for the same session so FastAPI's threadpool cannot corrupt history. - Tool-use loop capped at settings.max_tool_use_iterations; Anthropic client gets an explicit timeout. No more infinite-loop credit burn. - Every tool argument is regex-validated, length-capped, and control-character-stripped. asserts replaced with ValueError so -O cannot silently disable the checks. - PII-safe warning logs: session IDs and reply bodies are hashed, never logged in clear. - hmac.compare_digest for email comparison (constant-time). - Strict Content-Security-Policy plus X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy via middleware. - Explicit handlers for anthropic.RateLimitError, APIConnectionError, APIStatusError, ValueError; static dir resolved from __file__. - Prompt cache breakpoints on the last tool schema and the last message so per-turn input cost scales linearly, not quadratically. - TypedDict handler argument shapes; direct block.name/block.id access. - functools.lru_cache on _get_client. - Anchored word-boundary regexes for out-of-scope detection to kill false positives on phrases like "I'd recommend contacting...". Literate program: - Bookly.lit.md is now the single source of truth for the five core Python files. Tangles byte-for-byte; verified via tangle.ts --verify. - Prose walkthrough, three mermaid diagrams, narrative per module. - Woven to static/architecture.html with the app's palette (background #f5f3ee) via scripts/architecture-header.html. - New GET /architecture route serves the HTML with a relaxed CSP that allows pandoc's inline styles. Available at bookly.codyborders.com/architecture. - scripts/rebuild_architecture_html.sh regenerates the HTML after edits. - code_reviews/2026-04-15-1433-code-review.md captures the review that drove these changes. All 37 tests pass.
255 lines
8.7 KiB
Python
255 lines
8.7 KiB
Python
"""Agent-layer tests: validate_reply (Layer 4) and run_turn end-to-end with a
|
|
mocked Anthropic client.
|
|
|
|
The Anthropic API is never called. Each test wires a fake `_client` onto the
|
|
agent module that produces canned response objects, so the tests assert how
|
|
the agent loop wires layers 3 and 4 together rather than what the model
|
|
actually generates.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
import agent
|
|
from agent import SAFE_FALLBACK, SESSIONS, build_system_content, run_turn, validate_reply
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock SDK objects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class MockTextBlock:
|
|
text: str
|
|
type: str = "text"
|
|
|
|
|
|
@dataclass
|
|
class MockToolUseBlock:
|
|
id: str
|
|
name: str
|
|
input: dict
|
|
type: str = "tool_use"
|
|
|
|
|
|
@dataclass
|
|
class MockResponse:
|
|
content: list[Any]
|
|
stop_reason: str = "end_turn"
|
|
|
|
|
|
class MockClient:
|
|
"""A scripted Anthropic client. Hands out the next response in `script`
|
|
each time `messages.create` is called."""
|
|
|
|
def __init__(self, script: list[MockResponse]):
|
|
self.script = list(script)
|
|
self.calls: list[dict] = []
|
|
|
|
client_self = self
|
|
|
|
class _Messages:
|
|
def create(self, **kwargs):
|
|
client_self.calls.append(kwargs)
|
|
if not client_self.script:
|
|
raise AssertionError("MockClient ran out of scripted responses")
|
|
return client_self.script.pop(0)
|
|
|
|
self.messages = _Messages()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_sessions_and_client():
|
|
SESSIONS.clear()
|
|
agent._get_client.cache_clear()
|
|
yield
|
|
SESSIONS.clear()
|
|
agent._get_client.cache_clear()
|
|
|
|
|
|
def _install_mock(monkeypatch, script: list[MockResponse]) -> MockClient:
|
|
client = MockClient(script)
|
|
monkeypatch.setattr(agent, "_get_client", lambda: client)
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_system_content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_system_content_caches_main_prompt_block():
|
|
blocks = build_system_content(turn_count=0)
|
|
assert blocks[0]["cache_control"] == {"type": "ephemeral"}
|
|
# Reminder block is present but uncached.
|
|
assert blocks[1]["text"].startswith("<reminder>")
|
|
assert "cache_control" not in blocks[1]
|
|
|
|
|
|
def test_build_system_content_adds_long_conversation_reminder_after_threshold():
|
|
short = build_system_content(turn_count=2)
|
|
long = build_system_content(turn_count=5)
|
|
assert len(long) == len(short) + 1
|
|
assert "long" in long[-1]["text"].lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_reply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_reply_passes_clean_reply():
|
|
result = validate_reply("Your order BK-10042 was delivered.", [
|
|
{"name": "lookup_order", "result": {"order": {"order_id": "BK-10042"}}},
|
|
])
|
|
assert result.ok
|
|
assert result.violations == ()
|
|
|
|
|
|
def test_validate_reply_flags_ungrounded_order_id():
|
|
result = validate_reply("Your order BK-99999 is on the way.", [])
|
|
assert not result.ok
|
|
assert "ungrounded_order_id:BK-99999" in result.violations
|
|
|
|
|
|
def test_validate_reply_flags_ungrounded_date():
|
|
result = validate_reply("It will arrive on 2026-12-25.", [])
|
|
assert not result.ok
|
|
assert any(v.startswith("ungrounded_date:") for v in result.violations)
|
|
|
|
|
|
def test_validate_reply_passes_grounded_date():
|
|
result = validate_reply("It was delivered on 2026-04-01.", [
|
|
{"name": "lookup_order", "result": {"order": {"delivered_date": "2026-04-01"}}},
|
|
])
|
|
assert result.ok
|
|
|
|
|
|
def test_validate_reply_flags_markdown_bold():
|
|
result = validate_reply("Here are your **details**.", [])
|
|
assert not result.ok
|
|
assert "markdown_leaked" in result.violations
|
|
|
|
|
|
def test_validate_reply_flags_markdown_bullet():
|
|
result = validate_reply("Items:\n- The Goldfinch\n- Sapiens", [])
|
|
assert not result.ok
|
|
assert "markdown_leaked" in result.violations
|
|
|
|
|
|
def test_validate_reply_flags_off_topic_engagement():
|
|
result = validate_reply(
|
|
"I recommend Project Hail Mary, it's a great book.",
|
|
[],
|
|
)
|
|
assert not result.ok
|
|
assert "off_topic_engagement" in result.violations
|
|
|
|
|
|
def test_validate_reply_allows_refusal_template_even_with_keywords():
|
|
reply = "I can help with order status, returns, and our standard policies, but I'm not able to help with book recommendations. Is there an order or a policy question I can help you with instead?"
|
|
result = validate_reply(reply, [])
|
|
assert result.ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_turn end-to-end with mocked client
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_turn_returns_simple_text_reply(monkeypatch):
|
|
_install_mock(monkeypatch, [
|
|
MockResponse(content=[MockTextBlock(text="Hi! How can I help with an order today?")]),
|
|
])
|
|
reply = run_turn("session-1", "hi there")
|
|
assert "How can I help" in reply
|
|
session = SESSIONS["session-1"]
|
|
assert session.turn_count == 1
|
|
assert session.history[-1]["role"] == "assistant"
|
|
|
|
|
|
def test_run_turn_with_tool_use_loop(monkeypatch):
|
|
"""Two-step loop: model asks for a tool, then produces a final reply."""
|
|
first = MockResponse(
|
|
stop_reason="tool_use",
|
|
content=[
|
|
MockToolUseBlock(
|
|
id="toolu_1",
|
|
name="lookup_order",
|
|
input={"order_id": "BK-10042"},
|
|
)
|
|
],
|
|
)
|
|
second = MockResponse(
|
|
content=[MockTextBlock(text="Your order BK-10042 was delivered.")],
|
|
)
|
|
client = _install_mock(monkeypatch, [first, second])
|
|
reply = run_turn("session-2", "Where is BK-10042?")
|
|
assert "BK-10042" in reply
|
|
assert len(client.calls) == 2
|
|
# History must contain: user, assistant(tool_use), user(tool_result), assistant(text)
|
|
history = SESSIONS["session-2"].history
|
|
assert history[0]["role"] == "user"
|
|
assert history[1]["role"] == "assistant"
|
|
assert history[2]["role"] == "user" # tool_result is a user-role message
|
|
assert history[3]["role"] == "assistant"
|
|
|
|
|
|
def test_run_turn_drops_hallucinated_reply_and_returns_safe_fallback(monkeypatch):
|
|
"""A reply that mentions an order ID never seen by a tool must trigger
|
|
SAFE_FALLBACK, and the bad reply must not be appended to history."""
|
|
_install_mock(monkeypatch, [
|
|
MockResponse(content=[MockTextBlock(text="Your order BK-99999 will arrive on 2026-12-25.")]),
|
|
])
|
|
reply = run_turn("session-3", "where is my order")
|
|
assert reply == SAFE_FALLBACK
|
|
history = SESSIONS["session-3"].history
|
|
# Only the user message should be in history; no hallucinated assistant.
|
|
assert len(history) == 1
|
|
assert history[0]["role"] == "user"
|
|
|
|
|
|
def test_run_turn_passes_through_refusal_template(monkeypatch):
|
|
refusal = "I can help with order status, returns, and our standard policies, but I'm not able to help with book recommendations. Is there an order or a policy question I can help you with instead?"
|
|
_install_mock(monkeypatch, [
|
|
MockResponse(content=[MockTextBlock(text=refusal)]),
|
|
])
|
|
reply = run_turn("session-4", "recommend a mystery novel")
|
|
assert reply == refusal
|
|
assert SESSIONS["session-4"].turn_count == 1
|
|
|
|
|
|
def test_run_turn_layer_3_blocks_initiate_return_without_eligibility(monkeypatch):
|
|
"""If the model jumps straight to initiate_return, the tool refuses with
|
|
eligibility_not_verified, and the model can recover on the next iteration.
|
|
|
|
Here we script a model that immediately calls initiate_return, then on the
|
|
follow-up produces a clean text reply that quotes the error message.
|
|
"""
|
|
first = MockResponse(
|
|
stop_reason="tool_use",
|
|
content=[
|
|
MockToolUseBlock(
|
|
id="toolu_1",
|
|
name="initiate_return",
|
|
input={
|
|
"order_id": "BK-10042",
|
|
"customer_email": "sarah.chen@example.com",
|
|
"reason": "test",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
second = MockResponse(
|
|
content=[MockTextBlock(text="I need to check return eligibility first. Could you confirm the email on the order?")],
|
|
)
|
|
_install_mock(monkeypatch, [first, second])
|
|
reply = run_turn("session-5", "return BK-10042")
|
|
assert "eligibility" in reply.lower() or "email" in reply.lower()
|
|
# Verify the tool actually refused: nothing should be in returns_initiated.
|
|
session = SESSIONS["session-5"]
|
|
assert "BK-10042" not in session.guard_state.returns_initiated
|