mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
Merge pull request #2703 from langbot-app/fix/box-root-owned-export-20261017
fix(box): recover private outbox files without following links
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Bounded, link-free outbox operations executed inside the bound sandbox."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
|
||||
def outbox_script(
|
||||
outbox_root: str,
|
||||
query_key: str,
|
||||
*,
|
||||
operation: str,
|
||||
max_file_bytes: int,
|
||||
max_files: int,
|
||||
max_total_bytes: int,
|
||||
) -> str:
|
||||
settings = {
|
||||
'workspace': os.path.dirname(outbox_root),
|
||||
'outbox': os.path.basename(outbox_root),
|
||||
'query': query_key,
|
||||
'operation': operation,
|
||||
'max_file_bytes': max_file_bytes,
|
||||
'max_files': max_files,
|
||||
'max_total_bytes': max_total_bytes,
|
||||
}
|
||||
return f'settings = {settings!r}\n' + textwrap.dedent("""\
|
||||
import base64, errno, json, os, stat
|
||||
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||
out = []
|
||||
budget = {'entries': 512, 'directories': 64, 'bytes': 0}
|
||||
|
||||
def component(value):
|
||||
if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240
|
||||
or any(c in value for c in ('/', '\\\\', '\\x00'))):
|
||||
raise RuntimeError('Unsafe sandbox outbox path component')
|
||||
return value
|
||||
|
||||
def read_directory(fd, prefix='', depth=0):
|
||||
if depth > 8:
|
||||
return
|
||||
with os.scandir(fd) as entries:
|
||||
for entry in entries:
|
||||
if len(out) >= settings['max_files']:
|
||||
return
|
||||
if budget['entries'] <= 0:
|
||||
raise RuntimeError('Sandbox outbox exceeds the directory-entry limit')
|
||||
budget['entries'] -= 1
|
||||
name = component(entry.name)
|
||||
relative = prefix + '/' + name if prefix else name
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if budget['directories'] <= 0:
|
||||
raise RuntimeError('Sandbox outbox exceeds the directory limit')
|
||||
budget['directories'] -= 1
|
||||
flags = directory_flags
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
flags = file_flags
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
child_fd = os.open(name, flags, dir_fd=fd)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOENT, errno.ELOOP, errno.ENOTDIR}:
|
||||
continue
|
||||
raise
|
||||
try:
|
||||
metadata = os.fstat(child_fd)
|
||||
if flags == directory_flags and stat.S_ISDIR(metadata.st_mode):
|
||||
read_directory(child_fd, relative, depth + 1)
|
||||
continue
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
continue
|
||||
remaining = settings['max_total_bytes'] - budget['bytes']
|
||||
if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining:
|
||||
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
|
||||
with os.fdopen(child_fd, 'rb', closefd=False) as source:
|
||||
data = source.read(settings['max_file_bytes'] + 1)
|
||||
if len(data) > settings['max_file_bytes'] or len(data) > remaining:
|
||||
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
|
||||
out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')})
|
||||
budget['bytes'] += len(data)
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
|
||||
def clear_directory(fd, depth=0):
|
||||
if depth > 16:
|
||||
raise RuntimeError('Sandbox cleanup exceeded its depth limit')
|
||||
with os.scandir(fd) as entries:
|
||||
for entry in entries:
|
||||
if budget['entries'] <= 0:
|
||||
raise RuntimeError('Sandbox cleanup exceeded its inode limit')
|
||||
budget['entries'] -= 1
|
||||
name = component(entry.name)
|
||||
try:
|
||||
child_fd = os.open(name, directory_flags, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno not in {errno.ELOOP, errno.ENOTDIR}:
|
||||
raise
|
||||
try:
|
||||
os.unlink(name, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
clear_directory(child_fd, depth + 1)
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
try:
|
||||
os.rmdir(name, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
workspace_fd = os.open(settings['workspace'], directory_flags)
|
||||
try:
|
||||
outbox_fd = os.open(component(settings['outbox']), directory_flags, dir_fd=workspace_fd)
|
||||
try:
|
||||
query_fd = os.open(component(settings['query']), directory_flags, dir_fd=outbox_fd)
|
||||
try:
|
||||
if settings['operation'] == 'read':
|
||||
read_directory(query_fd)
|
||||
elif settings['operation'] == 'clear':
|
||||
budget['entries'] = 4096
|
||||
clear_directory(query_fd)
|
||||
else:
|
||||
raise RuntimeError('Unknown sandbox outbox operation')
|
||||
finally:
|
||||
os.close(query_fd)
|
||||
finally:
|
||||
os.close(outbox_fd)
|
||||
finally:
|
||||
os.close(workspace_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print(json.dumps(out))
|
||||
""")
|
||||
@@ -14,7 +14,9 @@ class UnsafeWorkspacePathError(OSError):
|
||||
_DIRECTORY_FLAGS = (
|
||||
os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
)
|
||||
_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
_FILE_READ_FLAGS = (
|
||||
os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0)
|
||||
)
|
||||
_FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
_MAX_REMOVAL_ENTRIES = 4096
|
||||
_MAX_REMOVAL_DEPTH = 16
|
||||
@@ -45,6 +47,8 @@ def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathE
|
||||
def _root_fd(root: str):
|
||||
try:
|
||||
fd = os.open(root, _DIRECTORY_FLAGS)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise _unsafe(root, exc)
|
||||
try:
|
||||
@@ -64,6 +68,8 @@ def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int:
|
||||
pass
|
||||
try:
|
||||
fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise _unsafe(name, exc)
|
||||
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
||||
@@ -91,6 +97,8 @@ def _remove_entry(
|
||||
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
|
||||
raise
|
||||
@@ -198,6 +206,8 @@ def _read_directory(
|
||||
remaining_directories[0] -= 1
|
||||
try:
|
||||
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
@@ -218,6 +228,8 @@ def _read_directory(
|
||||
continue
|
||||
try:
|
||||
file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -1129,12 +1129,10 @@ class BoxService:
|
||||
async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Collect files the agent produced in the sandbox outbox.
|
||||
|
||||
Reads ``/workspace/outbox/<query_id>/`` (recursively) — directly from
|
||||
the bind-mounted host directory when available (no size limit), else
|
||||
via the exec channel — returns a list of ``{type, name, base64}``
|
||||
ready to become platform message components, then clears the outbox so
|
||||
a later turn in the same session does not re-send stale files. Returns
|
||||
``[]`` when nothing was produced.
|
||||
Read regular files within the configured attachment limits from the
|
||||
current run's outbox. Private container output uses the bound sandbox
|
||||
channel; invalid or truncated responses retain the files. Clear a
|
||||
successful collection and return ``{type, name, base64}`` descriptors.
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
@@ -1143,7 +1141,11 @@ class BoxService:
|
||||
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
|
||||
if host_dir is not None:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
try:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
except PermissionError:
|
||||
# Root-owned container output can be private to its sandbox UID.
|
||||
entries = await self._read_outbox_via_exec(query)
|
||||
else:
|
||||
entries = await self._read_outbox_via_exec(query)
|
||||
|
||||
@@ -1187,57 +1189,17 @@ class BoxService:
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
|
||||
# Outbound bytes travel in raw stdout, not argv. Use the regular
|
||||
# attachment limits rather than the smaller inbound command limit.
|
||||
from .sandbox_files import outbox_script
|
||||
|
||||
max_file_bytes = self._ATTACHMENT_MAX_BYTES
|
||||
max_files = self._ATTACHMENT_MAX_FILES
|
||||
max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES
|
||||
max_scan_entries = 1000
|
||||
script = (
|
||||
'import base64, json, os\n'
|
||||
f'target = {target_dir!r}\n'
|
||||
f'max_file_bytes = {max_file_bytes}\n'
|
||||
f'max_files = {max_files}\n'
|
||||
f'max_total_bytes = {max_total_bytes}\n'
|
||||
f'max_scan_entries = {max_scan_entries}\n'
|
||||
'out = []\n'
|
||||
'total_bytes = 0\n'
|
||||
'scanned_entries = 0\n'
|
||||
'stack = [target]\n'
|
||||
'if os.path.isdir(target):\n'
|
||||
' while stack and len(out) < max_files and scanned_entries < max_scan_entries:\n'
|
||||
' current = stack.pop()\n'
|
||||
' try:\n'
|
||||
' with os.scandir(current) as iterator:\n'
|
||||
' entries = sorted(iterator, key=lambda item: item.name, reverse=True)\n'
|
||||
' except OSError:\n'
|
||||
' continue\n'
|
||||
' for entry in entries:\n'
|
||||
' scanned_entries += 1\n'
|
||||
' if scanned_entries > max_scan_entries:\n'
|
||||
' break\n'
|
||||
' try:\n'
|
||||
' if entry.is_dir(follow_symlinks=False):\n'
|
||||
' stack.append(entry.path)\n'
|
||||
' continue\n'
|
||||
' if not entry.is_file(follow_symlinks=False):\n'
|
||||
' continue\n'
|
||||
' size = entry.stat(follow_symlinks=False).st_size\n'
|
||||
' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n'
|
||||
" raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n"
|
||||
" with open(entry.path, 'rb') as f:\n"
|
||||
' data = f.read(max_file_bytes + 1)\n'
|
||||
' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n'
|
||||
" raise RuntimeError('Sandbox output attachment exceeds the export size limit')\n"
|
||||
' except OSError:\n'
|
||||
' continue\n'
|
||||
' rel = os.path.relpath(entry.path, target)\n'
|
||||
" out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n"
|
||||
' total_bytes += len(data)\n'
|
||||
' if len(out) >= max_files:\n'
|
||||
' break\n'
|
||||
'print(json.dumps(out))\n'
|
||||
script = outbox_script(
|
||||
self.OUTBOX_MOUNT_DIR,
|
||||
self._attachment_query_key(query),
|
||||
operation='read',
|
||||
max_file_bytes=max_file_bytes,
|
||||
max_files=self._ATTACHMENT_MAX_FILES,
|
||||
max_total_bytes=max_total_bytes,
|
||||
)
|
||||
spec_payload: dict = {
|
||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||
@@ -1288,19 +1250,37 @@ class BoxService:
|
||||
try:
|
||||
secure_fs.reset_directory(tenant_root, subdir, query_key)
|
||||
return False
|
||||
except secure_fs.UnsafeWorkspacePathError:
|
||||
raise
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
try:
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
except secure_fs.UnsafeWorkspacePathError as exc:
|
||||
self.ap.logger.warning(f'Failed to clear unsafe sandbox outbox {target_dir}: {exc}')
|
||||
return
|
||||
if not survived:
|
||||
return
|
||||
# Root-owned container files survived the host delete — fall through.
|
||||
|
||||
try:
|
||||
await self.execute_tool(
|
||||
{'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30},
|
||||
from .sandbox_files import outbox_script
|
||||
|
||||
script = outbox_script(
|
||||
self.OUTBOX_MOUNT_DIR,
|
||||
self._attachment_query_key(query),
|
||||
operation='clear',
|
||||
max_file_bytes=self._ATTACHMENT_MAX_BYTES,
|
||||
max_files=self._ATTACHMENT_MAX_FILES,
|
||||
max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 30},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
raise BoxValidationError('Sandbox outbox cleanup failed')
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}')
|
||||
|
||||
|
||||
@@ -2117,9 +2117,9 @@ class TestInboundOutboundRoundTrip:
|
||||
service.client.execute = AsyncMock(side_effect=fake_client_execute)
|
||||
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
|
||||
assert await service.collect_outbound_attachments(query) == []
|
||||
# cleanup (rm -rf) is issued unconditionally now
|
||||
# Cleanup is issued even when the outbox is empty.
|
||||
service.execute_tool.assert_awaited_once()
|
||||
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
|
||||
assert 'clear_directory' in service.execute_tool.await_args.args[0]['command']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passthrough_noop_when_unavailable(self):
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Permission recovery must keep sandbox exports inside the bound run."""
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.box.models import BoxExecutionResult, BoxExecutionStatus
|
||||
from langbot.pkg.box import secure_fs
|
||||
from tests.unit_tests.box import test_box_service as fixtures
|
||||
|
||||
|
||||
@pytest.mark.parametrize('denied_component', ['root', 'outbox', 'run', 'nested', 'answer.txt'])
|
||||
def test_unreadable_host_file_is_not_silently_dropped(tmp_path, monkeypatch, denied_component):
|
||||
outbox = tmp_path / 'outbox' / 'run' / 'nested'
|
||||
outbox.mkdir(parents=True)
|
||||
(outbox / 'answer.txt').write_bytes(b'private output')
|
||||
original_open = os.open
|
||||
|
||||
def denied_open(path, flags, *args, **kwargs):
|
||||
target = str(tmp_path) if denied_component == 'root' else denied_component
|
||||
if path == target:
|
||||
raise PermissionError(errno.EACCES, 'container-owned output', path)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
|
||||
with pytest.raises(PermissionError):
|
||||
secure_fs.read_regular_files(
|
||||
str(tmp_path), 'outbox', 'run', max_file_bytes=1024, max_files=20, max_total_bytes=4096
|
||||
)
|
||||
|
||||
|
||||
def test_host_outbox_skips_fifo_without_waiting_for_a_writer(tmp_path):
|
||||
outbox = tmp_path / 'outbox' / 'run'
|
||||
outbox.mkdir(parents=True)
|
||||
os.mkfifo(outbox / 'pipe')
|
||||
script = (
|
||||
'from langbot.pkg.box import secure_fs; '
|
||||
f'assert secure_fs.read_regular_files({str(tmp_path)!r}, "outbox", "run", '
|
||||
'max_file_bytes=1024, max_files=20, max_total_bytes=4096) == []'
|
||||
)
|
||||
subprocess.run([sys.executable, '-c', script], check=True, timeout=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unreadable_host_output_uses_the_bound_sandbox(tmp_path):
|
||||
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
|
||||
query = fixtures.make_query()
|
||||
outbox = Path(workspace) / 'outbox'
|
||||
current = outbox / 'query-42'
|
||||
current.mkdir(parents=True)
|
||||
(current / 'answer.txt').write_bytes(b'private output')
|
||||
(current / ' answer.txt ').write_bytes(b'spaced output')
|
||||
other = outbox / 'other-run'
|
||||
other.mkdir()
|
||||
(other / 'secret.txt').write_bytes(b'other run secret')
|
||||
(current / 'leak.txt').symlink_to(other / 'secret.txt')
|
||||
service.OUTBOX_MOUNT_DIR = str(outbox)
|
||||
service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output'))
|
||||
service._clear_outbox = AsyncMock()
|
||||
observed = []
|
||||
|
||||
async def execute(spec, *, action_context):
|
||||
assert action_context == fixtures._ACTION_CONTEXT
|
||||
assert spec.session_id == 'person_test_user'
|
||||
assert spec.host_path == workspace
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
spec.cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
observed.append(spec)
|
||||
return BoxExecutionResult(
|
||||
session_id=spec.session_id,
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=process.returncode,
|
||||
stdout=stdout.decode(),
|
||||
stderr=stderr.decode(),
|
||||
duration_ms=0,
|
||||
)
|
||||
|
||||
service.client.execute = AsyncMock(side_effect=execute)
|
||||
attachments = await service.collect_outbound_attachments(query)
|
||||
assert {item['name']: item['base64'] for item in attachments} == {
|
||||
'answer.txt': 'cHJpdmF0ZSBvdXRwdXQ=',
|
||||
' answer.txt ': 'c3BhY2VkIG91dHB1dA==',
|
||||
}
|
||||
assert len(observed) == 1
|
||||
assert (other / 'secret.txt').read_bytes() == b'other run secret'
|
||||
service._clear_outbox.assert_awaited_once_with(query, str(current))
|
||||
|
||||
|
||||
def _script(root, operation='read', **limits):
|
||||
from langbot.pkg.box.sandbox_files import outbox_script
|
||||
|
||||
return outbox_script(
|
||||
str(root / 'workspace' / 'outbox'),
|
||||
'run',
|
||||
operation=operation,
|
||||
max_file_bytes=limits.get('max_file_bytes', 1024),
|
||||
max_files=limits.get('max_files', 20),
|
||||
max_total_bytes=limits.get('max_total_bytes', 4096),
|
||||
)
|
||||
|
||||
|
||||
def _run_script(script):
|
||||
return subprocess.run([sys.executable, '-c', script], capture_output=True, text=True, timeout=3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['read', 'clear'])
|
||||
@pytest.mark.parametrize('component', ['workspace', 'outbox', 'run'])
|
||||
def test_sandbox_outbox_never_follows_ancestor_links(tmp_path, operation, component):
|
||||
workspace = tmp_path / 'workspace'
|
||||
current = workspace / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
other = tmp_path / 'other'
|
||||
other.mkdir()
|
||||
target = {
|
||||
'workspace': other / 'outbox' / 'run',
|
||||
'outbox': other / 'run',
|
||||
'run': other,
|
||||
}[component]
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
protected = target / 'secret.txt'
|
||||
protected.write_bytes(b'other run secret')
|
||||
import shutil
|
||||
|
||||
link = {'workspace': workspace, 'outbox': current.parent, 'run': current}[component]
|
||||
shutil.rmtree(link)
|
||||
link.symlink_to(other, target_is_directory=True)
|
||||
result = _run_script(_script(tmp_path, operation))
|
||||
assert result.returncode != 0
|
||||
assert protected.read_bytes() == b'other run secret'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('replacement', ['symlink', 'fifo', 'directory'])
|
||||
def test_sandbox_file_open_resists_type_changes(tmp_path, replacement):
|
||||
import json
|
||||
|
||||
current = tmp_path / 'workspace' / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
(current / 'answer.txt').write_bytes(b'expected')
|
||||
secret = tmp_path / 'secret.txt'
|
||||
secret.write_bytes(b'outside secret')
|
||||
# Change the real filesystem after classification, immediately before open.
|
||||
prelude = f"""
|
||||
import os
|
||||
original_open = os.open
|
||||
replacement = {replacement!r}
|
||||
def racing_open(path, flags, *args, **kwargs):
|
||||
fd = kwargs.get('dir_fd')
|
||||
if path == 'answer.txt' and fd is not None:
|
||||
os.unlink(path, dir_fd=fd)
|
||||
if replacement == 'symlink':
|
||||
os.symlink({str(secret)!r}, path, dir_fd=fd)
|
||||
elif replacement == 'fifo':
|
||||
os.mkfifo(path, dir_fd=fd)
|
||||
else:
|
||||
os.mkdir(path, dir_fd=fd)
|
||||
child = original_open(path, os.O_RDONLY | os.O_DIRECTORY, dir_fd=fd)
|
||||
try:
|
||||
output = original_open('secret.txt', os.O_WRONLY | os.O_CREAT, 0o600, dir_fd=child)
|
||||
os.write(output, b'unexpected directory')
|
||||
os.close(output)
|
||||
finally:
|
||||
os.close(child)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
os.open = racing_open
|
||||
"""
|
||||
result = _run_script(prelude + _script(tmp_path))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == []
|
||||
assert secret.read_bytes() == b'outside secret'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('limit', ['file_bytes', 'total_bytes', 'total_bytes_exact', 'entries', 'directories'])
|
||||
def test_sandbox_output_limits_fail_without_removing_files(tmp_path, limit):
|
||||
current = tmp_path / 'workspace' / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
options = {}
|
||||
if limit == 'file_bytes':
|
||||
(current / 'answer.txt').write_bytes(b'1234')
|
||||
options['max_file_bytes'] = 3
|
||||
elif limit in {'total_bytes', 'total_bytes_exact'}:
|
||||
(current / 'a.txt').write_bytes(b'12')
|
||||
(current / 'b.txt').write_bytes(b'34')
|
||||
options['max_total_bytes'] = 3
|
||||
if limit == 'total_bytes_exact':
|
||||
(current / 'c.txt').write_bytes(b'56')
|
||||
options['max_total_bytes'] = 4
|
||||
elif limit == 'entries':
|
||||
for index in range(513):
|
||||
(current / str(index)).symlink_to(tmp_path / 'missing')
|
||||
else:
|
||||
for index in range(65):
|
||||
(current / str(index)).mkdir()
|
||||
before = set(current.iterdir())
|
||||
result = _run_script(_script(tmp_path, **options))
|
||||
assert result.returncode != 0
|
||||
assert 'limit' in result.stderr
|
||||
assert set(current.iterdir()) == before
|
||||
|
||||
|
||||
def test_sandbox_output_count_and_depth_are_bounded(tmp_path):
|
||||
import json
|
||||
|
||||
current = tmp_path / 'workspace' / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
(current / 'a.txt').write_bytes(b'a')
|
||||
(current / 'b.txt').write_bytes(b'b')
|
||||
result = _run_script(_script(tmp_path, max_files=1))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert len(json.loads(result.stdout)) == 1
|
||||
(current / 'a.txt').unlink()
|
||||
(current / 'b.txt').unlink()
|
||||
deep = current.joinpath(*('nested' for _ in range(9)))
|
||||
deep.mkdir(parents=True)
|
||||
(deep / 'too-deep.txt').write_bytes(b'not exported')
|
||||
result = _run_script(_script(tmp_path))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['read', 'clear'])
|
||||
def test_sandbox_nested_links_do_not_touch_their_targets(tmp_path, operation):
|
||||
import json
|
||||
|
||||
current = tmp_path / 'workspace' / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
other = tmp_path / 'other'
|
||||
other.mkdir()
|
||||
protected = other / 'secret.txt'
|
||||
protected.write_bytes(b'protected')
|
||||
(current / 'linked-dir').symlink_to(other, target_is_directory=True)
|
||||
(current / 'linked-file').symlink_to(protected)
|
||||
result = _run_script(_script(tmp_path, operation))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == []
|
||||
assert protected.read_bytes() == b'protected'
|
||||
if operation == 'clear':
|
||||
assert list(current.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['permission', 'truncated-json'])
|
||||
async def test_permission_recovery_failure_does_not_clear_output(tmp_path, failure):
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
|
||||
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
|
||||
query = fixtures.make_query()
|
||||
current = Path(workspace) / 'outbox' / 'query-42'
|
||||
current.mkdir(parents=True)
|
||||
output = current / 'answer.txt'
|
||||
output.write_bytes(b'keep on failure')
|
||||
service._read_outbox_host = Mock(side_effect=PermissionError('container-owned output'))
|
||||
service._clear_outbox = AsyncMock()
|
||||
service.client.execute = AsyncMock(
|
||||
return_value=BoxExecutionResult(
|
||||
session_id='person_test_user',
|
||||
backend_name='test',
|
||||
status=BoxExecutionStatus.COMPLETED,
|
||||
exit_code=1 if failure == 'permission' else 0,
|
||||
stdout='' if failure == 'permission' else '[{"name": "answer.txt", "b64": "truncated',
|
||||
stderr='Permission denied' if failure == 'permission' else '',
|
||||
duration_ms=0,
|
||||
)
|
||||
)
|
||||
error = 'Failed to read sandbox outbox' if failure == 'permission' else 'invalid attachment data'
|
||||
with pytest.raises(BoxValidationError, match=error):
|
||||
await service.collect_outbound_attachments(query)
|
||||
service._clear_outbox.assert_not_awaited()
|
||||
assert output.read_bytes() == b'keep on failure'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_outbox_ancestor_never_uses_cleanup_exec(tmp_path):
|
||||
service, workspace = fixtures.TestAttachmentHostPath()._service_with_workspace(tmp_path)
|
||||
query = fixtures.make_query()
|
||||
outbox = Path(workspace) / 'outbox'
|
||||
other = tmp_path / 'other'
|
||||
protected = other / 'query-42' / 'secret.txt'
|
||||
protected.parent.mkdir(parents=True)
|
||||
protected.write_bytes(b'keep other run')
|
||||
outbox.symlink_to(other, target_is_directory=True)
|
||||
service.execute_tool = AsyncMock(side_effect=AssertionError('unsafe host ancestor must not use exec'))
|
||||
await service._clear_outbox(query, str(outbox / 'query-42'))
|
||||
service.execute_tool.assert_not_awaited()
|
||||
assert protected.read_bytes() == b'keep other run'
|
||||
assert any('unsafe sandbox outbox' in str(call.args[0]) for call in service.ap.logger.warning.call_args_list)
|
||||
|
||||
|
||||
def test_sandbox_cleanup_has_a_bounded_inode_budget(tmp_path):
|
||||
current = tmp_path / 'workspace' / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
for index in range(4097):
|
||||
(current / str(index)).write_bytes(b'')
|
||||
result = _run_script(_script(tmp_path, 'clear'))
|
||||
assert result.returncode != 0
|
||||
assert 'inode limit' in result.stderr
|
||||
assert list(current.iterdir())
|
||||
|
||||
|
||||
def test_host_reset_preserves_permission_error_for_private_run_directory(tmp_path, monkeypatch):
|
||||
current = tmp_path / 'outbox' / 'run'
|
||||
current.mkdir(parents=True)
|
||||
original_open = os.open
|
||||
|
||||
def denied_open(path, flags, *args, **kwargs):
|
||||
if path == 'run' and kwargs.get('dir_fd') is not None:
|
||||
raise PermissionError(errno.EACCES, 'private container directory', path)
|
||||
return original_open(path, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(secure_fs.os, 'open', denied_open)
|
||||
with pytest.raises(PermissionError):
|
||||
secure_fs.reset_directory(str(tmp_path), 'outbox', 'run')
|
||||
Reference in New Issue
Block a user