mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(agent): add event-aware tool permissions
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.agent_runner import (
|
||||
ActorContext,
|
||||
AgentInput,
|
||||
DeliveryContext,
|
||||
RawEventRef,
|
||||
SubjectContext,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||
from langbot.pkg.agent.runner.platform_tools import (
|
||||
build_platform_tool_resources,
|
||||
execute_platform_tool,
|
||||
freeze_platform_context,
|
||||
resolve_agent_platform_tool_names,
|
||||
)
|
||||
|
||||
|
||||
def _event(event_type: str = 'friend.request_received') -> AgentEventEnvelope:
|
||||
return AgentEventEnvelope(
|
||||
event_id='event-1',
|
||||
event_type=event_type,
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
input=AgentInput(text='event'),
|
||||
actor=ActorContext(actor_type='user', actor_id='user-1'),
|
||||
subject=SubjectContext(subject_type='group', subject_id='group-1'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
'target_type': 'group',
|
||||
'target_id': 'group-1',
|
||||
'group_id': 'group-1',
|
||||
'message_id': 'message-1',
|
||||
},
|
||||
platform_capabilities={
|
||||
'adapter': 'FakeAdapter',
|
||||
'supported_apis': [
|
||||
'send_message',
|
||||
'approve_friend_request',
|
||||
'get_group_info',
|
||||
],
|
||||
},
|
||||
),
|
||||
raw_ref=RawEventRef(ref_id='request-fallback'),
|
||||
data={'request_id': 'request-1'},
|
||||
)
|
||||
|
||||
|
||||
def test_platform_resources_intersect_selection_adapter_and_event() -> None:
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
_event(),
|
||||
[
|
||||
'event_reply',
|
||||
'event_respond_friend_request',
|
||||
'event_kick_member',
|
||||
'platform_get_group_info',
|
||||
'unknown_tool',
|
||||
],
|
||||
['detail', 'call'],
|
||||
)
|
||||
|
||||
assert {item['tool_name'] for item in resources} == {
|
||||
'event_reply',
|
||||
'event_respond_friend_request',
|
||||
'platform_get_group_info',
|
||||
}
|
||||
assert all(item['source'] == 'platform' for item in resources)
|
||||
assert capabilities['authorized_tools'] == [item['tool_name'] for item in resources]
|
||||
assert {item['reason'] for item in capabilities['unavailable_tools']} == {
|
||||
'adapter_api_unsupported',
|
||||
'unknown_tool',
|
||||
}
|
||||
|
||||
|
||||
def test_platform_resources_require_runner_call_permission() -> None:
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
_event(),
|
||||
['event_reply'],
|
||||
['detail'],
|
||||
)
|
||||
|
||||
assert resources == []
|
||||
assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}]
|
||||
|
||||
|
||||
def test_agent_platform_tools_are_resolved_for_the_current_event() -> None:
|
||||
selected = resolve_agent_platform_tool_names(
|
||||
{
|
||||
'allowed_platform_tools': ['platform_get_user_info', 'event_reply'],
|
||||
'event_tool_permissions': {
|
||||
'message.*': ['event_reply'],
|
||||
'group.member.joined': ['event_get_actor'],
|
||||
'group.*': ['event_get_group', 'unknown_tool'],
|
||||
},
|
||||
},
|
||||
'group.member.joined',
|
||||
)
|
||||
|
||||
assert selected == [
|
||||
'platform_get_user_info',
|
||||
'event_reply',
|
||||
'event_get_actor',
|
||||
'event_get_group',
|
||||
'event_get_group_member',
|
||||
]
|
||||
|
||||
|
||||
def test_agent_event_tools_are_automatic_without_permission_configuration() -> None:
|
||||
assert resolve_agent_platform_tool_names(
|
||||
{'allowed_platform_tools': ['event_reply', 'platform_get_user_info']},
|
||||
'friend.request_received',
|
||||
) == [
|
||||
'platform_get_user_info',
|
||||
'event_reply',
|
||||
'event_get_actor',
|
||||
'event_respond_friend_request',
|
||||
]
|
||||
|
||||
|
||||
def test_request_action_does_not_treat_host_event_ref_as_platform_request_id() -> None:
|
||||
event = _event()
|
||||
event.data = {}
|
||||
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
event,
|
||||
['event_respond_friend_request'],
|
||||
['detail', 'call'],
|
||||
)
|
||||
|
||||
assert resources == []
|
||||
assert capabilities['unavailable_tools'] == [
|
||||
{'name': 'event_respond_friend_request', 'reason': 'event_target_unavailable'}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_action_execution_uses_frozen_target_and_current_bot() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['approve_friend_request'],
|
||||
approve_friend_request=AsyncMock(return_value=None),
|
||||
)
|
||||
platform_mgr = SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
ap = SimpleNamespace(platform_mgr=platform_mgr)
|
||||
event = _event()
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': 'bot-1',
|
||||
'platform_context': freeze_platform_context(event),
|
||||
}
|
||||
}
|
||||
execution_context = object()
|
||||
|
||||
await execute_platform_tool(
|
||||
ap,
|
||||
execution_context,
|
||||
session,
|
||||
'event_respond_friend_request',
|
||||
{'approve': False, 'remark': 'not now'},
|
||||
)
|
||||
|
||||
platform_mgr.get_bot_by_uuid.assert_awaited_once_with(execution_context, 'bot-1')
|
||||
adapter.approve_friend_request.assert_awaited_once_with(
|
||||
request_id='request-1',
|
||||
approve=False,
|
||||
remark='not now',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_reply_builds_message_chain_for_the_frozen_target() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['send_message'],
|
||||
send_message=AsyncMock(return_value=None),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
)
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': 'bot-1',
|
||||
'platform_context': freeze_platform_context(_event()),
|
||||
}
|
||||
}
|
||||
|
||||
await execute_platform_tool(ap, object(), session, 'event_reply', {'text': 'hello'})
|
||||
|
||||
call = adapter.send_message.await_args
|
||||
assert call.kwargs['target_type'] == 'group'
|
||||
assert call.kwargs['target_id'] == 'group-1'
|
||||
assert isinstance(call.kwargs['message'], platform_message.MessageChain)
|
||||
assert call.kwargs['message'][0].text == 'hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_action_rejects_parameters_outside_the_declared_schema() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['get_group_info'],
|
||||
get_group_info=AsyncMock(),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
)
|
||||
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': {}}}
|
||||
|
||||
with pytest.raises(ValueError, match='Unexpected parameters'):
|
||||
await execute_platform_tool(
|
||||
ap,
|
||||
object(),
|
||||
session,
|
||||
'platform_get_group_info',
|
||||
{'group_id': 'group-1', 'raw_action': 'unsafe'},
|
||||
)
|
||||
|
||||
adapter.get_group_info.assert_not_awaited()
|
||||
@@ -6,12 +6,13 @@ from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext
|
||||
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
|
||||
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
@@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app):
|
||||
assert resources['models'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(app):
|
||||
event = AgentEventEnvelope(
|
||||
event_id='event-platform-disabled',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
input=AgentInput(text='hello'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={'target_type': 'person', 'target_id': 'user-1'},
|
||||
platform_capabilities={'supported_apis': ['send_message']},
|
||||
),
|
||||
)
|
||||
binding = AgentBinding(
|
||||
binding_id='binding-platform-disabled',
|
||||
scope=BindingScope(scope_type='global'),
|
||||
runner_id=RUNNER_ID,
|
||||
resource_policy=ResourcePolicy(allowed_platform_tool_names=['event_reply']),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
execution_context=TEST_CONTEXT,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=make_descriptor(capabilities={'tool_calling': False}),
|
||||
)
|
||||
|
||||
assert resources['tools'] == []
|
||||
assert resources['platform_capabilities']['authorized_tools'] == []
|
||||
assert resources['platform_capabilities']['unavailable_tools'] == [
|
||||
{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
"""Config-selected model references are projected regardless of method granularity."""
|
||||
|
||||
@@ -53,10 +53,24 @@ def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
|
||||
|
||||
|
||||
def test_independent_agent_projection_preserves_all_tools_intent():
|
||||
policy = ResourcePolicyProjector.from_runner_config({})
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{}, allowed_platform_tool_names=['event_reply', '', 'event_reply']
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is True
|
||||
assert policy.allowed_tool_names is None
|
||||
assert policy.allowed_platform_tool_names == ['event_reply']
|
||||
|
||||
|
||||
def test_runner_config_cannot_self_grant_platform_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{
|
||||
'platform-tools': ['platform_send_message'],
|
||||
'allowed_platform_tools': ['platform_delete_message'],
|
||||
}
|
||||
)
|
||||
|
||||
assert policy.allowed_platform_tool_names == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||
@@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools():
|
||||
assert policy.allowed_tool_names == ['exec']
|
||||
|
||||
|
||||
def test_agent_level_host_tool_policy_overrides_runner_tool_defaults():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': True, 'tools': ['runner-tool']},
|
||||
allowed_host_tool_names=['exec', 'mcp_tool', 'exec'],
|
||||
override_runner_tools=True,
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||
|
||||
|
||||
def test_agent_level_empty_host_tool_policy_fails_closed():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': True},
|
||||
allowed_host_tool_names=[],
|
||||
override_runner_tools=True,
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == []
|
||||
|
||||
|
||||
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
|
||||
|
||||
Reference in New Issue
Block a user