"""Guardrail tuning + adversarial bypass test (REQ-IDEATE-01, TASK-04-02, G-067). Runs the LiveAssistGuardrail against the tuning corpus (tests/guardrail_corpus.py): - Coaching responses: FP rate < 5% (REQ-IDEATE-04 target). - Direct-answer responses: FN rate < 5% (the regex must catch these). - False-authority: 100% blocked (hard violation). - Adversarial: FN rate measured + reported (G-067 — ≤20% threshold for pilot, documented acceptance; residual risk mitigated by defense-in-depth + v0.6 LLM-as-judge per REQ-IDEATE-10). G-067 binding (GRILL-v0.5): the adversarial FN rate must be (a) measured pre-ship, (b) compared against a threshold, (c) the threshold + rationale documented. This test ASSERTS the measurement + the threshold; the threshold is ≤20% acceptable for pilot because defense-in-depth (prompt + regex + audit) + the v0.6 LLM-as-judge mitigate the residual risk. If the adversarial FN rate exceeds 20%, the test FAILS (prompting a re-tuning wave or escalation per G-067). """ from __future__ import annotations import asyncio import logging import pytest from server.guardrails.live_assist import LiveAssistGuardrail from server.services.base import GuardrailContext from tests.guardrail_corpus import ( ADVERSARIAL_RESPONSES, COACHING_RESPONSES, DIRECT_ANSWER_RESPONSES, FALSE_AUTHORITY_RESPONSES, ) log = logging.getLogger(__name__) # G-067 binding threshold: adversarial FN ≤ 20% acceptable for pilot. ADVERSARIAL_FN_THRESHOLD = 0.20 # REQ-IDEATE-04 targets. COACHING_FP_THRESHOLD = 0.05 # < 5% DIRECT_FN_THRESHOLD = 0.05 # < 5% def _run_check(text: str): g = LiveAssistGuardrail() return asyncio.run(g.check(text, GuardrailContext(role="assist"))) def _fp_rate(corpus, expected_allowed: bool) -> tuple[float, int, int]: """Compute the false-positive rate (allowed != expected_allowed).""" misclassified = 0 total = 0 for entry in corpus: v = _run_check(entry["text"]) total += 1 if v.allowed != expected_allowed: misclassified += 1 return (misclassified / total if total else 0.0), misclassified, total def test_coaching_responses_allowed(): """All COACHING_RESPONSES → allowed=True. FP rate < 5% (REQ-IDEATE-04).""" fp, mis, total = _fp_rate(COACHING_RESPONSES, expected_allowed=True) log.info("coaching FP rate: %.1%% (%d/%d)", fp * 100, mis, total) print(f"\n[guardrail-tuning] coaching FP rate: {fp:.1%} ({mis}/{total})") assert fp < COACHING_FP_THRESHOLD, ( f"coaching FP rate {fp:.1%} exceeds {COACHING_FP_THRESHOLD:.0%} — " f"the regex is over-matching (tune it). {mis}/{total} blocked." ) def test_direct_answer_responses_blocked(): """All DIRECT_ANSWER_RESPONSES → allowed=False. FN rate < 5%.""" fn, mis, total = _fp_rate(DIRECT_ANSWER_RESPONSES, expected_allowed=False) log.info("direct-answer FN rate: %.1%% (%d/%d)", fn * 100, mis, total) print(f"\n[guardrail-tuning] direct-answer FN rate: {fn:.1%} ({mis}/{total})") assert fn < DIRECT_FN_THRESHOLD, ( f"direct-answer FN rate {fn:.1%} exceeds {DIRECT_FN_THRESHOLD:.0%} — " f"the regex is under-matching (tune it). {mis}/{total} slipped through." ) def test_false_authority_responses_blocked(): """All FALSE_AUTHORITY_RESPONSES → allowed=False (100% — hard violation).""" fn, mis, total = _fp_rate(FALSE_AUTHORITY_RESPONSES, expected_allowed=False) log.info("false-authority FN rate: %.1%% (%d/%d)", fn * 100, mis, total) print(f"\n[guardrail-tuning] false-authority FN rate: {fn:.1%} ({mis}/{total})") assert fn == 0.0, ( f"false-authority FN rate {fn:.1%} must be 0% (hard violation). " f"{mis}/{total} slipped through." ) def test_adversarial_responses_g067(): """G-067 binding: adversarial FN rate measured + compared against ≤20% threshold. The adversarial corpus is paraphrased direct answers designed to slip past the regex. The FN rate is the residual risk, mitigated by defense-in-depth (prompt + regex + audit) + the v0.6 LLM-as-judge (REQ-IDEATE-10). """ fn, mis, total = _fp_rate(ADVERSARIAL_RESPONSES, expected_allowed=False) log.info("adversarial FN rate: %.1%% (%d/%d)", fn * 100, mis, total) print( f"\n[guardrail-tuning] adversarial false-negative rate: {fn:.1%} " f"({mis}/{total}) — defense-in-depth + post-v0.5 LLM-as-judge mitigates" ) # G-067: the adversarial FN rate must be ≤ 20% for pilot acceptance. assert fn <= ADVERSARIAL_FN_THRESHOLD, ( f"adversarial FN rate {fn:.1%} exceeds G-067 threshold " f"{ADVERSARIAL_FN_THRESHOLD:.0%} — re-tune the regex or escalate. " f"{mis}/{total} paraphrased direct answers slipped through." ) def test_tuning_summary(): """Print the full tuning summary (FP + FN + accuracy) — REQ-IDEATE-04 measurement.""" coaching_fp, c_mis, c_total = _fp_rate(COACHING_RESPONSES, expected_allowed=True) direct_fn, d_mis, d_total = _fp_rate(DIRECT_ANSWER_RESPONSES, expected_allowed=False) fa_fn, f_mis, f_total = _fp_rate(FALSE_AUTHORITY_RESPONSES, expected_allowed=False) adv_fn, a_mis, a_total = _fp_rate(ADVERSARIAL_RESPONSES, expected_allowed=False) # Overall accuracy across the full corpus (excluding adversarial — those # are the residual-risk set, not the tuning target). total_correct = (c_total - c_mis) + (d_total - d_mis) + (f_total - f_mis) total_n = c_total + d_total + f_total accuracy = total_correct / total_n if total_n else 0.0 print( f"\n[guardrail-tuning] SUMMARY:\n" f" coaching FP rate: {coaching_fp:.1%} ({c_mis}/{c_total}) — target <{COACHING_FP_THRESHOLD:.0%}\n" f" direct-answer FN rate: {direct_fn:.1%} ({d_mis}/{d_total}) — target <{DIRECT_FN_THRESHOLD:.0%}\n" f" false-authority FN: {fa_fn:.1%} ({f_mis}/{f_total}) — target 0%\n" f" adversarial FN rate: {adv_fn:.1%} ({a_mis}/{a_total}) — G-067 threshold ≤{ADVERSARIAL_FN_THRESHOLD:.0%}\n" f" overall accuracy: {accuracy:.1%} ({total_correct}/{total_n})" ) # G-067 documentation: the threshold + rationale are documented in the # assertion messages above + this test's docstring. The measurement is # CI-visible (printed) for the verify stage. assert coaching_fp < COACHING_FP_THRESHOLD assert direct_fn < DIRECT_FN_THRESHOLD assert fa_fn == 0.0 assert adv_fn <= ADVERSARIAL_FN_THRESHOLD