mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
33865ca9d2
Multi-stage /review on the full Phase 1+2+3+4 stack surfaced 9 findings (0 critical, 3 major, 5 minor, 1 nit, 1 uncertain). All applied. Major * perf-1 (session_routes.py:2402): make_history_handler ran sync storage.load_workstream_config inside async def history on the cold- workstream path, blocking the event loop on every dashboard /history request for non-resident workstreams. Every other storage call in the same handler correctly used asyncio.to_thread. Wrap the sync call in asyncio.to_thread (preserving the existing try/except so a DB failure still degrades to the conservative-default branch instead of bubbling out). * q-2 (test_reasoning_audit_log_discipline.py): the security-sensitive test (reasoning text never lands at INFO+ severity) only covered the 4 Phase 1 surfaces. Phase 2 added the strip predicate in AnthropicProvider._convert_messages and Phase 3 added 3 more code paths that touch reasoning text — none guarded. Added 4 parallel tests using the existing capture-and-walk infrastructure: OpenAIResponsesProvider.extract_reasoning_text, OpenAIChatCompletionsProvider.extract_reasoning_text, ChatSession._stream_response (drives the synth-block stamp via a fake reasoning-emitting stream), AnthropicProvider._convert_messages with replay_reasoning_to_model=False (drives the Phase 2 strip predicate). * q-1 (model_registry.py:42): the persist_reasoning flag name implied storage-control but actually gates UI rehydration only — operators flipping it could reasonably expect "stop persisting reasoning" but storage of reasoning bytes happens in provider_data regardless. Renamed everywhere to surface_persisted_reasoning: ModelConfig field, migration 052 column (renaming in-place since 052 is not yet on main), schema, MODEL_DEFINITION_MUTABLE allowlist, _postgresql.py + _sqlite.py CRUD impls, _protocol.py create_model_definition signature, 3 console_schemas Pydantic models, console/server.py admin POST + PUT, model_registry row mapper, history_decoration.py helper parameter, server.py _build_history local var, session_routes.py make_history_handler local var, sdk/events.py HistoryEvent docstring, admin.js form id + override pill label, index.html form input id + UI label + tooltip, coordinator.js (none needed), and every test that referenced the old field name. The admin tooltip now reads "Storage of reasoning bytes is unaffected by this flag — they ride in provider_data regardless" so the decoupling stays explicit at the operator surface. Minor * bug-1 (history_decoration.py:336): dispatcher discriminated on provider_content[0]["type"] only. Anthropic's redacted_thinking blocks (sealed by the safety system) can appear before, after, or interleaved with regular thinking blocks per the API docs. When a redacted block lands first, the dispatcher returned "" and the UI silently lost the surrounding thinking text. Registered "redacted_thinking" as a second key in _BLOCK_TYPE_PROVIDER_FACTORY pointing at the same AnthropicProvider factory — the existing extractor's type=="thinking" filter already correctly skips redacted blocks while walking the full list. Regression test added. * q-3 (_protocol.py:155): replay_reasoning_to_model defaults split across 9 sites — operator-side defaults to False (matches DB server_default), provider-API defaults to True (back-compat with direct callers). Original "pick False everywhere" fix would have silently flipped behaviour for any direct provider caller. Instead documented the intentional bifurcation in the Protocol's create_streaming docstring. * q-4+q-5 (_protocol.py:107 + 3 providers): MAX_REASONING_DISPLAY_BYTES was enforced via Python str slicing which counts code points, not UTF-8 bytes — 4-byte CJK/emoji glyphs would blow past the byte ceiling. Renamed to MAX_REASONING_DISPLAY_CHARS to match actual behaviour. Hoisted the 4-line truncation pattern into a shared _join_reasoning_with_cap helper in _protocol.py; each provider's extractor becomes a single line at the tail. * q-6 (tests/_session_helpers.py): _NullUI + _make_session were duplicated verbatim between test_session_replay_reasoning.py and test_session_synth_reasoning_block.py. Hoisted to a shared tests/_session_helpers.py module (importable, leading underscore so pytest doesn't try to collect it). test_model_registry.py's _make_session has a different signature (registry/model_alias args + _FakeUI) and is not a candidate for sharing. Nit * q-7 (history_decoration.py:286): _make_provider_factory used a dict-as-cell workaround for closure read-only scope. Replaced with the more idiomatic nonlocal pattern. Lint + test gate * ruff check + ruff format -- clean. * mypy -- no issues across all 191 source files. * pytest -m 'not live' -- 6115 passed (3 deselected). Net +5 tests (4 audit-log discipline + 1 redacted_thinking dispatcher). Refinements vs the dedupe output (caught during sanity rendering the report) * perf-1 fix preserved the try/except wrapper. The original "wrap in to_thread" one-liner would have let an OperationalError bubble out instead of degrading to the fallback branch. * q-3 fix explicitly documented the bifurcation rather than collapsing both sides to False. "Pick False everywhere" would silently flip back-compat behaviour for direct provider callers. * q-1 fix included the admin.js:5292 fallback site (m.persist_reasoning !== false) that the original threaded-change list missed. * q-6 fix verified the third _make_session in test_model_registry.py is structurally different (different signature + different UI helper) and intentionally NOT a dedupe target.
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""Audit-log discipline test for reasoning text.
|
|
|
|
Phase 1 of optional reasoning persistence surfaces stored thinking
|
|
blocks on the ``/history`` payload (UI rehydration). The bytes ride
|
|
through the helper (``extract_reasoning_for_history``), through the
|
|
provider extractor (``AnthropicProvider.extract_reasoning_text``), and
|
|
through the server build path (``_build_history``).
|
|
|
|
This test pins the security-sensitive contract:
|
|
|
|
Reasoning text MAY land on ``msg["reasoning"]`` (UI-bound),
|
|
but MUST NOT appear in any ``Logger.info`` / ``warning`` /
|
|
``error`` payload at any layer in the pipeline.
|
|
|
|
The test mocks the standard-library ``logging.Logger`` info/warning/
|
|
error methods, runs a thinking-bearing turn through the relevant
|
|
extractors and history build, then asserts no captured log call's
|
|
positional args or kwargs contain the unique marker string. Replaces
|
|
the v4 grep-the-output approach (fragile when log strings are
|
|
formatted) with a structural mock-and-assert (tests the actual
|
|
contract rather than the rendered text).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
from tests._session_helpers import make_session
|
|
from turnstone.core.history_decoration import (
|
|
extract_reasoning_for_history,
|
|
extract_reasoning_text_from_provider_content,
|
|
)
|
|
from turnstone.core.providers._anthropic import AnthropicProvider
|
|
from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider
|
|
from turnstone.core.providers._openai_responses import OpenAIResponsesProvider
|
|
from turnstone.core.providers._protocol import StreamChunk, UsageInfo
|
|
from turnstone.server import _build_history
|
|
|
|
_MARKER = "SECRET_REASONING_MARKER_xyz123_unlikely_collision"
|
|
|
|
|
|
def _payload_contains_marker(args: tuple[Any, ...], kwargs: dict[str, Any]) -> bool:
|
|
"""Walk a captured log call's args + kwargs for the marker string.
|
|
|
|
Logger.info-style calls accept a format string + positional substitution
|
|
args; the marker could appear in either the format string itself or
|
|
the substitution values. Format-time strings (``%`` substitution) are
|
|
NOT inspected because they're a stdlib formatting concern, not a
|
|
callable our pipeline reaches into. The structural check is "no
|
|
user-controlled marker appears in any arg slot we passed".
|
|
"""
|
|
for a in args:
|
|
if isinstance(a, str) and _MARKER in a:
|
|
return True
|
|
# Defensive — a list/dict/exception arg might carry the marker too.
|
|
try:
|
|
if _MARKER in repr(a):
|
|
return True
|
|
except Exception:
|
|
continue
|
|
for v in kwargs.values():
|
|
if isinstance(v, str) and _MARKER in v:
|
|
return True
|
|
try:
|
|
if _MARKER in repr(v):
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
def _capture_log_calls():
|
|
"""Capture every Logger.info / warning / error call into a single list."""
|
|
captured: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
|
|
|
def make_recorder(level: str):
|
|
def _rec(*args: Any, **kwargs: Any) -> None:
|
|
captured.append((level, args, kwargs))
|
|
|
|
return _rec
|
|
|
|
return captured, [
|
|
patch.object(logging.Logger, "info", side_effect=make_recorder("info"), autospec=True),
|
|
patch.object(
|
|
logging.Logger, "warning", side_effect=make_recorder("warning"), autospec=True
|
|
),
|
|
patch.object(logging.Logger, "error", side_effect=make_recorder("error"), autospec=True),
|
|
]
|
|
|
|
|
|
class TestReasoningAuditLogDiscipline:
|
|
"""Reasoning text never lands at INFO+ severity on any logger."""
|
|
|
|
def _thinking_msg(self, text: str = _MARKER) -> dict[str, Any]:
|
|
return {
|
|
"role": "assistant",
|
|
"content": "Final answer.",
|
|
"_provider_content": [
|
|
{"type": "thinking", "thinking": text, "signature": "sig"},
|
|
{"type": "text", "text": "Final answer."},
|
|
],
|
|
}
|
|
|
|
def test_anthropic_extractor_does_not_log_reasoning(self) -> None:
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
provider = AnthropicProvider()
|
|
text = provider.extract_reasoning_text(
|
|
[{"type": "thinking", "thinking": _MARKER, "signature": "s"}]
|
|
)
|
|
assert text == _MARKER # extractor IS allowed to return it
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"AnthropicProvider.extract_reasoning_text leaked reasoning text "
|
|
f"into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_dispatch_helper_does_not_log_reasoning(self) -> None:
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
text = extract_reasoning_text_from_provider_content(
|
|
[{"type": "thinking", "thinking": _MARKER, "signature": "s"}]
|
|
)
|
|
assert text == _MARKER
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"extract_reasoning_text_from_provider_content leaked reasoning "
|
|
f"text into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_list_helper_does_not_log_reasoning(self) -> None:
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
messages = [self._thinking_msg(_MARKER)]
|
|
extract_reasoning_for_history(messages, surface_persisted_reasoning_flag=True)
|
|
assert messages[0]["reasoning"] == _MARKER # UI-bound is allowed
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"extract_reasoning_for_history leaked reasoning text into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_build_history_does_not_log_reasoning(self) -> None:
|
|
registry = SimpleNamespace(
|
|
get_config=lambda alias: SimpleNamespace(surface_persisted_reasoning=True)
|
|
)
|
|
session = SimpleNamespace(
|
|
messages=[self._thinking_msg(_MARKER)],
|
|
_ws_id="ws-audit",
|
|
_registry=registry,
|
|
_model_alias="claude-opus-4-7",
|
|
)
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
with patch(
|
|
"turnstone.server._load_verdict_indexes",
|
|
return_value=({}, {}),
|
|
):
|
|
history = _build_history(session)
|
|
assert history[0]["reasoning"] == _MARKER # UI-bound is allowed
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], f"_build_history leaked reasoning text into INFO+ logs: {offending}"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Phase 2 + Phase 3 surfaces — added in response to a code-review
|
|
# finding that the original 4-test coverage missed every code path
|
|
# introduced after Phase 1. Each new test mirrors the structure
|
|
# above: capture every Logger.info / warning / error call across
|
|
# the operation, assert the marker doesn't appear in any captured
|
|
# payload (UI-bound returns IS allowed; logging at INFO+ is NOT).
|
|
# ------------------------------------------------------------------
|
|
|
|
def test_openai_responses_extractor_does_not_log_reasoning(self) -> None:
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
provider = OpenAIResponsesProvider()
|
|
blocks = [
|
|
{
|
|
"type": "reasoning",
|
|
"id": "r_1",
|
|
"summary": [{"type": "summary_text", "text": _MARKER}],
|
|
}
|
|
]
|
|
text = provider.extract_reasoning_text(blocks)
|
|
assert _MARKER in text # UI-bound return is allowed
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"OpenAIResponsesProvider.extract_reasoning_text leaked reasoning "
|
|
f"text into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_openai_chat_extractor_does_not_log_reasoning(self) -> None:
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
provider = OpenAIChatCompletionsProvider()
|
|
blocks = [{"type": "reasoning_text", "text": _MARKER, "source": "vllm"}]
|
|
text = provider.extract_reasoning_text(blocks)
|
|
assert text == _MARKER
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"OpenAIChatCompletionsProvider.extract_reasoning_text leaked "
|
|
f"reasoning text into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_synth_reasoning_block_via_stream_response_does_not_log_reasoning(
|
|
self,
|
|
) -> None:
|
|
"""Drives ChatSession._stream_response (which calls
|
|
_maybe_synth_reasoning_block at end-of-stream) with a fake
|
|
``reasoning_delta=_MARKER`` chunk; asserts no log call carried
|
|
the marker text."""
|
|
session = make_session()
|
|
chunks = [
|
|
StreamChunk(reasoning_delta=_MARKER, is_first=True),
|
|
StreamChunk(content_delta="answer"),
|
|
StreamChunk(
|
|
finish_reason="stop",
|
|
usage=UsageInfo(prompt_tokens=10, completion_tokens=20, total_tokens=30),
|
|
),
|
|
]
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
msg = session._stream_response(iter(chunks))
|
|
# Synth block stamped onto _provider_content with the marker.
|
|
assert msg["_provider_content"][0]["text"] == _MARKER
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"_stream_response + _maybe_synth_reasoning_block leaked reasoning "
|
|
f"text into INFO+ logs: {offending}"
|
|
)
|
|
|
|
def test_anthropic_convert_messages_strip_does_not_log_reasoning(self) -> None:
|
|
"""Drives the Phase 2 strip predicate
|
|
(``replay_reasoning_to_model=False``) which walks thinking
|
|
blocks to filter them out before the wire payload is built;
|
|
asserts no log call carried the marker text."""
|
|
captured, patchers = _capture_log_calls()
|
|
for p in patchers:
|
|
p.start()
|
|
try:
|
|
provider = AnthropicProvider()
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "Final answer.",
|
|
"_provider_content": [
|
|
{"type": "thinking", "thinking": _MARKER, "signature": "s"},
|
|
{"type": "text", "text": "Final answer."},
|
|
],
|
|
},
|
|
]
|
|
_, converted = provider._convert_messages(messages, replay_reasoning_to_model=False)
|
|
# Strip fired — thinking block dropped from wire.
|
|
assistant = next(m for m in converted if m["role"] == "assistant")
|
|
block_types = [b.get("type") for b in assistant["content"]]
|
|
assert "thinking" not in block_types
|
|
finally:
|
|
for p in patchers:
|
|
p.stop()
|
|
offending = [
|
|
(lvl, args, kwargs)
|
|
for lvl, args, kwargs in captured
|
|
if _payload_contains_marker(args, kwargs)
|
|
]
|
|
assert offending == [], (
|
|
f"AnthropicProvider._convert_messages strip predicate leaked "
|
|
f"reasoning text into INFO+ logs: {offending}"
|
|
)
|