|
| 1 | +"""C(b): the Stage-2 verifier must not accept a `finish` tool call from a TRUNCATED |
| 2 | +turn (stop_reason == "max_tokens") as a completed verdict. |
| 3 | +
|
| 4 | +Before this fix the block loop harvested a `finish` ToolUseBlock regardless of |
| 5 | +stop_reason, so a `max_tokens`-truncated reply carrying a well-formed |
| 6 | +`finish(agree=False, correct_finding="safe")` was parsed as a COMPLETE verdict |
| 7 | +(`incomplete=False`) and downgraded a Stage-1 `vulnerable` to `safe` silently — the |
| 8 | +verify-stage tail of the same silent-false-negative family the adapter BUG-2/BUG-7 |
| 9 | +fixes address (the adapter now honestly reports truncation as `max_tokens`; the |
| 10 | +verifier must gate on it). Offline stub adapters; no real LLM calls. |
| 11 | +""" |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import sys |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +import pytest |
| 18 | + |
| 19 | +_CORE_ROOT = Path(__file__).resolve().parents[1] |
| 20 | +sys.path.insert(0, str(_CORE_ROOT)) |
| 21 | + |
| 22 | +from utilities.agentic_enhancer.repository_index import RepositoryIndex |
| 23 | +from utilities.finding_verifier import FindingVerifier, VerificationResult |
| 24 | +from utilities.llm import PhaseBinding, ToolUseBlock |
| 25 | +from utilities.llm.adapter import CompletionResult |
| 26 | +from utilities.llm_client import reset_warning_state |
| 27 | + |
| 28 | +STAGE1_FINDING = "vulnerable" |
| 29 | + |
| 30 | + |
| 31 | +@pytest.fixture(autouse=True) |
| 32 | +def _reset(): |
| 33 | + reset_warning_state() |
| 34 | + yield |
| 35 | + reset_warning_state() |
| 36 | + |
| 37 | + |
| 38 | +def _verify(adapter) -> VerificationResult: |
| 39 | + binding = PhaseBinding(phase="verify", adapter=adapter, model="claude-x", provider_name="anthropic") |
| 40 | + v = FindingVerifier(index=RepositoryIndex({}, repo_path=None), binding=binding) |
| 41 | + return v.verify_result(code="x = 1", finding=STAGE1_FINDING, attack_vector="a", reasoning="r") |
| 42 | + |
| 43 | + |
| 44 | +class _TruncatedFinishAdapter: |
| 45 | + """A finish(agree=False, safe) call on a turn the model truncated at max_tokens.""" |
| 46 | + name = "anthropic" |
| 47 | + supports_tools = True |
| 48 | + pricing = {"claude-x": {"input": 1.0, "output": 1.0}} |
| 49 | + |
| 50 | + def complete(self, *, model, system, messages, max_tokens, tools=None): |
| 51 | + return CompletionResult( |
| 52 | + content=[ToolUseBlock(id="t1", name="finish", |
| 53 | + input={"agree": False, "correct_finding": "safe"})], |
| 54 | + input_tokens=1, output_tokens=1, stop_reason="max_tokens", |
| 55 | + ) |
| 56 | + |
| 57 | + |
| 58 | +class _CompleteFinishAdapter: |
| 59 | + """Regression guard: a finish on a NORMAL turn (tool_use) is still accepted.""" |
| 60 | + name = "anthropic" |
| 61 | + supports_tools = True |
| 62 | + pricing = {"claude-x": {"input": 1.0, "output": 1.0}} |
| 63 | + |
| 64 | + def complete(self, *, model, system, messages, max_tokens, tools=None): |
| 65 | + return CompletionResult( |
| 66 | + content=[ToolUseBlock(id="t1", name="finish", |
| 67 | + input={"agree": True, "correct_finding": "vulnerable"})], |
| 68 | + input_tokens=1, output_tokens=1, stop_reason="tool_use", |
| 69 | + ) |
| 70 | + |
| 71 | + |
| 72 | +def test_truncated_finish_at_max_tokens_is_incomplete_not_safe(): |
| 73 | + r = _verify(_TruncatedFinishAdapter()) |
| 74 | + assert r.incomplete is True # must NOT be a completed verdict |
| 75 | + assert r.correct_finding == STAGE1_FINDING # Stage-1 verdict preserved, not "safe" |
| 76 | + assert r.agree is False |
| 77 | + |
| 78 | + |
| 79 | +def test_complete_finish_still_accepted(): |
| 80 | + r = _verify(_CompleteFinishAdapter()) |
| 81 | + assert r.incomplete is False # a normal finish is still a real verdict |
| 82 | + assert r.agree is True |
0 commit comments