"""The safety model may decide immediately or use one inspection call.""" from __future__ import annotations import json from pathlib import Path from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Literal import pytest from agents import Agent, Runner from agents.items import ModelResponse from agents.models.interface import Model from agents.tool_context import ToolContext from agents.usage import Usage from openai.types.responses import ( ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText, ) import strix.safety.reviewer as reviewer_module from strix.config.settings import SafetySettings from strix.safety.evidence import EvidenceBundle from strix.safety.reviewer import SafetyReviewer, run_inspection from strix.safety.types import InspectionContext, SafetyVerdict if TYPE_CHECKING: from pytest import MonkeyPatch class _InspectionRunner: def __init__(self) -> None: self.calls = 0 async def run(self, *, evidence_dir: str, script: str) -> str: self.calls += 1 return f"inspected {Path(evidence_dir).name}: {script}" class _Result: def __init__(self, verdict: SafetyVerdict) -> None: self._verdict = verdict self.context_wrapper = SimpleNamespace(usage=SimpleNamespace()) def final_output_as(self, _cls: type[Any], *, raise_if_incorrect_type: bool) -> SafetyVerdict: assert raise_if_incorrect_type is True return self._verdict def _settings() -> Any: return SimpleNamespace( safety=SafetySettings(model="test-model"), llm=SimpleNamespace( model="main-model", extra_headers=None, ), ) @pytest.mark.asyncio async def test_reviewer_is_capped_at_two_turns_and_zero_retries( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: captured: dict[str, Any] = {} async def fake_run(agent: Any, *, input: str, context: Any, max_turns: int) -> _Result: # noqa: A002 captured.update(agent=agent, input=input, context=context, max_turns=max_turns) return _Result( SafetyVerdict( decision="allow", risk="low", categories=[], reason="read only", confidence=0.99, ) ) monkeypatch.setattr(reviewer_module, "load_settings", _settings) monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None) monkeypatch.setattr( reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model" ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) monkeypatch.setattr(reviewer_module, "get_global_report_state", lambda: None) bundle = EvidenceBundle( case_id="case-1", root=tmp_path, packet={"completeness": {"status": "complete"}}, complete=True, incomplete_reasons=[], ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle) assert decision.allowed is True assert captured["max_turns"] == 2 assert [tool.name for tool in captured["agent"].tools] == ["run_inspection"] assert captured["agent"].model_settings.retry.max_retries == 0 # The cap also covers reasoning tokens; a verdict-sized budget would truncate the # structured output on a reasoning model and fail every review closed. assert captured["agent"].model_settings.max_tokens == SafetySettings().max_output_tokens @pytest.mark.asyncio async def test_review_budget_covers_both_turns_and_the_inspection( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: captured: dict[str, Any] = {} async def fake_wait_for(awaitable: Any, *, timeout: float) -> Any: captured["timeout"] = timeout return await awaitable async def fake_run(_agent: Any, **_kwargs: Any) -> _Result: return _Result( SafetyVerdict( decision="allow", risk="low", categories=[], reason="read only", confidence=0.99, ) ) monkeypatch.setattr(reviewer_module, "load_settings", _settings) monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None) monkeypatch.setattr( reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model" ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) monkeypatch.setattr(reviewer_module, "get_global_report_state", lambda: None) monkeypatch.setattr(reviewer_module.asyncio, "wait_for", fake_wait_for) bundle = EvidenceBundle( case_id="case-budget", root=tmp_path, packet={"completeness": {"status": "complete"}}, complete=True, incomplete_reasons=[], ) await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle) safety = SafetySettings() assert captured["timeout"] == 2 * safety.timeout + safety.inspection_timeout @pytest.mark.asyncio async def test_inspection_tool_can_only_run_once(tmp_path: Path) -> None: runner = _InspectionRunner() state = InspectionContext(evidence_dir=str(tmp_path), runner=runner) ctx = ToolContext( context=state, tool_name="run_inspection", tool_call_id="inspect-1", tool_arguments="{}", ) raw = json.dumps({"reason": "correlate files", "script": "print('ok')"}) first = await run_inspection.on_invoke_tool(ctx, raw) second = await run_inspection.on_invoke_tool(ctx, raw) assert "inspected" in first assert "already used" in second assert runner.calls == 1 assert state.attempts == 2 assert state.incomplete is True @pytest.mark.asyncio async def test_inspection_collects_workspace_files_before_running_script(tmp_path: Path) -> None: collected: list[tuple[str, ...]] = [] async def collect(paths: tuple[str, ...]) -> tuple[str, bool]: collected.append(paths) return '{"workspace_artifacts":[{"path":"/workspace/hosts.txt"}]}', False runner = _InspectionRunner() state = InspectionContext( evidence_dir=str(tmp_path), runner=runner, collect_workspace=collect, ) ctx = ToolContext( context=state, tool_name="run_inspection", tool_call_id="inspect-collect", tool_arguments="{}", ) result = await run_inspection.on_invoke_tool( ctx, json.dumps( { "reason": "resolve host list", "workspace_paths": ["/workspace/hosts.txt"], "script": "print('analyzed')", } ), ) assert collected == [("/workspace/hosts.txt",)] assert "workspace_artifacts" in result assert "inspected" in result assert runner.calls == 1 @pytest.mark.asyncio async def test_inspection_can_collect_without_analysis_script(tmp_path: Path) -> None: async def collect(_paths: tuple[str, ...]) -> tuple[str, bool]: return "collected file", False state = InspectionContext( evidence_dir=str(tmp_path), runner=_InspectionRunner(), collect_workspace=collect, ) ctx = ToolContext( context=state, tool_name="run_inspection", tool_call_id="inspect-read", tool_arguments="{}", ) result = await run_inspection.on_invoke_tool( ctx, json.dumps( { "reason": "read missing file", "workspace_paths": ["/workspace/missing.txt"], } ), ) assert "collected file" in result assert state.incomplete is False @pytest.mark.asyncio async def test_real_sdk_loop_replays_inspection_output_into_second_turn(tmp_path: Path) -> None: class LoopModel(Model): def __init__(self) -> None: self.inputs: list[Any] = [] self.tool_names: list[list[str]] = [] async def get_response(self, *_args: Any, **kwargs: Any) -> ModelResponse: self.inputs.append(kwargs["input"]) self.tool_names.append([tool.name for tool in kwargs["tools"]]) if len(self.inputs) == 1: return ModelResponse( output=[ ResponseFunctionToolCall( call_id="inspect-call", name="run_inspection", arguments=json.dumps( { "reason": "read host list", "workspace_paths": ["/workspace/hosts.txt"], } ), type="function_call", ) ], usage=Usage(), response_id="response-1", ) replay = json.dumps(kwargs["input"], default=str) assert "function_call_output" in replay assert "host-a.example.test" in replay verdict = SafetyVerdict( decision="allow", risk="low", categories=["read_only_reconnaissance"], reason="collected host list proves one bounded GET", confidence=0.99, ).model_dump_json() return ModelResponse( output=[ ResponseOutputMessage.model_construct( id="message-1", type="message", role="assistant", status="completed", content=[ ResponseOutputText( type="output_text", text=verdict, annotations=[], ) ], ) ], usage=Usage(), response_id="response-2", ) def stream_response(self, *_args: Any, **_kwargs: Any) -> Any: raise NotImplementedError async def collect(_paths: tuple[str, ...]) -> tuple[str, bool]: return '{"path":"/workspace/hosts.txt","source":"host-a.example.test"}', False model = LoopModel() agent: Agent[InspectionContext] = Agent( name="Safety loop test", instructions="Use the tool once, then return the typed verdict.", model=model, tools=[run_inspection], output_type=SafetyVerdict, tool_use_behavior="run_llm_again", ) context = InspectionContext( evidence_dir=str(tmp_path), runner=_InspectionRunner(), collect_workspace=collect, ) result = await Runner.run(agent, input="deterministic packet", context=context, max_turns=2) assert result.final_output_as(SafetyVerdict).decision == "allow" assert model.tool_names == [["run_inspection"], []] assert len(model.inputs) == 2 @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_repeated_inspection_attempt_fails_review_closed( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: async def fake_run(_agent: Any, *, context: Any, **_kwargs: Any) -> _Result: context.used = True context.attempts = 2 return _Result( SafetyVerdict( decision="defer", risk="medium", categories=[], reason="still uncertain", confidence=0.9, ) ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _incomplete_bundle(tmp_path, "case-repeated-inspection"), human_approval_available=True, ) assert decision.source == "review_error" assert decision.categories == ("inspection_repeated",) @pytest.mark.asyncio async def test_reviewer_failure_blocks(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: async def fail(*_args: Any, **_kwargs: Any) -> Any: raise RuntimeError("provider down") monkeypatch.setattr(reviewer_module, "load_settings", _settings) monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None) monkeypatch.setattr( reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model" ) monkeypatch.setattr(reviewer_module.Runner, "run", fail) bundle = EvidenceBundle( case_id="case-2", root=tmp_path, packet={"completeness": {"status": "complete"}}, complete=True, incomplete_reasons=[], ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review(bundle) assert decision.allowed is False assert decision.source == "review_error" @pytest.fixture def _patched_sdk(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr(reviewer_module, "load_settings", _settings) monkeypatch.setattr(reviewer_module, "configure_sdk_model_defaults", lambda _settings: None) monkeypatch.setattr( reviewer_module.StrixProvider, "get_model", lambda _self, _name: "test-model" ) monkeypatch.setattr(reviewer_module, "get_global_report_state", lambda: None) def _bundle(tmp_path: Path, case_id: str) -> EvidenceBundle: return EvidenceBundle( case_id=case_id, root=tmp_path, packet={"completeness": {"status": "complete"}}, complete=True, incomplete_reasons=[], ) def _incomplete_bundle(tmp_path: Path, case_id: str) -> EvidenceBundle: return EvidenceBundle( case_id=case_id, root=tmp_path, packet={ "completeness": { "status": "incomplete", "reasons": ["dynamic network destination"], } }, complete=False, incomplete_reasons=["dynamic network destination"], ) def _reviewable_bundle(tmp_path: Path, case_id: str) -> EvidenceBundle: return EvidenceBundle( case_id=case_id, root=tmp_path, packet={ "completeness": { "status": "reviewable", "hard_gaps": [], "reviewable_issues": ["dynamic network destination"], } }, complete=True, incomplete_reasons=[], reviewable_issues=["dynamic network destination"], ) def _verdict_run(verdict: SafetyVerdict) -> Any: async def fake_run(_agent: Any, **_kwargs: Any) -> _Result: return _Result(verdict) return fake_run @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_low_confidence_allow_is_refused(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: """An allow the reviewer is unsure of is the case the threshold exists for.""" monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="allow", risk="medium", categories=["target_mutation"], reason="probably fine", confidence=0.5, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, "case-low-confidence") ) assert decision.allowed is False assert decision.deferred is False assert decision.source == "reviewer" assert "below the 0.75 threshold" in decision.reason assert decision.categories == ("target_mutation",) @pytest.mark.parametrize("model_decision", ["allow", "block"]) @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_interactive_low_confidence_verdict_defers( tmp_path: Path, monkeypatch: MonkeyPatch, model_decision: Literal["allow", "block"], ) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision=model_decision, risk="medium", categories=["ambiguous_effect"], reason="effect is unclear", confidence=0.5, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, f"case-low-{model_decision}"), human_approval_available=True, ) assert decision.allowed is False assert decision.deferred is True assert decision.risk == "medium" assert "below the 0.75 threshold" in decision.reason @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_explicit_defer_requires_an_approval_channel( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="defer", risk="high", categories=["ambiguous_effect"], reason="persistence depends on endpoint behavior", confidence=0.9, ) ), ) reviewer = SafetyReviewer(inspection_runner=_InspectionRunner()) interactive = await reviewer.review( _bundle(tmp_path, "case-explicit-interactive"), human_approval_available=True, ) noninteractive = await reviewer.review(_bundle(tmp_path, "case-explicit-headless")) assert interactive.deferred is True assert interactive.risk == "high" assert noninteractive.allowed is False assert noninteractive.deferred is False assert "no human approval channel" in noninteractive.reason @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_interactive_incomplete_evidence_requires_the_inspection_call( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="defer", risk="medium", categories=["incomplete_evidence"], reason="destination remains unknown", confidence=0.9, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _incomplete_bundle(tmp_path, "case-uninspected"), human_approval_available=True, ) assert decision.allowed is False assert decision.deferred is False assert decision.categories == ("missing_evidence_uninspected",) @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_incomplete_allow_after_inspection_is_deferred_to_human( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: async def fake_run(_agent: Any, *, context: Any, **_kwargs: Any) -> _Result: context.used = True return _Result( SafetyVerdict( decision="allow", risk="medium", categories=["incomplete_evidence"], reason="available artifacts look non-destructive", confidence=0.95, ) ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _incomplete_bundle(tmp_path, "case-inspected"), human_approval_available=True, ) assert decision.allowed is False assert decision.deferred is True assert "dynamic network destination" in decision.reason assert "available artifacts look non-destructive" in decision.reason @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_reviewable_issue_can_be_allowed_after_inspection( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: async def fake_run(_agent: Any, *, context: Any, **_kwargs: Any) -> _Result: context.used = True return _Result( SafetyVerdict( decision="allow", risk="low", categories=["read_only_reconnaissance"], reason="inspection resolved the destination and found fixed GET requests", confidence=0.95, ) ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _reviewable_bundle(tmp_path, "case-reviewable") ) assert decision.allowed is True assert decision.deferred is False assert decision.source == "reviewer" @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_collected_workspace_file_can_resolve_hard_gap_and_allow( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: bundle = _incomplete_bundle(tmp_path, "case-collected-hard-gap") async def collect(paths: tuple[str, ...]) -> tuple[str, bool]: assert paths == ("/workspace/hosts.txt",) bundle.incomplete_reasons.clear() bundle.complete = True bundle.packet["completeness"] = { "status": "complete", "hard_gaps": [], "reviewable_issues": [], } return "collected hosts", False async def fake_run(_agent: Any, *, context: Any, **_kwargs: Any) -> _Result: assert context.collect_workspace is not None await context.collect_workspace(("/workspace/hosts.txt",)) context.used = True return _Result( SafetyVerdict( decision="allow", risk="low", categories=["read_only_reconnaissance"], reason="collected host list proves bounded GET requests", confidence=0.95, ) ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( bundle, human_approval_available=True, workspace_collector=collect, ) assert decision.allowed is True assert decision.deferred is False @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_reviewable_issue_requires_inspection( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="allow", risk="low", categories=[], reason="looks safe", confidence=0.95, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _reviewable_bundle(tmp_path, "case-reviewable-uninspected") ) assert decision.allowed is False assert decision.categories == ("missing_evidence_uninspected",) @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_confident_allow_passes(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="allow", risk="low", categories=[], reason="read only", confidence=0.8, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, "case-confident") ) assert decision.allowed is True assert decision.source == "reviewer" @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") async def test_block_verdict_is_returned_as_a_block( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setattr( reviewer_module.Runner, "run", _verdict_run( SafetyVerdict( decision="block", risk="high", categories=["state_mutation"], reason="deletes a record", confidence=0.99, ) ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, "case-block") ) assert decision.allowed is False assert decision.deferred is False assert decision.source == "reviewer" assert decision.reason == "deletes a record" @pytest.mark.asyncio async def test_missing_model_configuration_blocks( tmp_path: Path, monkeypatch: MonkeyPatch, ) -> None: monkeypatch.setattr( reviewer_module, "load_settings", lambda: SimpleNamespace( safety=SafetySettings(model=None), llm=SimpleNamespace(model="", extra_headers=None), ), ) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, "case-no-model") ) assert decision.allowed is False assert decision.source == "review_error" assert decision.categories == ("review_unavailable",) @pytest.mark.asyncio @pytest.mark.usefixtures("_patched_sdk") @pytest.mark.parametrize("model_decision", ["allow", "defer"]) async def test_non_block_after_a_failed_inspection_is_refused( tmp_path: Path, monkeypatch: MonkeyPatch, model_decision: Literal["allow", "defer"], ) -> None: """The reviewer decides from the inspection's own output, so an inspection that failed must not be able to underwrite an allow.""" async def fake_run(_agent: Any, *, context: Any, **_kwargs: Any) -> _Result: context.incomplete = True return _Result( SafetyVerdict( decision=model_decision, risk="low", categories=[], reason="looked fine", confidence=0.99, ) ) monkeypatch.setattr(reviewer_module.Runner, "run", fake_run) decision = await SafetyReviewer(inspection_runner=_InspectionRunner()).review( _bundle(tmp_path, "case-bad-inspection"), human_approval_available=True, ) assert decision.allowed is False assert decision.deferred is False assert decision.categories == ("inspection_incomplete",) @pytest.mark.parametrize( "output", [ "Inspection failed: frozen evidence directory is unavailable.", "Inspection exit code: 1", "... output truncated ...", ], ) @pytest.mark.asyncio async def test_inspection_failure_output_is_recognized(tmp_path: Path, output: str) -> None: """These strings are produced in inspection.py and matched by substring here, so a reword on either side silently stops marking failed inspections.""" class _Failing: async def run(self, *, evidence_dir: str, script: str) -> str: # noqa: ARG002 return output state = InspectionContext(evidence_dir=str(tmp_path), runner=_Failing()) ctx = ToolContext( context=state, tool_name="run_inspection", tool_call_id="inspect-1", tool_arguments="{}", ) await run_inspection.on_invoke_tool( ctx, json.dumps({"reason": "check", "script": "print('x')"}) ) assert state.incomplete is True def test_prompt_judges_security_testing_by_effect_not_technique() -> None: """Pins the effect-based guardrails so a future edit cannot silently revert to blocking in-scope offensive testing on the technique alone.""" prompt = reviewer_module._SAFETY_PROMPT normalized = " ".join(prompt.split()) # Authorization framing and the effect-not-technique rule. assert "authorized penetration test" in prompt assert "not, by itself, a reason to block" in normalized # Read probes pass; writes and destruction block. assert "OR 1=1" in prompt for keyword in ("DROP", "DELETE", "INSERT", "TRUNCATE", "OUTFILE", "xp_cmdshell"): assert keyword in prompt # Scope enforcement belongs elsewhere, including for passive third-party services. assert "Do not enforce authorization scope" in prompt assert "crt.sh" in prompt assert "solely because their hostname is outside" in prompt # Ambiguity only reaches a human when an approval channel exists. assert "Return defer only when approval is available" in prompt assert "Without human approval, ambiguity must block" in normalized assert "MUST use run_inspection exactly once" in prompt # Non-negotiable guardrails survive. assert "Never allow when completeness.hard_gaps is non-empty" in prompt assert 'do not defer merely because completeness.status is "reviewable"' in normalized assert "Deterministic policy blocks cannot be overridden" in prompt assert "analysis.mutating_request" in prompt def test_prompt_explains_input_files() -> None: prompt = " ".join(reviewer_module._SAFETY_PROMPT.split()) assert 'role "input"' in prompt