feat(tenancy): harden shared cloud runtime boundaries

This commit is contained in:
Junyan Qin
2026-07-20 01:47:42 +08:00
parent 41772920ef
commit a47bfe8167
121 changed files with 18152 additions and 5588 deletions
+126 -1
View File
@@ -304,6 +304,22 @@ class TestSkillPathHelpers:
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
assert command.rstrip().endswith('python scripts/run.py')
def test_wrap_skill_python_env_keeps_state_outside_read_only_source(self):
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
command = wrap_skill_command_with_python_env(
'python scripts/run.py',
mount_path='/workspace/.skills/demo',
state_path='/workspace/.skill-envs/demo',
)
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in command
assert '_LB_META_DIR="/workspace/.skill-envs/demo/.langbot"' in command
assert '_LB_TMP_DIR="/workspace/.skill-envs/demo/.tmp"' in command
assert '_LB_PIP_CACHE_DIR="/workspace/.skill-envs/demo/.cache/pip"' in command
assert 'root = "/workspace/.skills/demo"' in command
assert 'pip install "/workspace/.skills/demo"' in command
class TestSkillToolLoader:
"""The skill tool surface is now just ``activate`` + ``register_skill``.
@@ -497,7 +513,11 @@ class TestNativeToolLoaderSkillPaths:
f.write('demo instructions')
ap = _make_ap()
ap.box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap.box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
shares_filesystem_with_box=True,
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
@@ -511,6 +531,70 @@ class TestNativeToolLoaderSkillPaths:
assert result['content'] == 'demo instructions'
assert result['truncated'] is False
@pytest.mark.asyncio
async def test_external_runtime_read_never_interprets_package_root_on_core_host(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'SKILL.md'), 'w', encoding='utf-8') as file_obj:
file_obj.write('core-host-secret')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
read_skill_file=AsyncMock(return_value={'content': 'runtime-owned-content'}),
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
query = _make_query(
query_id='q-external-read',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
result = await loader.invoke_tool(
'read',
{'path': '/workspace/.skills/demo/SKILL.md'},
query,
)
assert result['ok'] is True
assert result['content'] == 'runtime-owned-content'
assert 'core-host-secret' not in repr(result)
ap.box_service.read_skill_file.assert_awaited_once_with(_CONTEXT, 'demo', 'SKILL.md')
@pytest.mark.asyncio
async def test_external_runtime_rejects_skill_host_fallback_without_protocol_capability(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
with tempfile.TemporaryDirectory() as tmpdir:
with open(os.path.join(tmpdir, 'secret.txt'), 'w', encoding='utf-8') as file_obj:
file_obj.write('core-host-secret')
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
)
ap.skill_mgr = _make_skill_manager({'demo': _make_skill_data(name='demo', package_root=tmpdir)})
loader = NativeToolLoader(ap)
query = _make_query(
query_id='q-external-no-protocol',
variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']},
)
with pytest.raises(ValueError, match='owned by the Box Runtime'):
await loader.invoke_tool(
'grep',
{
'path': '/workspace/.skills/demo',
'pattern': 'core-host-secret',
},
query,
)
@pytest.mark.asyncio
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
@@ -542,8 +626,49 @@ class TestNativeToolLoaderSkillPaths:
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
assert tool_parameters['command'] == 'python /workspace/.skills/demo/scripts/run.py'
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with(_CONTEXT, 'demo')
@pytest.mark.asyncio
async def test_external_runtime_python_skill_uses_trusted_metadata_and_writable_env(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
ap = _make_ap()
ap.box_service = SimpleNamespace(
available=True,
shares_filesystem_with_box=False,
execute_tool=AsyncMock(return_value={'ok': True}),
)
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
loader = NativeToolLoader(ap)
query = _make_query(query_id='q-external', launcher_type='person', launcher_id='123')
register_activated_skill(
query,
_make_skill_data(
name='demo',
package_root='/box-runtime/skills/tenants/workspace/demo',
python_project=True,
),
)
result = await loader.invoke_tool(
'exec',
{
'command': 'python /workspace/.skills/demo/scripts/run.py',
'workdir': '/workspace/.skills/demo',
},
query,
)
assert result['ok'] is True
tool_parameters = ap.box_service.execute_tool.await_args.args[0]
wrapped = tool_parameters['command']
assert '_LB_VENV_DIR="/workspace/.skill-envs/demo/.venv"' in wrapped
assert 'root = "/workspace/.skills/demo"' in wrapped
assert '/box-runtime/skills/tenants/workspace/demo' not in wrapped
assert ap.box_service.execute_tool.await_args.kwargs['skill_name'] == 'demo'
@pytest.mark.asyncio
async def test_write_requires_skill_activation(self):
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
@@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import contextlib
import os
import tempfile
from types import SimpleNamespace
@@ -11,6 +12,7 @@ import pytest
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.loaders import native as native_loader
from langbot.pkg.provider.tools.toolmgr import ToolManager
from langbot.pkg.api.http.context import ExecutionContext
@@ -52,7 +54,8 @@ class StubLoader:
for tool in self._tools
]
async def has_tool(self, name: str) -> bool:
async def has_tool(self, *args) -> bool:
name = args[-1]
return any(tool.name == name for tool in self._tools)
async def invoke_tool(self, name: str, parameters: dict, query):
@@ -129,11 +132,55 @@ async def test_tool_manager_routes_native_tool_calls():
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
assert result == {'backend': 'fake'}
@pytest.mark.asyncio
async def test_tool_manager_hides_sandbox_and_skill_tools_without_workspace_entitlement():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
tools = await manager.get_all_tools(_CONTEXT, include_skill_authoring=True)
catalog = await manager.get_tool_catalog(_CONTEXT, include_skill_authoring=True)
assert [tool.name for tool in tools] == ['plugin_tool', 'mcp_tool']
assert [item['name'] for item in catalog] == ['plugin_tool', 'mcp_tool']
assert box_service.is_workspace_sandbox_available.await_count == 2
@pytest.mark.asyncio
async def test_tool_manager_rechecks_workspace_entitlement_before_native_invocation():
box_service = SimpleNamespace(is_workspace_sandbox_available=AsyncMock(return_value=False))
manager = ToolManager(SimpleNamespace(box_service=box_service))
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'unexpected': True})
manager.skill_tool_loader = StubLoader([])
manager.plugin_tool_loader = StubLoader([])
manager.mcp_tool_loader = StubLoader([])
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
with pytest.raises(Exception, match='exec'):
await manager.execute_func_call('exec', {'command': 'pwd'}, query=query)
box_service.is_workspace_sandbox_available.assert_awaited_once_with(_CONTEXT)
@pytest.mark.asyncio
async def test_native_tool_loader_hides_tools_when_box_unavailable():
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
@@ -159,6 +206,26 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
assert await loader.has_tool(tool_name) is True
@pytest.mark.asyncio
async def test_native_tool_loader_rechecks_admission_at_the_final_invoke_boundary():
box_service = SimpleNamespace(
available=True,
require_workspace_sandbox=AsyncMock(side_effect=RuntimeError('entitlement expired')),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
query = SimpleNamespace(
_execution_context=_CONTEXT,
bot_uuid=None,
pipeline_uuid=None,
query_uuid=None,
)
with pytest.raises(RuntimeError, match='entitlement expired'):
await loader.invoke_tool('read', {'path': '/workspace/private.txt'}, query)
box_service.require_workspace_sandbox.assert_awaited_once_with(_CONTEXT)
# ── read/write/edit file tool tests ─────────────────────────────
@@ -386,6 +453,103 @@ async def test_path_escape_blocked():
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
@pytest.mark.parametrize(
('tool_name', 'parameters'),
[
('read', {'path': '/workspace/shared/tenant-b-only.txt'}),
(
'write',
{'path': '/workspace/shared/tenant-b-only.txt', 'content': 'overwritten by tenant a'},
),
(
'edit',
{
'path': '/workspace/shared/tenant-b-only.txt',
'old_string': 'tenant-b-secret',
'new_string': 'overwritten by tenant a',
},
),
('glob', {'path': '/workspace/shared', 'pattern': '*'}),
('grep', {'path': '/workspace/shared', 'pattern': 'tenant-b-secret'}),
],
)
@pytest.mark.asyncio
async def test_host_workspace_operations_do_not_follow_a_swapped_ancestor(
monkeypatch,
tool_name: str,
parameters: dict,
):
with tempfile.TemporaryDirectory() as tmpdir:
tenant_a = os.path.join(tmpdir, 'tenant-a')
tenant_b = os.path.join(tmpdir, 'tenant-b')
os.makedirs(os.path.join(tenant_a, 'shared'))
os.makedirs(os.path.join(tenant_b, 'shared'))
tenant_b_file = os.path.join(tenant_b, 'shared', 'tenant-b-only.txt')
with open(os.path.join(tenant_a, 'shared', 'tenant-a-only.txt'), 'w', encoding='utf-8') as file_obj:
file_obj.write('tenant-a-content')
with open(tenant_b_file, 'w', encoding='utf-8') as file_obj:
file_obj.write('tenant-b-secret')
box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
_tenant_workspace=Mock(return_value=tenant_a),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
original_open_host_root = native_loader._open_host_root
@contextlib.contextmanager
def open_host_root_after_swap(location, *, create):
with original_open_host_root(location, create=create) as root_fd:
original_ancestor = os.path.join(tenant_a, 'shared-original')
os.rename(os.path.join(tenant_a, 'shared'), original_ancestor)
os.symlink(os.path.join(tenant_b, 'shared'), os.path.join(tenant_a, 'shared'))
yield root_fd
monkeypatch.setattr(native_loader, '_open_host_root', open_host_root_after_swap)
try:
result = await loader.invoke_tool(tool_name, parameters, _make_query())
except ValueError as exc:
result = {'ok': False, 'error': str(exc)}
assert result.get('ok') is False
assert 'tenant-b-secret' not in repr(result)
assert 'tenant-b-only.txt' not in repr(result)
with open(tenant_b_file, encoding='utf-8') as file_obj:
assert file_obj.read() == 'tenant-b-secret'
@pytest.mark.asyncio
async def test_host_file_api_falls_back_to_tenant_box_when_openat_is_unavailable(monkeypatch):
with tempfile.TemporaryDirectory() as tmpdir:
box_service = SimpleNamespace(
available=True,
default_workspace=tmpdir,
_tenant_workspace=Mock(return_value=tmpdir),
execute_tool=AsyncMock(
return_value={
'ok': True,
'stdout': '{"ok": true, "content": "box-owned", "truncated": false}',
'stderr': '',
}
),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
monkeypatch.setattr(native_loader, '_SECURE_HOST_FILE_OPS_AVAILABLE', False)
result = await loader.invoke_tool(
'read',
{'path': '/workspace/file.txt'},
_make_query(),
)
assert result['ok'] is True
assert result['content'] == 'box-owned'
command = box_service.execute_tool.await_args.args[0]['command']
assert 'path = "/workspace/file.txt"' in command
@pytest.mark.asyncio
async def test_box_availability_helper_handles_unavailable_and_errors():
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available