diff --git a/src/langbot/pkg/box/sandbox_files.py b/src/langbot/pkg/box/sandbox_files.py new file mode 100644 index 000000000..730260149 --- /dev/null +++ b/src/langbot/pkg/box/sandbox_files.py @@ -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)) + """) diff --git a/src/langbot/pkg/box/secure_fs.py b/src/langbot/pkg/box/secure_fs.py index 4e6c7de3a..2a0d2166a 100644 --- a/src/langbot/pkg/box/secure_fs.py +++ b/src/langbot/pkg/box/secure_fs.py @@ -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: diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 4527b954d..c4c4552c1 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -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//`` (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}') diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 301fbd2d5..aa7471411 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -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): diff --git a/tests/unit_tests/box/test_outbox_permissions.py b/tests/unit_tests/box/test_outbox_permissions.py new file mode 100644 index 000000000..0adf9f8e5 --- /dev/null +++ b/tests/unit_tests/box/test_outbox_permissions.py @@ -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')