fix: harden agent runner runtime boundaries

This commit is contained in:
huanghuoguoguo
2026-06-13 00:17:40 +08:00
parent 2094993afb
commit e7779bd16f
22 changed files with 366 additions and 889 deletions
+6
View File
@@ -43,6 +43,9 @@ def make_session(
plugin_identity: str = 'test/test-runner',
resources: dict | None = None,
conversation_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
available_apis: dict[str, bool] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
@@ -114,6 +117,9 @@ def make_session(
'resources': res,
'available_apis': apis,
'conversation_id': conversation_id,
'bot_id': bot_id,
'workspace_id': workspace_id,
'thread_id': thread_id,
'state_policy': policy,
'state_context': context,
'authorized_ids': authorized_ids,
+80 -1
View File
@@ -208,10 +208,20 @@ class TestArtifactAuthorization:
class TestArtifactAccessValidation:
"""Test _validate_artifact_access authorization rules."""
def _make_session(self, conversation_id: str | None):
def _make_session(
self,
conversation_id: str | None,
*,
bot_id: str | None = None,
workspace_id: str | None = None,
thread_id: str | None = None,
):
return make_session(
run_id="run_001",
conversation_id=conversation_id,
bot_id=bot_id,
workspace_id=workspace_id,
thread_id=thread_id,
available_apis={"artifact_metadata": True, "artifact_read": True},
)
@@ -259,6 +269,64 @@ class TestArtifactAccessValidation:
assert is_allowed is True
assert error is None
def test_same_conversation_and_scope_allowed(self):
"""Artifacts in the same run scope are allowed across runs."""
session = self._make_session(
"conv_001",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001",
"run_id": "run_other",
"bot_id": "bot_001",
"workspace_id": "workspace_001",
"thread_id": "thread_001",
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is True
assert error is None
def test_same_conversation_different_scope_denied(self):
"""Artifacts in another bot/thread scope are denied even in the same conversation."""
session = self._make_session(
"conv_001",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
)
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001",
"run_id": "run_other",
"bot_id": "bot_002",
"workspace_id": "workspace_001",
"thread_id": "thread_001",
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is False
assert "denied" in error.lower()
def test_same_conversation_missing_scope_denied_for_scoped_session(self):
"""Scoped runs should not read legacy-scope artifacts from other runs."""
session = self._make_session("conv_001", bot_id="bot_001")
metadata = {
"artifact_id": "art_001",
"conversation_id": "conv_001",
"run_id": "run_other",
"bot_id": None,
"workspace_id": None,
"thread_id": None,
}
is_allowed, error = self._call_validate(session, metadata)
assert is_allowed is False
assert "denied" in error.lower()
def test_different_conversation_and_run_denied(self):
"""Artifacts in different conversation and different run are denied."""
session = self._make_session("conv_001")
@@ -470,6 +538,9 @@ class TestArtifactStoreRealSQLite:
content=content,
conversation_id="conv_001",
run_id="run_001",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
)
assert artifact_id == "art_real_001"
@@ -489,6 +560,14 @@ class TestArtifactStoreRealSQLite:
assert "storage_type" not in metadata
assert "bot_id" not in metadata
assert "workspace_id" not in metadata
assert "thread_id" not in metadata
assert "_langbot_thread_id" not in metadata.get("metadata", {})
auth_metadata = await store.get_authorization_metadata(artifact_id)
assert auth_metadata is not None
assert auth_metadata["bot_id"] == "bot_001"
assert auth_metadata["workspace_id"] == "workspace_001"
assert auth_metadata["thread_id"] == "thread_001"
@pytest.mark.asyncio
async def test_read_artifact_round_trip(self, db_engine):
@@ -628,6 +628,52 @@ class TestTranscriptStoreRealSQLite:
assert messages[0].content[0].text == "User structured text"
assert messages[1].content == "Assistant text"
@pytest.mark.asyncio
async def test_get_legacy_provider_messages_filters_scope(self, db_engine):
"""Legacy Pipeline history projection must stay inside the current run scope."""
store = TranscriptStore(db_engine)
await store.append_transcript(
transcript_id="trans_scope_001",
event_id="evt_scope_001",
conversation_id="conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
role="user",
content="Current scope text",
)
await store.append_transcript(
transcript_id="trans_scope_002",
event_id="evt_scope_002",
conversation_id="conv_scope",
bot_id="bot_002",
workspace_id="workspace_001",
thread_id="thread_001",
role="assistant",
content="Other bot text",
)
await store.append_transcript(
transcript_id="trans_scope_003",
event_id="evt_scope_003",
conversation_id="conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_002",
role="assistant",
content="Other thread text",
)
messages = await store.get_legacy_provider_messages(
"conv_scope",
bot_id="bot_001",
workspace_id="workspace_001",
thread_id="thread_001",
strict_thread=True,
)
assert [message.content for message in messages] == ["Current scope text"]
@pytest.mark.asyncio
async def test_search_transcript_real_db(self, db_engine):
"""Test search_transcript with real DB."""
@@ -193,6 +193,41 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
]
@pytest.mark.asyncio
async def test_build_resources_accepts_dynamic_form_type_aliases(app):
"""Frontend DynamicForm aliases should resolve to runtime resource grants."""
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=make_model())
async def get_kb(kb_uuid):
return SimpleNamespace(
uuid=kb_uuid,
get_name=lambda: f'name-{kb_uuid}',
knowledge_base_entity=SimpleNamespace(kb_type='default'),
)
app.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(side_effect=get_kb)
descriptor = make_descriptor(
capabilities={'knowledge_retrieval': True},
config_schema=[
{'name': 'model', 'type': 'select-llm-model'},
{'name': 'knowledge-bases', 'type': 'select-knowledge-bases'},
],
)
query = make_query({
'model': 'llm_alias',
'knowledge-bases': ['kb_alias'],
})
resources = await build_resources(app, query, descriptor)
assert resources['models'] == [
{'model_id': 'llm_alias', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']},
]
assert resources['knowledge_bases'] == [
{'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
]
@pytest.mark.asyncio
async def test_build_models_manifest_permission_narrows_binding(app):
"""Manifest model permissions narrower than binding should remove LLM grants."""
@@ -288,6 +288,45 @@ class TestSessionRegistryBasic:
assert len(items) == MAX_STEERING_QUEUE_ITEMS
assert all(item['event']['event_id'] != 'overflow' for item in items)
@pytest.mark.asyncio
async def test_find_steering_target_requires_same_scope(self):
"""Steering claims must not cross bot/workspace/thread boundaries."""
registry = AgentRunSessionRegistry()
await registry.register(
run_id='run_steering_scoped',
runner_id='plugin:test/my-runner/default',
query_id=1,
plugin_identity='test/my-runner',
resources=make_resources(),
conversation_id='conv_1',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
available_apis={'steering_pull': True},
)
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_1',
) == 'run_steering_scoped'
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_2',
workspace_id='workspace_1',
thread_id='thread_1',
) is None
assert await registry.find_steering_target(
conversation_id='conv_1',
runner_id='plugin:test/my-runner/default',
bot_id='bot_1',
workspace_id='workspace_1',
thread_id='thread_2',
) is None
@pytest.mark.asyncio
async def test_unregister_returns_pending_steering_queue(self):
"""Unregister returns the removed session so callers can audit pending steering."""
@@ -630,7 +630,9 @@ class TestMCPServiceTestMCPServer:
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
mock_session = MagicMock()
mock_session.server_name = 'transient-test-server'
mock_session.start = AsyncMock()
mock_session.shutdown = AsyncMock()
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
@@ -645,4 +647,9 @@ class TestMCPServiceTestMCPServer:
# Verify - load_mcp_server called
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
assert task_id == 456
assert task_id == 456
coroutine = ap.task_mgr.create_user_task.call_args.args[0]
await coroutine
mock_session.start.assert_awaited_once()
mock_session.shutdown.assert_awaited_once()
+17
View File
@@ -181,6 +181,23 @@ def make_app(
)
def test_resolve_box_session_id_reads_current_runner_config():
query = make_query(101)
query.pipeline_config = {
'ai': {
'runner': {'id': 'plugin:langbot/local-agent/default'},
'runner_config': {
'plugin:langbot/local-agent/default': {
'box-session-id-template': 'bot-{launcher_id}-{sender_id}',
},
},
},
}
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
assert service.resolve_box_session_id(query) == 'bot-test_user-test_user'
@pytest.mark.asyncio
async def test_box_service_without_explicit_client_initializes_internal_connector(monkeypatch: pytest.MonkeyPatch):
connector = Mock()
+7
View File
@@ -273,6 +273,13 @@ async def test_preproc_uses_transcript_history_view_when_available():
assert result.result_type == entities_module.ResultType.CONTINUE
assert query.messages == transcript_messages
stage._load_agent_runner_history_messages.assert_awaited_once_with(
'plugin:langbot/local-agent/default',
'conv-1',
bot_id='bot-1',
workspace_id=None,
thread_id=None,
)
@pytest.mark.asyncio