Files
2026-07-13 13:12:33 +08:00

475 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 215231) + denied_tools (lines 233251).
# 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