fix(guardrails): land the TrustGuard ask verdict as a block

TrustGuard reduces findings to block, ask, transform, report, or allow.
The hook only knew four of them, so a policy with an Ask gate made every
evaluation on that collector fail closed with 503 "unknown verdict",
which reads as an outage rather than a policy decision

A proxy has no approval flow to hand the question to, so ask now raises
the same 400 as block. The response carries the verdict so operators can
tell the two apart in the error body and in Activity
This commit is contained in:
albertbausili 2026-09-13 13:40:17 +02:00
parent 2d9b4a3eb8
commit fee2ff525d
3 changed files with 10 additions and 4 deletions

View file

@ -34,6 +34,7 @@ Each evaluate call carries `session_id` from the LiteLLM session and `consumer_i
| TrustGuard `status` | LiteLLM |
| --- | --- |
| `block` | HTTP 400 (trace_id / request_id only; findings are not echoed) |
| `ask` | HTTP 400 like `block`: a proxy has no approval flow, so the response names `verdict: ask` |
| `transform` | rewrite the last user message / last text from `transformed_payload` |
| `report` / `allow` | pass through (`report` is logged by trace_id) |

View file

@ -43,10 +43,12 @@ CONSUMER_ID_KEYS: Final = (
METADATA_ADAPTER: Final = TypeAdapter(Mapping[str, object])
EMPTY_METADATA: Final[Mapping[str, object]] = MappingProxyType({})
STATUS_BLOCK: Final = "block"
STATUS_ASK: Final = "ask"
STATUS_TRANSFORM: Final = "transform"
STATUS_REPORT: Final = "report"
STATUS_ALLOW: Final = "allow"
KNOWN_STATUSES: Final = frozenset({STATUS_ALLOW, STATUS_BLOCK, STATUS_TRANSFORM, STATUS_REPORT})
BLOCKING_STATUSES: Final = frozenset({STATUS_BLOCK, STATUS_ASK})
KNOWN_STATUSES: Final = frozenset({STATUS_ALLOW, STATUS_TRANSFORM, STATUS_REPORT, *BLOCKING_STATUSES})
UNREACHABLE_HTTP_STATUSES: Final = frozenset({502, 504})
TRANSFORM_MISSING: Final = "TrustGuard transform missing payload"
@ -245,12 +247,13 @@ class NeuralTrustGuardrail(CustomGuardrail):
return self._handle_unreachable(inputs, exc)
status: Final = result["status"]
if status == STATUS_BLOCK:
if status in BLOCKING_STATUSES:
raise HTTPException(
status_code=400,
detail={ # mutable-ok: FastAPI HTTPException.detail is a JSON object
"error": "Violated guardrail policy",
"neuraltrust_guardrail_response": "Blocked by NeuralTrust TrustGuard.",
"verdict": status,
"trace_id": result.get("trace_id"),
"request_id": result.get("request_id"),
},

View file

@ -231,12 +231,13 @@ class TestNeuralTrustGuardrail:
assert "collector_key" not in mock_post.call_args.kwargs["json"]
@pytest.mark.asyncio
async def test_block_raises_without_findings(self) -> None:
@pytest.mark.parametrize("status", ["block", "ask"])
async def test_block_and_ask_raise_without_findings(self, status: str) -> None:
guardrail = _guardrail()
mock_post = AsyncMock(
return_value=_response(
{
"status": "block",
"status": status,
"trace_id": "tr-1",
"findings": [{"outcome": {"action": "block"}, "evidence": "ssn 123-45-6789"}],
}
@ -256,6 +257,7 @@ class TestNeuralTrustGuardrail:
assert "findings" not in detail
assert "evidence" not in str(detail)
assert detail["trace_id"] == "tr-1"
assert detail["verdict"] == status
@pytest.mark.asyncio
async def test_transform_rewrites_texts(self) -> None: