mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
3233719856
Adds a second, LLM-driven stage to the output guard so domain-camouflaged prompt-injection payloads that the regex stage misses (arXiv:2605.22001 — Llama 3.1 8B evades the existing regex set on ~90% of camouflaged prompts) get caught before the tool output lands in the assistant's context. ## Surface * New `OutputGuardJudge` in `turnstone/core/output_guard_judge.py` — synchronous, single-shot LLM call. Inlines the alias-resolution + client-config + JSON-parsing helpers (copied verbatim from `IntentJudge` at `judge.py:917-969` / `1604-1659`) rather than going through a shared module — when `IntentJudge` lifts its own helpers, both copies move together. * JSON-in-content verdict with a 3-strategy parser (direct / markdown fence / balanced braces). `IntentJudge` ships a 4th regex-field fallback; OutputGuardJudge deliberately doesn't, because strategy-4 hits on broken LLM output can extract a "verdict" from the model's reasoning quote that lands in storage looking identical to a clean strategy-1 result. Failure of all three returns `error="unparseable_verdict"` and the heuristic stage stands. * `OutputJudgeVerdict` is a frozen dataclass with: `risk_level` (none/low/medium/high — normalises `critical`→`high` and `info[rmational]`→`low` for IntentJudge-echo safety), `flags: tuple[str, ...]`, `reasoning`, `confidence: float` (0.0-1.0, parsed + clamped from the LLM's self-report; pass-through to audit, no threshold gating), `judge_model`, `latency_ms`, `error`. * Real wall-clock timeout via `ThreadPoolExecutor.shutdown(wait=False, cancel_futures=True)` on the timeout/cancel path — `with ... as ex:` would block return until the worker drained. 1s `cancel_event` poll mirrors `IntentJudge._run_judge` at `judge.py:1117-1118`. * HTTP client lazy-init + reuse for the judge instance's lifetime. Session-side model swap drops the entire judge, dropping the client with it. * Untrusted tool output wrapped in per-call random-nonced `<tool_output_NONCE>...</tool_output_NONCE>` fence. Closing-tag substrings in the raw text are case-insensitively backslash-escaped first (`</tool_output` → `<\/tool_output`) so an attacker can't break out even if they guess the nonce. System prompt classifies the fenced region as UNTRUSTED DATA so directives inside are evaluated as content, not obeyed. * Judge user prompt carries the heuristic verdict (risk + flags + annotations), the tool description (looked up from the session's tools registry), and the tool args (truncated to 500 chars, also classified UNTRUSTED in the system prompt since they may be caller-supplied). Lets the judge defer to the regex on credential leaks and focus on injection signals the regex set misses; also enables output-vs-request plausibility reasoning. ## Session integration * `_evaluate_output(call_id, output, func_name, *, tool_args="")` — heuristic always runs; LLM stage runs when `judge.output_guard_llm` is enabled. When the LLM produces a usable verdict and the heuristic didn't detect credentials, the LLM verdict is acted on; otherwise the heuristic stands. * Credential redaction is a regex-only signal. When `heuristic. sanitized` is non-None, the heuristic owns the acted assessment regardless of what the LLM said — an LLM asked about prompt- injection can correctly label a credential-bearing output as "none" risk for injection, but the secret still needs redaction. * `_batch_evaluate_outputs` runs the per-tool guard concurrently (4-worker pool) when LLM is enabled and there are ≥2 string outputs — collapses N×LLM-latency to ⌈N/4⌉×latency on the common 5-20 tool-calls-per-turn turn. * Per-session `TokenBucket(rate=1.0, burst=60)` caps adversarial LLM-fan-out cost at 60 calls/min/session. * Pre-truncation: the per-tool loop truncates output before the judge sees it, so the judge evaluates exactly what enters the assistant's context (no wasted tokens on text that won't land). * Both heuristic and LLM tier rows persisted to `output_assessments` when the LLM ran (audit completeness); heuristic-only rows skip when matched-clean to keep the table focused. ## Storage Migration 057 extends `output_assessments` with five LLM-tier columns: `tier` (`heuristic` / `llm`, backfilled to `heuristic`), `reasoning`, `judge_model`, `latency_ms`, `confidence`. Tie-break on `(created DESC, tier='llm' first)` so downstream consumers see the acted verdict first when the two rows tie at second resolution. `StorageBackend.record_output_assessment` + sqlite/pg implementations + `SessionUIBase.record_output_assessment` + `SessionUI` protocol + the test stub overrides (cli, eval, 9 test files) all take the new LLM-tier kwargs. ## Config surface Three new judge.* settings in `settings_registry`: * `judge.output_guard_llm` (bool, default False) — capability gate. Default off; operators opt in once a small/fast model is pointed at `output_guard_model`. * `judge.output_guard_model` (str, default "") — alias for the LLM stage. Empty inherits the session model (same fallback shape as `judge.model`). * `judge.output_guard_llm_timeout` (float, default 30.0, min 1.0) — wall-clock budget per call. Both `server.py` and `console/session_factory.py` wire these into the `JudgeConfig` they hand to `ChatSession`. ## Notes * No backwards-compatibility shims — the LLM stage is purely additive. * No reasoning/threshold gating on confidence; it rides as an audit-only signal per maintainer direction. Surface it in the `on_output_warning` dict so live UI / cluster broadcast can sort flagged outputs by judge certainty. * Tests: 392 lines of judge-only coverage (`test_output_guard_judge. py`) + 629 lines of session-integration coverage in `test_session. py`, plus the storage and stub-shape updates.
181 lines
7.6 KiB
Python
181 lines
7.6 KiB
Python
"""Tests for output assessment storage operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
def _make_assessment_kwargs(**overrides):
|
|
"""Build default kwargs for record_output_assessment."""
|
|
defaults = {
|
|
"assessment_id": "oa_001",
|
|
"ws_id": "ws-abc",
|
|
"call_id": "tc_001",
|
|
"func_name": "bash",
|
|
"flags": '["credential_leak"]',
|
|
"risk_level": "high",
|
|
"annotations": "[]",
|
|
"output_length": 256,
|
|
"redacted": False,
|
|
}
|
|
defaults.update(overrides)
|
|
return defaults
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRUD Operations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOutputAssessmentCRUD:
|
|
def test_record_and_list(self, db):
|
|
db.record_output_assessment(**_make_assessment_kwargs())
|
|
results = db.list_output_assessments()
|
|
assert len(results) == 1
|
|
assert results[0]["assessment_id"] == "oa_001"
|
|
assert results[0]["ws_id"] == "ws-abc"
|
|
assert results[0]["func_name"] == "bash"
|
|
assert results[0]["risk_level"] == "high"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Count queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOutputAssessmentCount:
|
|
def test_count_basic(self, db):
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3"))
|
|
assert db.count_output_assessments() == 3
|
|
|
|
def test_count_empty(self, db):
|
|
assert db.count_output_assessments() == 0
|
|
|
|
def test_count_with_ws_id(self, db):
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1", ws_id="ws-1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2", ws_id="ws-1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3", ws_id="ws-2"))
|
|
assert db.count_output_assessments(ws_id="ws-1") == 2
|
|
|
|
def test_count_with_risk_level(self, db):
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa1", risk_level="low")
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa2", risk_level="high")
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa3", risk_level="high")
|
|
)
|
|
assert db.count_output_assessments(risk_level="high") == 2
|
|
|
|
def test_count_with_since(self, db):
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2"))
|
|
|
|
future = (datetime.now(UTC) + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
assert db.count_output_assessments(since=future) == 0
|
|
|
|
def test_count_with_until(self, db):
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2"))
|
|
|
|
past = "2020-01-01T00:00:00"
|
|
assert db.count_output_assessments(until=past) == 0
|
|
|
|
def test_count_with_date_range(self, db):
|
|
now = datetime.now(UTC)
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa1"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa2"))
|
|
db.record_output_assessment(**_make_assessment_kwargs(assessment_id="oa3"))
|
|
|
|
one_minute_ago = (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
one_minute_later = (now + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
assert db.count_output_assessments(since=one_minute_ago, until=one_minute_later) == 3
|
|
|
|
def test_count_matches_list_length(self, db):
|
|
"""Count with filters matches the length of list with same filters."""
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa1", ws_id="ws-1", risk_level="high")
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa2", ws_id="ws-1", risk_level="low")
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa3", ws_id="ws-2", risk_level="high")
|
|
)
|
|
|
|
now = datetime.now(UTC)
|
|
one_minute_ago = (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
one_minute_later = (now + timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
for ws, rl, s, u in [
|
|
("ws-1", "", "", ""),
|
|
("", "high", "", ""),
|
|
("ws-1", "high", "", ""),
|
|
("ws-2", "low", "", ""),
|
|
("", "", one_minute_ago, one_minute_later),
|
|
("ws-1", "high", one_minute_ago, one_minute_later),
|
|
]:
|
|
count = db.count_output_assessments(ws_id=ws, risk_level=rl, since=s, until=u)
|
|
listed = db.list_output_assessments(ws_id=ws, risk_level=rl, since=s, until=u)
|
|
assert count == len(listed), (
|
|
f"Mismatch for ws_id={ws!r}, risk_level={rl!r}, since={s!r}, until={u!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tier tie-breaker — when heuristic and llm rows share a second-resolution
|
|
# `created` value (the common case for two rows on the same call_id), the
|
|
# llm row must sort first so downstream consumers see the acted verdict.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOutputAssessmentTierOrdering:
|
|
def test_llm_wins_tie_on_same_created(self, db):
|
|
# Two rows on the same call_id with the SAME `created` timestamp —
|
|
# without the tier tie-breaker the order is randomised by
|
|
# assessment_id (UUID). With the tie-breaker, llm sorts first.
|
|
# The insert path writes `created = now`, so back-to-back inserts
|
|
# within the same wall-clock second already tie naturally.
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(
|
|
assessment_id="oa_h",
|
|
call_id="tc_tied",
|
|
tier="heuristic",
|
|
)
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(
|
|
assessment_id="oa_l",
|
|
call_id="tc_tied",
|
|
tier="llm",
|
|
reasoning="judged",
|
|
judge_model="gpt-5-mini",
|
|
latency_ms=42,
|
|
)
|
|
)
|
|
rows = db.list_output_assessments()
|
|
# Two rows for the same call_id; llm must be first.
|
|
assert len(rows) == 2
|
|
assert rows[0]["tier"] == "llm"
|
|
assert rows[1]["tier"] == "heuristic"
|
|
|
|
def test_single_tier_ordering_unchanged(self, db):
|
|
# Single-tier rows (no LLM stage) should still sort by created DESC
|
|
# — the tie-breaker only kicks in when timestamps match exactly.
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa_old", call_id="tc_a")
|
|
)
|
|
db.record_output_assessment(
|
|
**_make_assessment_kwargs(assessment_id="oa_new", call_id="tc_b")
|
|
)
|
|
rows = db.list_output_assessments()
|
|
# Most recent first; with both at "heuristic" tier the secondary
|
|
# sort falls through to assessment_id DESC, but the key invariant
|
|
# is that listing produces both rows in a deterministic order.
|
|
assert len(rows) == 2
|
|
assert {r["assessment_id"] for r in rows} == {"oa_old", "oa_new"}
|