"""Property tests for dispatch_legacy.py invariants. Three properties verified across the corpus: 1. Idempotent status normalisation: normalize(normalize(s)) == normalize(s) for any returned execution_status. 2. Artifact monotonicity: len(ctx.published_artifacts) after dispatch >= before for any case. 3. First denial wins: When two policies would each deny, only the first policy's error_class appears. This is verified structurally against the multiple_policies_would_deny_first_wins case (owner_only fires before denied_tools in the waterfall). hypothesis is not available in this environment; cases are hand-rolled parametrizations drawn from ALL_CASES plus targeted edge inputs. """ from __future__ import annotations import json from typing import Any import pytest from test_tools.dispatch_corpus import ALL_CASES, CorpusCase from opensquilla.execution_status import normalize_execution_status from opensquilla.tool_boundary import ToolCall from opensquilla.tools.dispatch import build_tool_handler from opensquilla.tools.registry import ToolRegistry from opensquilla.tools.types import ( CallerKind, InteractionMode, ToolContext, ToolSpec, current_tool_context, ) # --------------------------------------------------------------------------- # Property 1: Idempotent status normalisation # --------------------------------------------------------------------------- # # normalize_execution_status is a pure function. For any execution_status # dict returned by dispatch, applying normalize again must produce the same # result (i.e., it is already in canonical form after one application). # # We verify this against every corpus case result PLUS a set of hand-rolled # raw dicts that cover all branches of normalize_execution_status. _RAW_STATUS_SAMPLES: list[dict[str, Any]] = [ # Canonical forms — should be unchanged { "version": 1, "status": "success", "exit_code": None, "timed_out": False, "truncated": False, "reason": None, "source": "tool_runtime", "preservation_class": "normal", }, { "version": 1, "status": "error", "exit_code": 1, "timed_out": False, "truncated": True, "reason": "nonzero_exit", "source": "adapter", "preservation_class": "diagnostic", }, { "version": 1, "status": "timeout", "exit_code": None, "timed_out": True, "truncated": False, "reason": "tool_timeout", "source": "adapter", "preservation_class": "diagnostic", }, # Invalid status — normalises to unknown { "version": 1, "status": "bogus_status", "exit_code": None, "timed_out": False, "truncated": False, "reason": None, "source": "tool_runtime", "preservation_class": "normal", }, # Invalid source — normalises to unknown { "version": 1, "status": "success", "exit_code": None, "timed_out": False, "truncated": False, "reason": None, "source": "made_up_source", "preservation_class": "normal", }, # Invalid preservation_class — normalises to normal { "version": 1, "status": "success", "exit_code": None, "timed_out": False, "truncated": False, "reason": None, "source": "tool_runtime", "preservation_class": "invalid_class", }, # Non-dict input — normalises to unknown sentinel "not a dict", # type: ignore[list-item] None, # type: ignore[list-item] 42, # type: ignore[list-item] # Empty dict — all fields default {}, # approval_pending round-trip { "version": 1, "status": "unknown", "exit_code": None, "timed_out": False, "truncated": False, "reason": "approval_pending", "source": "tool_runtime", "preservation_class": "ephemeral", }, ] @pytest.mark.parametrize( "raw_status", _RAW_STATUS_SAMPLES, ids=[f"sample_{i}" for i in range(len(_RAW_STATUS_SAMPLES))], ) def test_normalize_execution_status_is_idempotent(raw_status: Any) -> None: """normalize(normalize(s)) == normalize(s) for any status input.""" once = normalize_execution_status(raw_status) twice = normalize_execution_status(once) assert once == twice, ( f"normalize is not idempotent: once={once!r}, twice={twice!r}" ) @pytest.mark.parametrize( "case", ALL_CASES, ids=[c.name for c in ALL_CASES], ) @pytest.mark.asyncio async def test_corpus_execution_status_is_already_normalised(case: CorpusCase) -> None: """Any execution_status returned by dispatch is already in normalised form.""" ctx = case.ctx_factory() registry = case.registry_factory() handler = build_tool_handler( registry, ctx, known_skill_names=set(case.known_skill_names) if case.known_skill_names else None, ) token = current_tool_context.set(None) if case.setup is not None: case.setup() try: result = await handler(case.tool_call) finally: current_tool_context.reset(token) if case.teardown is not None: case.teardown() if result.execution_status is not None: # Preflight rejections annotate the normalised status with exactly two # diagnostic keys that normalize_execution_status does not carry. # Assert their shape explicitly, then require the remainder to be in # normalised form already. comparable = dict(result.execution_status) preflight_rejected = comparable.pop("preflight_rejected", None) reason_code = comparable.pop("reason_code", None) assert preflight_rejected in (None, True), ( f"[{case.name}] unexpected preflight_rejected value: " f"{preflight_rejected!r}" ) if preflight_rejected: assert isinstance(reason_code, str) and reason_code, ( f"[{case.name}] preflight rejection missing reason_code: " f"{result.execution_status!r}" ) else: assert reason_code is None, ( f"[{case.name}] reason_code present without preflight_rejected: " f"{result.execution_status!r}" ) re_normalised = normalize_execution_status(comparable) assert comparable == re_normalised, ( f"[{case.name}] execution_status not already normalised: " f"{result.execution_status!r} != {re_normalised!r}" ) # --------------------------------------------------------------------------- # Property 2: Artifact monotonicity # --------------------------------------------------------------------------- # # For any dispatch call, len(ctx.published_artifacts) after >= before. # Publishing artifacts is append-only — dispatch never removes items. @pytest.mark.parametrize( "case", ALL_CASES, ids=[c.name for c in ALL_CASES], ) @pytest.mark.asyncio async def test_artifact_list_never_shrinks_after_dispatch(case: CorpusCase) -> None: """len(ctx.published_artifacts) after dispatch >= before for any case.""" ctx = case.ctx_factory() registry = case.registry_factory() handler = build_tool_handler( registry, ctx, known_skill_names=set(case.known_skill_names) if case.known_skill_names else None, ) token = current_tool_context.set(None) if case.setup is not None: case.setup() try: before = len(ctx.published_artifacts) if ctx is not None else 0 await handler(case.tool_call) after = len(ctx.published_artifacts) if ctx is not None else 0 finally: current_tool_context.reset(token) if case.teardown is not None: case.teardown() assert after >= before, ( f"[{case.name}] artifact list shrank: before={before}, after={after}" ) # --------------------------------------------------------------------------- # Property 3: First denial wins # --------------------------------------------------------------------------- # # When multiple policies would deny a request, the waterfall fires them in # document order. The corpus case `multiple_policies_would_deny_first_wins` # sets up owner_only=True (lines 215–231) + denied_tools (lines 233–251). # Owner-only fires first → OwnerOnly error_class, not PolicyDenied. @pytest.mark.asyncio async def test_first_denial_wins_owner_only_before_denied_tools() -> None: """owner_only check (lines 215-231) fires before denied_tools check (233-251).""" registry = ToolRegistry() async def _handler() -> str: return "should not reach" registry.register( ToolSpec(name="double_deny_tool", description="test", parameters={}, owner_only=True), _handler, ) ctx = ToolContext( is_owner=False, caller_kind=CallerKind.AGENT, interaction_mode=InteractionMode.INTERACTIVE, agent_id="main", session_key="agent:main:corpus", denied_tools={"double_deny_tool"}, ) handler = build_tool_handler(registry, ctx) token = current_tool_context.set(None) try: result = await handler( ToolCall( tool_use_id="tc-first-wins", tool_name="double_deny_tool", arguments={}, ) ) finally: current_tool_context.reset(token) assert result.is_error is True payload = json.loads(result.content) # OwnerOnly is the FIRST check; denied_tools is second. # If denied_tools fired first we'd see PolicyDenied. assert payload["error_class"] == "OwnerOnly", ( f"Expected OwnerOnly (first denial wins), got {payload['error_class']!r}. " "This indicates the waterfall order has changed — update the corpus." ) @pytest.mark.asyncio async def test_first_denial_wins_denied_tools_before_private_memory() -> None: """denied_tools check (lines 233-251) fires before private_memory check (253-270). Configure ctx with memory_get in denied_tools AND SUBAGENT caller (which triggers private_memory_read_tool_denied). denied_tools fires first → PolicyDenied with reason=denied. If private_memory fired first we'd get the same error_class (PolicyDenied) but the log reason differs — we inspect log events. """ import structlog.testing registry = ToolRegistry() async def _handler() -> str: return "should not reach" registry.register( ToolSpec(name="memory_get", description="test", parameters={}), _handler, ) ctx = ToolContext( is_owner=True, caller_kind=CallerKind.SUBAGENT, interaction_mode=InteractionMode.UNATTENDED, agent_id="sub1", session_key="subagent:sub1:corpus", denied_tools={"memory_get"}, # explicit deny fires at line 234 ) handler = build_tool_handler(registry, ctx) token = current_tool_context.set(None) try: with structlog.testing.capture_logs() as captured: result = await handler( ToolCall( tool_use_id="tc-deny-before-privmem", tool_name="memory_get", arguments={}, ) ) finally: current_tool_context.reset(token) assert result.is_error is True # Both policies produce PolicyDenied — distinguish by log reason field block_events = [ e for e in captured if e.get("event") == "dispatch.defense_in_depth_block" ] assert block_events, "Expected at least one defense_in_depth_block log event" # denied_tools fires first: reason="denied" (line 240) # private_memory fires second: reason="private_memory_scope" (line 257) first_reason = block_events[0].get("reason") assert first_reason == "denied", ( f"Expected first block reason='denied' (denied_tools check), got {first_reason!r}. " "Waterfall order may have changed." ) @pytest.mark.asyncio async def test_session_search_is_denied_by_private_memory_scope_for_subagents() -> None: import structlog.testing registry = ToolRegistry() async def _handler() -> str: return "should not reach" registry.register( ToolSpec(name="session_search", description="test", parameters={}), _handler, ) ctx = ToolContext( is_owner=True, caller_kind=CallerKind.SUBAGENT, interaction_mode=InteractionMode.UNATTENDED, agent_id="sub1", session_key="subagent:agent:main:parent", ) handler = build_tool_handler(registry, ctx) token = current_tool_context.set(None) try: with structlog.testing.capture_logs() as captured: result = await handler( ToolCall( tool_use_id="tc-subagent-session-search", tool_name="session_search", arguments={"query": "needle"}, ) ) finally: current_tool_context.reset(token) assert result.is_error is True assert "not available in this context" in result.content assert any( event.get("reason") == "private_memory_scope" for event in captured if event.get("event") == "dispatch.defense_in_depth_block" ) # --------------------------------------------------------------------------- # Property 4: Contextvar set during handler call (HIGH 2) # --------------------------------------------------------------------------- # # dispatch_legacy sets current_tool_context to effective_ctx before invoking # the registered handler (line 343) and resets it in the finally block (line 492). # Dispatch must honour the same contract so nested tool calls see their parent context. # # The _contextvar_reading_registry helper captures current_tool_context.get() # mid-execution into registry._corpus_captured. This test asserts that captured # value IS effective_ctx — not None, not some other context. @pytest.mark.asyncio async def test_contextvar_is_effective_ctx_during_handler_call() -> None: """current_tool_context.get() inside a handler equals effective_ctx (legacy line 343). Rationale: dispatch could run without setting the contextvar before dispatch and the existing ``contextvar_must_be_none_after`` assertion would still pass (None → dispatch → None is indistinguishable from None → set → dispatch → reset → None if we only check after the fact). This test closes that gap by asserting the value seen *inside* the handler matches the context the handler was built for. """ registry = ToolRegistry() captured_during: list[ToolContext | None] = [] async def _ctx_reader() -> str: captured_during.append(current_tool_context.get()) return "done" registry.register( ToolSpec(name="ctx_reader", description="ctx_reader", parameters={}), _ctx_reader, ) expected_ctx = ToolContext( is_owner=True, caller_kind=CallerKind.AGENT, interaction_mode=InteractionMode.INTERACTIVE, agent_id="main", session_key="agent:main:ctxvar-test", ) handler = build_tool_handler(registry, expected_ctx) # Start with contextvar unset (mimics normal agent loop entry) token = current_tool_context.set(None) try: result = await handler( ToolCall(tool_use_id="tc-ctxvar-during", tool_name="ctx_reader", arguments={}) ) finally: current_tool_context.reset(token) # Handler must have been called exactly once assert len(captured_during) == 1, ( f"Expected handler to be called once, got {len(captured_during)} captured values" ) # The value seen inside the handler must be the effective context, not None assert captured_during[0] is not None, ( "current_tool_context.get() was None inside the handler — " "dispatch did not set the contextvar before calling the handler (line 343 contract)." ) assert captured_during[0] is expected_ctx, ( f"current_tool_context.get() inside handler was {captured_during[0]!r}, " f"expected {expected_ctx!r}. " "Dispatch must set current_tool_context to effective_ctx before invoking the handler." ) # After dispatch the contextvar must be reset to None (finally-block contract) assert current_tool_context.get() is None, ( "current_tool_context was not reset to None after dispatch — finally-block leak." ) assert result.is_error is False