288 lines
9.4 KiB
Python
288 lines
9.4 KiB
Python
"""Regression tests for the dispatch policy refactor surface-hardening pass.
|
|
|
|
Two boundaries are exercised here:
|
|
|
|
1. Untrusted CHANNEL callers (and anonymous callers with no
|
|
:class:`ToolContext`) must not be able to enumerate the registry by
|
|
probing tool names. The registry-miss envelope they see must be opaque,
|
|
while the structured log retains the actual tool name for operators.
|
|
|
|
2. The :class:`PermissionMatrixPolicy` must clamp CHANNEL principals to
|
|
``role="user"`` even when ``is_owner`` is True, so a future ctx leak
|
|
cannot promote a channel caller to operator and bypass the
|
|
``ADMIN_ONLY`` gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
import structlog.testing
|
|
|
|
from opensquilla.engine.types 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,
|
|
)
|
|
|
|
|
|
def _build_registry() -> ToolRegistry:
|
|
registry = ToolRegistry()
|
|
|
|
async def some_tool() -> str:
|
|
return "ok"
|
|
|
|
registry.register(
|
|
ToolSpec(name="some_real_tool", description="real", parameters={}),
|
|
some_tool,
|
|
)
|
|
return registry
|
|
|
|
|
|
_PROBE_TOOL_NAME = "definitely_not_a_real_tool_xyz"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_miss_for_channel_caller_is_opaque() -> None:
|
|
handler = build_tool_handler(_build_registry())
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
agent_id="main",
|
|
session_key="agent:main:hardening",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
with structlog.testing.capture_logs() as captured:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-opaque-1",
|
|
tool_name=_PROBE_TOOL_NAME,
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result.is_error is True
|
|
payload = json.loads(result.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
assert payload["status"] == "error"
|
|
assert payload["user_message"] == "Tool unavailable for this surface."
|
|
# The tool name must NOT appear in any user-visible field.
|
|
assert _PROBE_TOOL_NAME not in payload["user_message"]
|
|
|
|
# Operators must still be able to debug the miss via structured logs.
|
|
miss_events = [e for e in captured if e["event"] == "dispatch.registry_miss"]
|
|
assert miss_events, "dispatch.registry_miss must be logged"
|
|
event = miss_events[0]
|
|
assert event["tool"] == _PROBE_TOOL_NAME
|
|
assert event["untrusted_caller"] is True
|
|
assert event["is_skill"] is False
|
|
assert event["session_key"] == "agent:main:hardening"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_miss_for_anonymous_caller_is_opaque() -> None:
|
|
"""No ``ToolContext`` at all is treated as untrusted for the same reason."""
|
|
handler = build_tool_handler(_build_registry())
|
|
|
|
with structlog.testing.capture_logs() as captured:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-opaque-2",
|
|
tool_name=_PROBE_TOOL_NAME,
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
assert result.is_error is True
|
|
payload = json.loads(result.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
assert _PROBE_TOOL_NAME not in payload["user_message"]
|
|
miss_events = [e for e in captured if e["event"] == "dispatch.registry_miss"]
|
|
assert miss_events and miss_events[0]["tool"] == _PROBE_TOOL_NAME
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_miss_for_channel_skill_collision_is_opaque() -> None:
|
|
"""Even the skill-collision branch must not echo the probed name."""
|
|
handler = build_tool_handler(
|
|
_build_registry(),
|
|
known_skill_names={"my_skill"},
|
|
)
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
agent_id="main",
|
|
session_key="agent:main:hardening",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-opaque-3",
|
|
tool_name="my_skill",
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
assert "my_skill" not in payload["user_message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_miss_for_cli_caller_preserves_descriptive_envelope() -> None:
|
|
"""Trusted CLI callers must keep the actionable ToolNotFound message."""
|
|
handler = build_tool_handler(_build_registry())
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
agent_id="main",
|
|
session_key="cli:main:hardening",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-cli-miss",
|
|
tool_name=_PROBE_TOOL_NAME,
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result.content)
|
|
assert payload["error_class"] == "ToolNotFound"
|
|
assert _PROBE_TOOL_NAME in payload["user_message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_miss_for_owner_channel_preserves_descriptive_envelope() -> None:
|
|
"""Owner CHANNEL callers (already cleared upstream) keep the verbose envelope."""
|
|
handler = build_tool_handler(_build_registry())
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
agent_id="main",
|
|
session_key="agent:main:hardening",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-owner-miss",
|
|
tool_name=_PROBE_TOOL_NAME,
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result.content)
|
|
assert payload["error_class"] == "ToolNotFound"
|
|
assert _PROBE_TOOL_NAME in payload["user_message"]
|
|
|
|
|
|
def _registry_with(name: str) -> ToolRegistry:
|
|
registry = ToolRegistry()
|
|
|
|
async def handler() -> str:
|
|
return "ok"
|
|
|
|
registry.register(ToolSpec(name=name, description=name, parameters={}), handler)
|
|
return registry
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permission_matrix_clamps_channel_is_owner_leak_to_user_role() -> None:
|
|
"""A leaked ``is_owner=True`` on a CHANNEL ctx must not promote to operator.
|
|
|
|
``git_push`` is ``ADMIN_ONLY`` in the permission matrix. A genuine
|
|
operator principal would receive ``operator_override`` and be allowed,
|
|
so any test that sees an allow here would prove the clamp had failed.
|
|
"""
|
|
handler = build_tool_handler(_registry_with("git_push"))
|
|
ctx = ToolContext(
|
|
is_owner=True, # Simulated leak from a buggy upstream constructor.
|
|
caller_kind=CallerKind.CHANNEL,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
agent_id="main",
|
|
session_key="agent:main:hardening",
|
|
# Explicit allowlist bypasses the profile gate so the matrix gate
|
|
# is the one we observe.
|
|
allowed_tools={"git_push"},
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
with structlog.testing.capture_logs() as captured:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-matrix-clamp",
|
|
tool_name="git_push",
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
payload = json.loads(result.content)
|
|
assert result.is_error is True
|
|
assert payload["error_class"] == "UnsupportedSurface"
|
|
# operator_override would NEVER appear in a user-visible reason; matrix
|
|
# block must fire.
|
|
assert "operator_override" not in payload["user_message"]
|
|
matrix_blocks = [
|
|
e for e in captured if e["event"] == "dispatch.permission_matrix_block"
|
|
]
|
|
assert matrix_blocks, "permission matrix must record the block"
|
|
assert matrix_blocks[0]["reason"].startswith("admin_only_denied_in_")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_permission_matrix_uses_webui_source_for_owner_admin_tools() -> None:
|
|
"""Authenticated Web UI turns should use the Web UI permission surface.
|
|
|
|
Webchat sessions carry ``channel_kind='webchat'`` for display/routing,
|
|
while the trusted surface is recorded as ``source_kind='webui'``. The
|
|
permission matrix must not collapse those turns to the DM surface or
|
|
admin-only workspace tools are denied even for authenticated operators.
|
|
"""
|
|
handler = build_tool_handler(_registry_with("write_file"))
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
interaction_mode=InteractionMode.INTERACTIVE,
|
|
agent_id="main",
|
|
session_key="agent:main:webchat:hardening",
|
|
channel_kind="webchat",
|
|
source_kind="webui",
|
|
)
|
|
token = current_tool_context.set(ctx)
|
|
try:
|
|
result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-webui-write",
|
|
tool_name="write_file",
|
|
arguments={},
|
|
)
|
|
)
|
|
finally:
|
|
current_tool_context.reset(token)
|
|
|
|
assert result.is_error is False
|
|
assert result.content == "ok"
|