From 381c3f4c7d49800484f71305aa0d806262c23826 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 28 Oct 2026 17:30:15 +0100 Subject: [PATCH 1/2] Python: bound Monty file capture --- python/packages/monty/README.md | 12 +- .../_execute_code_tool.py | 75 +++-- .../agent_framework_monty/_instructions.py | 3 +- .../monty/tests/monty/test_monty_codeact.py | 275 +++++++++++++++++- .../references/integrations.md | 7 + 5 files changed, 346 insertions(+), 26 deletions(-) diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index ad63ce90acf..6b7603acde6 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -195,12 +195,22 @@ codeact = MontyCodeActProvider( after each `execute_code` call and returned as `Content.from_data(...)` attachments (with a `path` annotation in `additional_properties`), mirroring Hyperlight's `/output` flow. +- Capture is limited to **20 files and 20 MiB of raw file data per call across + all read-write mounts**, with a **5 MiB per-file limit**. Empty files count + toward the file limit. Files that do not fit the remaining byte budget are + skipped; later smaller files can still be captured. Exact fits are accepted. + Returned base64 data and Python objects add memory overhead beyond the raw + byte budget. +- Files omitted by capture limits produce one text summary, not one warning + per file. Omission does not undo filesystem writes. These limits bound + attachment capture, not filesystem traversal, disk use, or all host memory. - `overlay` mounts buffer writes in memory (nothing leaks to the host and nothing is captured). `read-only` mounts reject writes. - **`resource_limits`** is forwarded straight to Monty's [`ResourceLimits`](https://github.com/pydantic/monty) TypedDict (`max_duration_secs`, `max_memory`, `gc_interval`, `max_recursion_depth`, - `max_suspensions`). + `max_suspensions`). These interpreter limits do not independently bound + host-side post-execution attachment capture. ## DSL inside `execute_code` diff --git a/python/packages/monty/agent_framework_monty/_execute_code_tool.py b/python/packages/monty/agent_framework_monty/_execute_code_tool.py index 551c5964441..ff8cdc89a76 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -6,8 +6,8 @@ applies to a pure-Python interpreter (no backends to choose from). By default the Monty sandbox rejects OS / filesystem / network calls with ``PermissionError``; pass ``workspace_root`` or ``file_mounts`` to expose -scoped host directories, and the tool will capture any files written under -``read-write`` mounts as ``Content`` items in the response. +scoped host directories, and the tool will capture files written under +``read-write`` mounts as ``Content`` items within per-execution capture limits. """ from __future__ import annotations @@ -56,10 +56,18 @@ #: matching the Hyperlight default. Use ``file_mounts`` for any other path. WORKSPACE_MOUNT_PATH = "/input" -#: Maximum bytes per captured output file. Files larger than this are skipped -#: and a ``Content.from_text`` warning is appended in their place. +#: Maximum bytes per captured output file. Oversized files are skipped and +#: reported in one omission summary per execution. MAX_CAPTURED_FILE_BYTES = 5 * 1024 * 1024 # 5 MiB +#: Maximum captured files across all read-write mounts in one execution. +MAX_CAPTURED_FILES = 20 + +#: Maximum raw captured bytes across all read-write mounts in one execution. +MAX_CAPTURED_TOTAL_BYTES = 20 * 1024 * 1024 # 20 MiB + +_CAPTURE_OMISSION_MESSAGE = "[files omitted: capture file-count or byte limits exceeded]" + EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = { "type": "object", "title": "_ExecuteCodeInput", @@ -552,14 +560,25 @@ def _safe_capture_file_stat(*, root: Path, entry: Path) -> os.stat_result | None return entry_stat if stat.S_ISREG(entry_stat.st_mode) else None -def _read_validated_file(*, root: Path, entry: Path, expected: os.stat_result) -> bytes | None: +class _CaptureSizeLimitError(ValueError): + """A file exceeded its remaining capture byte allowance.""" + + +def _read_validated_file( + *, + root: Path, + entry: Path, + expected: os.stat_result, + max_bytes: int, +) -> bytes | None: """Read ``entry`` through one descriptor that is checked against the validated file. ``Path.read_bytes()`` reopens the path, so a component swapped for a link or junction after validation would be followed. Opening first, comparing the descriptor with the validated ``lstat`` identity, and revalidating the path afterwards rejects such a swap. Windows has no no-follow open, so the check is not atomic there, but an undetected swap would have to be applied, reverted, and - applied again around the checks. Returns ``None`` when the file changed or cannot be read safely. + applied again around the checks. Returns ``None`` when the file identity changed or cannot be read + safely. Raises ``_CaptureSizeLimitError`` if the file exceeds ``max_bytes``. """ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) try: @@ -574,8 +593,13 @@ def _read_validated_file(*, root: Path, entry: Path, expected: os.stat_result) - rechecked = _safe_capture_file_stat(root=root, entry=entry) if rechecked is None or (rechecked.st_dev, rechecked.st_ino) != identity: return None + if opened.st_size > max_bytes: + raise _CaptureSizeLimitError("File exceeds the capture byte limit.") with os.fdopen(fd, "rb", closefd=False) as handle: - return handle.read() + data = handle.read(max_bytes + 1) + if len(data) > max_bytes: + raise _CaptureSizeLimitError("File grew beyond the capture byte limit.") + return data except OSError: return None finally: @@ -614,15 +638,16 @@ def _capture_written_files( ) -> list[Content]: """Return :class:`Content` items for files the sandbox wrote during the run. - Mirrors Hyperlight's ``/output`` capture flow: any new or modified real - regular file under a read-write mount is read back as binary and - surfaced as ``Content.from_data`` with a ``path`` annotation in - ``additional_properties``. Links and reparse points are skipped at every - directory level, and each file is read through a descriptor verified - against the validated path, so a malicious workspace cannot trick us into - capturing host files outside the configured mount root. + New or modified real regular files under read-write mounts are surfaced as + ``Content.from_data`` with a ``path`` annotation in ``additional_properties``. + File-count and raw-byte budgets apply across all mounts, with one summary + for files omitted by capture limits. Links and reparse points are skipped at + every directory level, and bounded reads use descriptors verified against + the validated paths. """ captured: list[Content] = [] + total_bytes = 0 + omitted = False for mount in mounts: if mount.mode != "read-write": continue @@ -636,16 +661,19 @@ def _capture_written_files( current = (int(entry_stat.st_size), int(entry_stat.st_mtime_ns)) if before.get(relative) == current: continue # Unchanged. + if len(captured) >= MAX_CAPTURED_FILES: + captured.append(Content.from_text(_CAPTURE_OMISSION_MESSAGE)) + return captured sandbox_path = f"{mount.mount_path.rstrip('/')}/{relative}" - if entry_stat.st_size > MAX_CAPTURED_FILE_BYTES: - captured.append( - Content.from_text( - f"[file {sandbox_path} omitted: {entry_stat.st_size} bytes " - f"exceeds MAX_CAPTURED_FILE_BYTES={MAX_CAPTURED_FILE_BYTES}]" - ) - ) + max_bytes = min(MAX_CAPTURED_FILE_BYTES, MAX_CAPTURED_TOTAL_BYTES - total_bytes) + if entry_stat.st_size > max_bytes: + omitted = True + continue + try: + data = _read_validated_file(root=host_root, entry=entry, expected=entry_stat, max_bytes=max_bytes) + except _CaptureSizeLimitError: + omitted = True continue - data = _read_validated_file(root=host_root, entry=entry, expected=entry_stat) if data is None: continue media_type = mimetypes.guess_type(entry.name)[0] or "application/octet-stream" @@ -656,4 +684,7 @@ def _capture_written_files( additional_properties={"path": sandbox_path}, ) ) + total_bytes += len(data) + if omitted: + captured.append(Content.from_text(_CAPTURE_OMISSION_MESSAGE)) return captured diff --git a/python/packages/monty/agent_framework_monty/_instructions.py b/python/packages/monty/agent_framework_monty/_instructions.py index c7e6ad9c6a3..98721b95edf 100644 --- a/python/packages/monty/agent_framework_monty/_instructions.py +++ b/python/packages/monty/agent_framework_monty/_instructions.py @@ -76,7 +76,8 @@ def _format_filesystem_capabilities(mounts: Sequence[FileMount]) -> str: writable_paths = ", ".join(f"`{m.mount_path}`" for m in writable) lines.append( f"Files written to {writable_paths} are returned to the caller as attached files; " - "use these paths for any output artifacts." + "use these paths for any output artifacts. Capture file-count and byte limits apply across " + "all mounts per execution; omitted files are reported in one summary." ) return "\n".join(lines) diff --git a/python/packages/monty/tests/monty/test_monty_codeact.py b/python/packages/monty/tests/monty/test_monty_codeact.py index d5f5db71cc2..b1e913578f0 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact.py +++ b/python/packages/monty/tests/monty/test_monty_codeact.py @@ -17,7 +17,8 @@ import subprocess import sys import types -from collections.abc import Callable, Iterable, Iterator, Mapping +from collections.abc import Callable, Generator, Iterable, Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass, field from pathlib import Path from typing import Annotated, Any, Literal @@ -730,6 +731,8 @@ def test_instruction_builders_describe_write_caps_and_visible_tools(tmp_path: Pa assert "write cap 128 bytes" in description assert "Files written to `/work` are returned" in description + assert "Capture file-count and byte limits apply across all mounts per execution" in description + assert "Capture file-count and byte limits apply across all mounts per execution" in instructions assert "Some tools may also appear directly" in instructions @@ -1017,7 +1020,8 @@ def test_capture_written_files_returns_new_files_and_omits_large_ones( "/work/existing.txt", "/work/nested/report.txt", } - assert any("large.txt" in (item.text or "") and "omitted" in (item.text or "") for item in text_items) + assert len(text_items) == 1 + assert "omitted" in (text_items[0].text or "") assert any( _decode_content_bytes(item) == b"after" for item in data_items @@ -1027,6 +1031,178 @@ def test_capture_written_files_returns_new_files_and_omits_large_ones( assert all(item.additional_properties["path"] != "/work/unchanged.txt" for item in data_items) +def test_capture_written_files_default_limits() -> None: + assert execute_code_module.MAX_CAPTURED_FILES == 20 + assert execute_code_module.MAX_CAPTURED_FILE_BYTES == 5 * 1024 * 1024 + assert execute_code_module.MAX_CAPTURED_TOTAL_BYTES == 20 * 1024 * 1024 + + +@pytest.mark.parametrize("file_count", [0, 19, 20, 21]) +@pytest.mark.parametrize("mount_count", [1, 2]) +def test_capture_written_files_bounds_empty_attachment_count( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + file_count: int, + mount_count: int, +) -> None: + from agent_framework_monty import FileMount + + mounts = [] + for index in range(mount_count): + root = tmp_path / str(index) + root.mkdir() + mounts.append(FileMount(host_path=root, mount_path=f"/work{index}", mode="read-write")) + for index in range(file_count): + (Path(mounts[index % mount_count].host_path) / f"{index:02}.bin").write_bytes(b"") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 8) + read_file = MagicMock(wraps=execute_code_module._read_validated_file) + encode = MagicMock(wraps=Content.from_data) + monkeypatch.setattr(execute_code_module, "_read_validated_file", read_file) + monkeypatch.setattr(Content, "from_data", encode) + + captured = execute_code_module._capture_written_files(mounts, {}) + data_items = [item for item in captured if item.type == "data"] + text_items = [item for item in captured if item.type == "text"] + + expected_count = min(file_count, 20) + assert len(data_items) == expected_count + assert all(_decode_content_bytes(item) == b"" for item in data_items) + assert len(text_items) == int(file_count > 20) + assert read_file.call_count == expected_count + assert encode.call_count == expected_count + + +@pytest.mark.parametrize( + ("sizes", "expected_sizes", "omitted"), + [ + ([4, 3], [4, 3], False), + ([4, 4], [4, 4], False), + ([4, 5, 4], [4, 4], True), + ([8, 1, 0], [8, 0], True), + ([3, 0, 6, 5], [3, 0, 5], True), + ], +) +def test_capture_written_files_bounds_total_bytes_across_mounts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + sizes: list[int], + expected_sizes: list[int], + omitted: bool, +) -> None: + from agent_framework_monty import FileMount + + roots = [tmp_path / "first", tmp_path / "second"] + for root in roots: + root.mkdir() + mounts = [ + FileMount(host_path=root, mount_path=f"/work{index}", mode="read-write") for index, root in enumerate(roots) + ] + for index, size in enumerate(sizes): + (roots[min(index, 1)] / f"{index:02}.bin").write_bytes(b"x" * size) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 8) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_TOTAL_BYTES", 8) + read_file = MagicMock(wraps=execute_code_module._read_validated_file) + encode = MagicMock(wraps=Content.from_data) + monkeypatch.setattr(execute_code_module, "_read_validated_file", read_file) + monkeypatch.setattr(Content, "from_data", encode) + + captured = execute_code_module._capture_written_files(mounts, {}) + data_items = [item for item in captured if item.type == "data"] + text_items = [item for item in captured if item.type == "text"] + + assert [len(_decode_content_bytes(item)) for item in data_items] == expected_sizes + assert len(text_items) == int(omitted) + assert read_file.call_count == len(expected_sizes) + assert encode.call_count == len(expected_sizes) + remaining = 8 + for call, size in zip(read_file.call_args_list, expected_sizes, strict=True): + assert call.kwargs["max_bytes"] == min(8, remaining) + remaining -= size + + +@pytest.mark.parametrize("skipped_kind", ["unchanged", "unsafe", "unreadable"]) +def test_capture_written_files_does_not_charge_skipped_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + skipped_kind: str, +) -> None: + from agent_framework_monty import FileMount + + ignored = tmp_path / "a-ignored.bin" + ignored.write_bytes(b"xx") + mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write") + before = execute_code_module._snapshot_writable_mounts([mount]) if skipped_kind == "unchanged" else {} + (tmp_path / "z-safe.bin").write_bytes(b"ok") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILES", 1) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_TOTAL_BYTES", 2) + + if skipped_kind == "unsafe": + original_stat = execute_code_module._safe_capture_file_stat + + def safe_stat(*, root: Path, entry: Path) -> os.stat_result | None: + return None if entry == ignored else original_stat(root=root, entry=entry) + + monkeypatch.setattr(execute_code_module, "_safe_capture_file_stat", safe_stat) + elif skipped_kind == "unreadable": + original_read = execute_code_module._read_validated_file + + def read_file(*, root: Path, entry: Path, expected: os.stat_result, max_bytes: int) -> bytes | None: + if entry == ignored: + return None + return original_read(root=root, entry=entry, expected=expected, max_bytes=max_bytes) + + monkeypatch.setattr(execute_code_module, "_read_validated_file", read_file) + + captured = execute_code_module._capture_written_files([mount], before) + + assert len(captured) == 1 + assert _decode_content_bytes(captured[0]) == b"ok" + + +def test_capture_written_files_bounds_omission_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent_framework_monty import FileMount + + for index in range(32): + (tmp_path / f"{index:02}-{'x' * 100}.bin").write_bytes(b"xxx") + mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 2) + read_file = MagicMock(wraps=execute_code_module._read_validated_file) + encode = MagicMock(wraps=Content.from_data) + monkeypatch.setattr(execute_code_module, "_read_validated_file", read_file) + monkeypatch.setattr(Content, "from_data", encode) + + captured = execute_code_module._capture_written_files([mount], {}) + + assert len(captured) == 1 + assert captured[0].type == "text" + assert "omitted" in (captured[0].text or "") + assert len(captured[0].text or "") < 200 + read_file.assert_not_called() + encode.assert_not_called() + + +def test_capture_written_files_resets_budgets_between_calls( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agent_framework_monty import FileMount + + (tmp_path / "first.bin").write_bytes(b"ok") + (tmp_path / "second.bin").write_bytes(b"no") + mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILES", 1) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_TOTAL_BYTES", 2) + + for _ in range(2): + captured = execute_code_module._capture_written_files([mount], {}) + assert len(captured) == 2 + assert _decode_content_bytes(captured[0]) == b"ok" + assert captured[1].type == "text" + + def _create_junction_or_skip(*, link: Path, target: Path) -> None: if sys.platform != "win32": pytest.skip("Windows directory junctions are only available on Windows") @@ -1325,6 +1501,101 @@ def swap() -> None: assert not any(item.type == "data" for item in captured) +@pytest.mark.parametrize("limit_kind", ["per-file", "total"]) +def test_capture_written_files_rejects_growth_before_opening( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + limit_kind: str, +) -> None: + from agent_framework_monty import FileMount + + artifact = tmp_path / "a-growing.bin" + artifact.write_bytes(b"xxxx") + (tmp_path / "z-safe.bin").write_bytes(b"ok") + mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILES", 1) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 5 if limit_kind == "per-file" else 8) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_TOTAL_BYTES", 5 if limit_kind == "total" else 8) + + def grow() -> None: + artifact.write_bytes(b"x" * 6) + + _swap_after_first_validation(monkeypatch, grow) + open_reader = MagicMock(wraps=os.fdopen) + close = MagicMock(wraps=os.close) + encode = MagicMock(wraps=Content.from_data) + monkeypatch.setattr(os, "fdopen", open_reader) + monkeypatch.setattr(os, "close", close) + monkeypatch.setattr(Content, "from_data", encode) + + captured = execute_code_module._capture_written_files([mount], {}) + + assert len(captured) == 2 + assert _decode_content_bytes(captured[0]) == b"ok" + assert captured[1].type == "text" + assert open_reader.call_count == 1 + assert close.call_count == 2 + assert encode.call_count == 1 + + +@pytest.mark.parametrize( + ("initial_size", "grown_size", "safe_size", "total_limit", "expected_sizes", "omitted"), + [ + (4, 0, 8, 8, [0, 8], False), + (4, 5, 4, 8, [5], True), + (4, 8, 1, 8, [8], True), + (4, 9, 4, 8, [4], True), + (0, 1, 0, 0, [0], True), + ], +) +def test_capture_written_files_bounds_reads_and_charges_actual_bytes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + initial_size: int, + grown_size: int, + safe_size: int, + total_limit: int, + expected_sizes: list[int], + omitted: bool, +) -> None: + from agent_framework_monty import FileMount + + artifact = tmp_path / "a-growing.bin" + artifact.write_bytes(b"x" * initial_size) + (tmp_path / "z-safe.bin").write_bytes(b"s" * safe_size) + mount = FileMount(host_path=tmp_path, mount_path="/work", mode="read-write") + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_FILE_BYTES", 8) + monkeypatch.setattr(execute_code_module, "MAX_CAPTURED_TOTAL_BYTES", total_limit) + original_fdopen = os.fdopen + readers: list[MagicMock] = [] + + @contextmanager + def growing_reader(fd: int, mode: str, *, closefd: bool) -> Generator[MagicMock, None, None]: + if not readers: + artifact.write_bytes(b"g" * grown_size) + with original_fdopen(fd, mode, closefd=closefd) as handle: + reader = MagicMock(wraps=handle) + readers.append(reader) + yield reader + + read_file = MagicMock(wraps=execute_code_module._read_validated_file) + encode = MagicMock(wraps=Content.from_data) + monkeypatch.setattr(os, "fdopen", growing_reader) + monkeypatch.setattr(execute_code_module, "_read_validated_file", read_file) + monkeypatch.setattr(Content, "from_data", encode) + + captured = execute_code_module._capture_written_files([mount], {}) + data_items = [item for item in captured if item.type == "data"] + text_items = [item for item in captured if item.type == "text"] + + assert [len(_decode_content_bytes(item)) for item in data_items] == expected_sizes + assert len(text_items) == int(omitted) + assert encode.call_count == len(expected_sizes) + assert len(readers) == read_file.call_count + for reader, call in zip(readers, read_file.call_args_list, strict=True): + reader.read.assert_called_once_with(call.kwargs["max_bytes"] + 1) + + # --------------------------------------------------------------------------- # MontyCodeActProvider tests # --------------------------------------------------------------------------- diff --git a/skills/agent-framework-builder-python/references/integrations.md b/skills/agent-framework-builder-python/references/integrations.md index 689008db810..415d4b3336e 100644 --- a/skills/agent-framework-builder-python/references/integrations.md +++ b/skills/agent-framework-builder-python/references/integrations.md @@ -44,6 +44,13 @@ client when its lifetime must extend beyond any one agent context. Do not assume For AG-UI, remember that thread and run IDs are correlation identifiers, not authentication boundaries. The application owns authorization, durable snapshots, scaling, and rendering policy. +## Code execution + +For Monty, distinguish interpreter `resource_limits` from host-side attachment capture. Capture has per-call +file-count and raw-byte limits shared across read-write mounts. Omitted attachments do not undo filesystem writes. +Check the [Monty file-mount and resource-limit guidance](https://github.com/microsoft/agent-framework/blob/main/python/packages/monty/README.md#file-mounts-and-resource-limits) +before relying on output attachments or treating interpreter limits as host resource limits. + ## Selection checklist Confirm the provider/service, authentication model, required capabilities, package maturity, Python range, data and From 04b249073fda418fb2a57ec14e6aaec4aecb9758 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 28 Oct 2026 19:16:36 +0100 Subject: [PATCH 2/2] Python: simplify capture test generator annotation --- python/packages/monty/tests/monty/test_monty_codeact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/monty/tests/monty/test_monty_codeact.py b/python/packages/monty/tests/monty/test_monty_codeact.py index b1e913578f0..adfe3d7b5e5 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact.py +++ b/python/packages/monty/tests/monty/test_monty_codeact.py @@ -1570,7 +1570,7 @@ def test_capture_written_files_bounds_reads_and_charges_actual_bytes( readers: list[MagicMock] = [] @contextmanager - def growing_reader(fd: int, mode: str, *, closefd: bool) -> Generator[MagicMock, None, None]: + def growing_reader(fd: int, mode: str, *, closefd: bool) -> Generator[MagicMock]: if not readers: artifact.write_bytes(b"g" * grown_size) with original_fdopen(fd, mode, closefd=closefd) as handle: