604 lines
20 KiB
Python
604 lines
20 KiB
Python
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.policy import ToolSurfaceCapabilities
|
|
from opensquilla.tools.registry import ToolRegistry
|
|
from opensquilla.tools.types import CallerKind, InteractionMode, ToolContext, ToolSpec
|
|
|
|
|
|
async def _handler() -> str:
|
|
return "ok"
|
|
|
|
|
|
def _spec(name: str, *, exposed_by_default: bool = True) -> ToolSpec:
|
|
return ToolSpec(
|
|
name=name,
|
|
description=f"{name} tool",
|
|
parameters={},
|
|
exposed_by_default=exposed_by_default,
|
|
)
|
|
|
|
|
|
def test_register_overwrite_warns() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("dup"), _handler)
|
|
|
|
with structlog.testing.capture_logs() as captured:
|
|
registry.register(_spec("dup"), _handler)
|
|
|
|
assert any(
|
|
event["event"] == "registry.tool_overwrite" and event["name"] == "dup"
|
|
for event in captured
|
|
)
|
|
|
|
|
|
def test_surfaced_tools_make_hidden_tools_visible() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("visible"), _handler)
|
|
registry.register(_spec("hidden", exposed_by_default=False), _handler)
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.AGENT,
|
|
surfaced_tools={"hidden"},
|
|
)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(ctx)}
|
|
|
|
assert names == {"visible", "hidden"}
|
|
|
|
|
|
def test_allowed_tools_remains_strict_when_tool_is_surfaced() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("visible"), _handler)
|
|
registry.register(_spec("hidden", exposed_by_default=False), _handler)
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.AGENT,
|
|
allowed_tools={"visible"},
|
|
surfaced_tools={"hidden"},
|
|
)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(ctx)}
|
|
|
|
assert names == {"visible"}
|
|
|
|
|
|
def test_default_registry_removes_obsolete_wrapper_tools_but_keeps_canonical_tools() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
|
|
assert registry.get("generate_image") is None
|
|
assert registry.get("spawn_subagent") is None
|
|
assert registry.get("send_message") is None
|
|
|
|
assert registry.get("image_generate") is not None
|
|
assert registry.get("sessions_spawn") is not None
|
|
assert registry.get("sessions_send") is not None
|
|
assert registry.get("subagents") is not None
|
|
|
|
|
|
def test_owner_schema_keeps_canonical_tools_and_subagents_stays_explicit_only() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
owner_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.AGENT)
|
|
|
|
default_names = {tool.name for tool in registry.to_tool_definitions(owner_ctx)}
|
|
assert {"image_generate", "sessions_spawn", "sessions_send"} <= default_names
|
|
assert "subagents" not in default_names
|
|
assert "create_pptx" not in default_names
|
|
|
|
surfaced_ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.AGENT,
|
|
surfaced_tools={"create_pptx", "subagents"},
|
|
)
|
|
surfaced_names = {tool.name for tool in registry.to_tool_definitions(surfaced_ctx)}
|
|
assert "subagents" in surfaced_names
|
|
assert "create_pptx" in surfaced_names
|
|
|
|
|
|
def test_node_runtime_stubs_stay_hidden_until_explicitly_surfaced() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
owner_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.AGENT)
|
|
|
|
default_tools = registry.to_tool_definitions(owner_ctx)
|
|
assert {tool.name for tool in default_tools}.isdisjoint({"nodes", "canvas"})
|
|
|
|
surfaced_ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.AGENT,
|
|
surfaced_tools={"nodes", "canvas"},
|
|
)
|
|
surfaced_tools = {
|
|
tool.name: tool.description.lower()
|
|
for tool in registry.to_tool_definitions(surfaced_ctx)
|
|
if tool.name in {"nodes", "canvas"}
|
|
}
|
|
|
|
assert set(surfaced_tools) == {"nodes", "canvas"}
|
|
assert "node runtime" in surfaced_tools["nodes"]
|
|
assert "unavailable" in surfaced_tools["nodes"]
|
|
assert "node runtime" in surfaced_tools["canvas"]
|
|
assert "unavailable" in surfaced_tools["canvas"]
|
|
|
|
|
|
def test_web_owner_schema_hides_basic_pptx_fallback_by_default() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
web_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.WEB)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(web_ctx)}
|
|
|
|
assert "create_pptx" not in names
|
|
assert "execute_code" in names
|
|
|
|
|
|
def test_channel_runtime_profile_exposes_publish_artifact() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import filter_by_profile, get_default_registry, resolve_profile
|
|
|
|
registry = get_default_registry()
|
|
channel_ctx = ToolContext(is_owner=False, caller_kind=CallerKind.CHANNEL)
|
|
|
|
names = {
|
|
tool.name
|
|
for tool in filter_by_profile(
|
|
registry.to_tool_definitions(channel_ctx),
|
|
resolve_profile(channel_ctx),
|
|
)
|
|
}
|
|
|
|
assert "publish_artifact" in names
|
|
|
|
|
|
def test_channel_runtime_profile_exposes_safe_structured_file_tools() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import filter_by_profile, get_default_registry, resolve_profile
|
|
|
|
registry = get_default_registry()
|
|
channel_ctx = ToolContext(is_owner=False, caller_kind=CallerKind.CHANNEL)
|
|
|
|
names = {
|
|
tool.name
|
|
for tool in filter_by_profile(
|
|
registry.to_tool_definitions(channel_ctx),
|
|
resolve_profile(channel_ctx),
|
|
channel_ctx,
|
|
)
|
|
}
|
|
|
|
assert {"create_csv", "create_xlsx", "create_pdf_report", "create_pptx"} <= names
|
|
assert "write_file" not in names
|
|
assert "execute_code" not in names
|
|
|
|
|
|
def test_channel_media_policy_surfaces_basic_pptx_fallback_explicitly() -> None:
|
|
from opensquilla.tools.policy import apply_tool_policy_from_config
|
|
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("session_status"), _handler)
|
|
registry.register(_spec("create_pptx", exposed_by_default=False), _handler)
|
|
ctx = apply_tool_policy_from_config(
|
|
ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
channel_kind="feishu",
|
|
channel_id="oc_demo",
|
|
),
|
|
available_tools=registry.list_names(),
|
|
config={
|
|
"channels": {
|
|
"feishu": {
|
|
"groups": {
|
|
"oc_demo": {
|
|
"tools": {"profile": "minimal", "also_allow": ["channel:media"]}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(ctx)}
|
|
|
|
assert names == {"session_status", "create_pptx"}
|
|
|
|
|
|
def test_channel_runtime_profile_exposes_explicit_category_tools_not_host_mutation() -> None:
|
|
from opensquilla.tools.registry import filter_by_profile, resolve_profile
|
|
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
allowed_tools={"feishu_drive_upload_artifact", "write_file"},
|
|
)
|
|
tools = [
|
|
_spec("feishu_drive_upload_artifact"),
|
|
_spec("write_file"),
|
|
_spec("create_pptx"),
|
|
]
|
|
|
|
names = {tool.name for tool in filter_by_profile(tools, resolve_profile(ctx), ctx)}
|
|
|
|
assert "feishu_drive_upload_artifact" in names
|
|
assert "create_pptx" in names
|
|
assert "write_file" not in names
|
|
|
|
|
|
def test_channel_hidden_tool_visibility_stays_on_channel_profile(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_TOOL_PROFILE", "owner_full")
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("create_pptx", exposed_by_default=False), _handler)
|
|
registry.register(_spec("hidden_authoring", exposed_by_default=False), _handler)
|
|
channel_ctx = ToolContext(is_owner=False, caller_kind=CallerKind.CHANNEL)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(channel_ctx)}
|
|
|
|
assert "create_pptx" in names
|
|
assert "hidden_authoring" not in names
|
|
|
|
|
|
def test_shared_channel_context_hides_private_memory_read_tools_even_when_allowed() -> None:
|
|
from opensquilla.tools.policy import resolve_runtime_tool_surface
|
|
from opensquilla.tools.registry import filter_by_profile, resolve_profile
|
|
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("memory_get"), _handler)
|
|
registry.register(_spec("memory_search"), _handler)
|
|
registry.register(_spec("session_search"), _handler)
|
|
registry.register(_spec("read_file"), _handler)
|
|
channel_ctx = resolve_runtime_tool_surface(
|
|
ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
session_key="agent:main:slack:group:g1",
|
|
allowed_tools={"memory_get", "memory_search", "read_file"},
|
|
),
|
|
capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
)
|
|
|
|
names = {
|
|
tool.name
|
|
for tool in filter_by_profile(
|
|
registry.to_tool_definitions(channel_ctx),
|
|
resolve_profile(channel_ctx),
|
|
channel_ctx,
|
|
)
|
|
}
|
|
|
|
assert "memory_get" not in names
|
|
assert "memory_search" not in names
|
|
assert "session_search" not in names
|
|
assert "read_file" in names
|
|
|
|
|
|
def test_direct_channel_context_keeps_private_memory_read_tools() -> None:
|
|
from opensquilla.tools.policy import resolve_runtime_tool_surface
|
|
from opensquilla.tools.registry import filter_by_profile, resolve_profile
|
|
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("memory_get"), _handler)
|
|
registry.register(_spec("memory_search"), _handler)
|
|
channel_ctx = resolve_runtime_tool_surface(
|
|
ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
session_key="agent:main:slack:dm:u1",
|
|
),
|
|
capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
)
|
|
|
|
names = {
|
|
tool.name
|
|
for tool in filter_by_profile(
|
|
registry.to_tool_definitions(channel_ctx),
|
|
resolve_profile(channel_ctx),
|
|
channel_ctx,
|
|
)
|
|
}
|
|
|
|
assert "memory_get" in names
|
|
assert "memory_search" in names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_effective_tools_hide_private_memory_reads_for_cron_and_subagents() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("memory_get"), _handler)
|
|
registry.register(_spec("memory_search"), _handler)
|
|
registry.register(_spec("session_search"), _handler)
|
|
registry.register(_spec("read_file"), _handler)
|
|
|
|
cron_names = {
|
|
tool["name"]
|
|
for tool in await registry.effective_tools(
|
|
session_key="cron:dream:run:1",
|
|
agent_id="main",
|
|
caller_kind=CallerKind.CRON,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
tool_surface_capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
is_owner=False,
|
|
)
|
|
}
|
|
subagent_names = {
|
|
tool["name"]
|
|
for tool in await registry.effective_tools(
|
|
session_key="agent:main:subagent:run-1",
|
|
agent_id="main",
|
|
caller_kind=CallerKind.SUBAGENT,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
tool_surface_capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
)
|
|
}
|
|
|
|
assert cron_names == {"read_file"}
|
|
assert subagent_names == {"read_file"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_effective_tools_keep_private_memory_reads_for_owner_cron() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("memory_get"), _handler)
|
|
registry.register(_spec("memory_search"), _handler)
|
|
registry.register(_spec("session_search"), _handler)
|
|
registry.register(_spec("read_file"), _handler)
|
|
|
|
cron_names = {
|
|
tool["name"]
|
|
for tool in await registry.effective_tools(
|
|
session_key="cron:owner:run:1",
|
|
agent_id="main",
|
|
caller_kind=CallerKind.CRON,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
tool_surface_capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
is_owner=True,
|
|
)
|
|
}
|
|
|
|
assert "memory_get" in cron_names
|
|
assert "memory_search" in cron_names
|
|
assert "session_search" in cron_names
|
|
|
|
|
|
def test_subagent_schema_hides_publish_artifact_without_artifact_context() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
from opensquilla.tools.types import SUBAGENT_TOOL_DENY
|
|
|
|
registry = get_default_registry()
|
|
subagent_ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.SUBAGENT,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
denied_tools=set(SUBAGENT_TOOL_DENY),
|
|
)
|
|
|
|
names = {tool.name for tool in registry.to_tool_definitions(subagent_ctx)}
|
|
|
|
assert "publish_artifact" not in names
|
|
|
|
|
|
def test_owner_only_tools_are_hidden_from_non_owner_schema() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
owner_ctx = ToolContext(is_owner=True, caller_kind=CallerKind.AGENT)
|
|
non_owner_ctx = ToolContext(is_owner=False, caller_kind=CallerKind.AGENT)
|
|
|
|
owner_names = {tool.name for tool in registry.to_tool_definitions(owner_ctx)}
|
|
non_owner_names = {tool.name for tool in registry.to_tool_definitions(non_owner_ctx)}
|
|
|
|
assert {"http_request", "git_commit"} <= owner_names
|
|
assert "http_request" not in non_owner_names
|
|
assert "git_commit" not in non_owner_names
|
|
|
|
|
|
def test_web_group_can_surface_owner_only_http_request_for_owner_only() -> None:
|
|
import opensquilla.tools.builtin # noqa: F401
|
|
from opensquilla.gateway.config import GatewayConfig, ToolsConfig
|
|
from opensquilla.tools.policy import apply_tool_policy_from_config
|
|
from opensquilla.tools.registry import get_default_registry
|
|
|
|
registry = get_default_registry()
|
|
available = registry.list_names()
|
|
config = GatewayConfig(
|
|
tools=ToolsConfig(profile="minimal", also_allow=["group:web"])
|
|
)
|
|
|
|
owner_ctx = apply_tool_policy_from_config(
|
|
ToolContext(is_owner=True, caller_kind=CallerKind.AGENT),
|
|
available_tools=available,
|
|
config=config,
|
|
)
|
|
non_owner_ctx = apply_tool_policy_from_config(
|
|
ToolContext(is_owner=False, caller_kind=CallerKind.CHANNEL),
|
|
available_tools=available,
|
|
config=config,
|
|
)
|
|
|
|
owner_names = {tool.name for tool in registry.to_tool_definitions(owner_ctx)}
|
|
non_owner_names = {tool.name for tool in registry.to_tool_definitions(non_owner_ctx)}
|
|
|
|
assert {
|
|
"web_search",
|
|
"web_discover",
|
|
"web_fetch",
|
|
"http_request",
|
|
} <= owner_names
|
|
assert "research_search" not in owner_names
|
|
assert "research_search" not in non_owner_names
|
|
assert "web_search" in non_owner_names
|
|
assert "web_discover" in non_owner_names
|
|
assert "http_request" not in non_owner_names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tools_uses_visible_helper_and_stable_sorting() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("zeta"), _handler)
|
|
registry.register(_spec("alpha"), _handler)
|
|
registry.register(_spec("hidden", exposed_by_default=False), _handler)
|
|
|
|
tools = await registry.list_tools()
|
|
|
|
assert [tool["name"] for tool in tools] == ["alpha", "zeta"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_visibility_and_dispatch_denial_use_same_context() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("allowed"), _handler)
|
|
registry.register(_spec("denied"), _handler)
|
|
ctx = ToolContext(
|
|
is_owner=True,
|
|
caller_kind=CallerKind.CLI,
|
|
denied_tools={"denied"},
|
|
)
|
|
|
|
schema_names = {tool.name for tool in registry.to_tool_definitions(ctx)}
|
|
handler = build_tool_handler(registry, ctx)
|
|
forced_result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-denied",
|
|
tool_name="denied",
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
assert schema_names == {"allowed"}
|
|
assert forced_result.is_error is True
|
|
payload = json.loads(forced_result.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_profile_blocks_forced_tool_calls_outside_safe_allowlist() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("create_csv"), _handler)
|
|
registry.register(_spec("execute_code"), _handler)
|
|
ctx = ToolContext(is_owner=False, caller_kind=CallerKind.CHANNEL)
|
|
handler = build_tool_handler(registry, ctx)
|
|
|
|
allowed = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-safe",
|
|
tool_name="create_csv",
|
|
arguments={},
|
|
)
|
|
)
|
|
forced = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-forced",
|
|
tool_name="execute_code",
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
assert allowed.is_error is False
|
|
assert forced.is_error is True
|
|
payload = json.loads(forced.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_profile_allows_explicit_category_tools_not_host_mutation() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("create_pptx"), _handler)
|
|
registry.register(_spec("feishu_drive_upload_artifact"), _handler)
|
|
registry.register(_spec("write_file"), _handler)
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
allowed_tools={"create_pptx", "feishu_drive_upload_artifact", "write_file"},
|
|
)
|
|
handler = build_tool_handler(registry, ctx)
|
|
|
|
category_tool = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-drive",
|
|
tool_name="feishu_drive_upload_artifact",
|
|
arguments={},
|
|
)
|
|
)
|
|
host_mutation = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-write",
|
|
tool_name="write_file",
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
assert category_tool.is_error is False
|
|
assert host_mutation.is_error is True
|
|
payload = json.loads(host_mutation.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_denies_private_memory_reads_for_shared_sessions() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("memory_search"), _handler)
|
|
ctx = ToolContext(
|
|
is_owner=False,
|
|
caller_kind=CallerKind.CHANNEL,
|
|
session_key="agent:main:slack:group:g1",
|
|
)
|
|
|
|
handler = build_tool_handler(registry, ctx)
|
|
forced_result = await handler(
|
|
ToolCall(
|
|
tool_use_id="tc-memory-search",
|
|
tool_name="memory_search",
|
|
arguments={},
|
|
)
|
|
)
|
|
|
|
assert forced_result.is_error is True
|
|
payload = json.loads(forced_result.content)
|
|
assert payload["error_class"] == "PolicyDenied"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_and_effective_names_agree_for_unattended_cli_context() -> None:
|
|
registry = ToolRegistry()
|
|
registry.register(_spec("sessions_spawn"), _handler)
|
|
registry.register(_spec("sessions_list"), _handler)
|
|
registry.register(_spec("read_file"), _handler)
|
|
|
|
catalog = await registry.list_tools(
|
|
session_key="agent:main:auto",
|
|
agent_id="main",
|
|
caller_kind=CallerKind.CLI,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
tool_surface_capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
)
|
|
effective = await registry.effective_tools(
|
|
session_key="agent:main:auto",
|
|
agent_id="main",
|
|
caller_kind=CallerKind.CLI,
|
|
interaction_mode=InteractionMode.UNATTENDED,
|
|
tool_surface_capabilities=ToolSurfaceCapabilities(session_manager=True),
|
|
)
|
|
|
|
catalog_names = {tool["name"] for tool in catalog}
|
|
effective_names = {tool["name"] for tool in effective}
|
|
assert catalog_names == effective_names == {"read_file", "sessions_list"}
|