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:
huanghuoguoguo
2026-10-17 20:34:49 +08:00
committed by GitHub
5 changed files with 512 additions and 62 deletions
+139
View File
@@ -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))
""")
+13 -1
View File
@@ -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:
+39 -59
View File
@@ -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}')
+2 -2
View File
@@ -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')