Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ e2b = [
"pygments>=2.20.0",
]
modal = [
"modal==1.4.3",
"modal==1.6.1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve indefinite retention for persisted Modal snapshots

When workspace_persistence is snapshot_filesystem or snapshot_directory, this upgrade crosses Modal 1.5, which changed both snapshot APIs from indefinite retention to a 30-day default. The persistence methods still call these APIs without ttl= and serialize only the resulting image ID, so after 30 days a previously durable workspace can no longer be restored. Pass ttl=None explicitly to preserve the released behavior; Modal documents this breaking default in its Python SDK release notes.

AGENTS.md reference: AGENTS.md:L111-L111

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the configured timeout to directory snapshots

When a directory snapshot takes more than 55 seconds, Modal 1.5 now terminates it at a new provider-side default even if snapshot_filesystem_timeout_s is None or greater than 55. _persist_workspace_via_snapshot_directory() only applies that public setting through an outer asyncio.wait_for() and invokes snapshot_directory.aio(root) without timeout=, so the configured 60-second default and larger user values no longer control the operation. Forward the configured timeout into the Modal call; the new upstream deadline is documented in the Modal 1.5 release notes.

AGENTS.md reference: AGENTS.md:L115-L115

Useful? React with 👍 / 👎.

"aiohttp>=3.14.3,<4",
"cbor2>=5.9.0",
"h2>=4.4.1",
Expand Down
42 changes: 18 additions & 24 deletions src/agents/extensions/sandbox/modal/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
SandboxError,
WorkspaceArchiveReadError,
WorkspaceArchiveWriteError,
WorkspaceReadNotFoundError,
WorkspaceStartError,
WorkspaceStopError,
WorkspaceWriteTypeError,
Expand Down Expand Up @@ -1251,30 +1252,23 @@ async def _terminate_pty_entry(self, entry: _ModalPtyProcessEntry) -> None:
async def _read_bounded(self, path: Path, *, max_bytes: int) -> bytes:
try:
workspace_path = await self._validate_path_access(path)
await self._ensure_sandbox()
assert self._sandbox is not None
# Each read starts a remote operation. Use Modal's 100 MiB per-read
# ceiling while respecting the caller's remaining byte budget.
stream = await self._sandbox.open.aio(sandbox_path_str(workspace_path), "rb")
completed = False
try:
result = bytearray()
while len(result) < max_bytes:
chunk = await stream.read.aio(min(100 * 1024 * 1024, max_bytes - len(result)))
if not chunk:
break
result.extend(chunk)
payload = bytes(result)
completed = True
return payload
finally:
try:
await asyncio.wait_for(stream.close.aio(), timeout=5.0)
except Exception:
# Preserve an active read failure or cancellation. A close failure
# still fails a read that would otherwise have completed.
if completed:
raise
# Bound acquisition inside the sandbox, using the same exec transport as read().
# Like read(), do not impose an exec deadline: it terminates the whole sandbox.
result = await self.exec(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Terminate the bounded-read process on cancellation

When cancellation arrives after Modal has created the head process but while stdout is still being read—for example, when a regular file on a slow mounted filesystem blocks—_exec_internal() propagates CancelledError without calling ContainerProcess.terminate(). The remote process therefore survives the cancelled read_bounded() call, and repeated cancellations can accumulate blocked processes until the sandbox expires; retain the process handle and terminate it during cancellation cleanup without terminating the entire sandbox.

AGENTS.md reference: AGENTS.md:L117-L118

Useful? React with 👍 / 👎.

"/bin/sh",
"-c",
"PATH=/usr/bin:/bin; export PATH; "
'[ -e "$1" ] || exit 44; [ -f "$1" ] || exit 45; head -c "$2" < "$1"',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

P2 Badge Security: Enforce the Modal read limit outside the sandbox

When a Modal-backed agent can modify image utilities, model-controlled shell commands can replace /usr/bin/head before the host or a sink calls read_bounded(). This line makes that mutable binary the only enforcement of max_bytes; _exec_internal() then drains stdout without a byte limit or timeout, so a replacement can emit arbitrary output and exhaust or indefinitely stall the host. Fixed PATH and quoted arguments prevent injection, but not binary tampering. Enforce the limit in the trusted provider/host transport.

SECURITY.md reference: SECURITY.md:L38-L38


Dismiss this finding: Reply with @codex security dismiss <reason> [context]. Codex will resolve this conversation automatically; GitHub may require a page refresh to show the result.

Valid reasons: false-positive, duplicate, out-of-scope, compensating-control, risk-accepted, or other. Example: @codex security dismiss duplicate Already flagged by another review

What each reason means
  • false-positive — Not a vulnerability
  • duplicate — Already tracked elsewhere
  • out-of-scope — Outside this review's scope
  • compensating-control — Mitigated by another control
  • risk-accepted — Risk intentionally accepted
  • other — Another reason; context required

Useful? React with 👍 / 👎.

"sh",
sandbox_path_str(workspace_path),
str(max_bytes),
shell=False,
)
if result.exit_code == 44:
raise WorkspaceReadNotFoundError(path=path)
if not result.ok():
raise WorkspaceArchiveReadError(path=path)
return result.stdout
except (FileNotFoundError, SandboxError):
raise
except Exception as error:
Expand Down
167 changes: 90 additions & 77 deletions tests/sandbox/test_bounded_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import base64
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -222,68 +223,108 @@ async def test_docker_bounded_read_uses_trusted_utilities_without_encoding() ->


@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform == "win32", reason="Exercises the Linux sandbox command locally.")
@pytest.mark.parametrize(
"close_error", [None, RuntimeError("Close failed"), asyncio.TimeoutError()]
"kind,limit",
[("file", 1), ("file", 4), ("file", 20), ("missing", 5), ("directory", 5), ("fifo", 5)],
)
async def test_modal_bounded_read_closes_provider_descriptor(close_error: Exception | None) -> None:
async def test_modal_bounded_read_limits_source_bytes(
tmp_path: Path, kind: str, limit: int
) -> None:
pytest.importorskip("modal")
from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession

path = tmp_path / "quoted ' file; $(false).bin"
if kind == "file":
path.write_bytes(b"abc\x00def\xff")
elif kind == "directory":
path.mkdir()
elif kind == "fifo":
os.mkfifo(path)
session = object.__new__(ModalSandboxSession)
session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl"))
session._ensure_sandbox = AsyncMock()
stream = SimpleNamespace(
read=SimpleNamespace(aio=AsyncMock(return_value=b"abc")),
close=SimpleNamespace(aio=AsyncMock(side_effect=close_error)),
)
session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream)))
if close_error is None:
assert await session.read_bounded(Path("out.jsonl"), max_bytes=3) == b"abc"
session._validate_path_access = AsyncMock(return_value=path)

async def execute(*command: str, shell: bool, timeout: float | None = None) -> ExecResult:
assert shell is False
assert timeout is None
process = await asyncio.create_subprocess_exec(
*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5)
finally:
if process.returncode is None:
process.kill()
await process.wait()
return ExecResult(stdout=stdout, stderr=stderr, exit_code=process.returncode or 0)

session.exec = AsyncMock(side_effect=execute)
if kind == "file":
assert await session.read_bounded(path, max_bytes=limit) == b"abc\x00def\xff"[:limit]
else:
with pytest.raises(WorkspaceArchiveReadError) as caught:
await session.read_bounded(Path("out.jsonl"), max_bytes=3)
assert caught.value.context["reason"] == "bounded_read_failed"
expected = WorkspaceReadNotFoundError if kind == "missing" else WorkspaceArchiveReadError
with pytest.raises(expected) as caught:
await session.read_bounded(path, max_bytes=limit)
assert caught.value.__context__ is None
stream.read.aio.assert_awaited_once_with(3)
stream.close.aio.assert_awaited_once()


@pytest.mark.asyncio
@pytest.mark.parametrize(
"limit,expected_requests",
[
(4 * 1024 * 1024, [4 * 1024 * 1024]),
(8 * 1024 * 1024 + 1, [8 * 1024 * 1024 + 1, 4 * 1024 * 1024 + 1]),
(101 * 1024 * 1024, [100 * 1024 * 1024, 97 * 1024 * 1024]),
],
)
async def test_modal_bounded_read_uses_large_bounded_requests(
limit: int, expected_requests: list[int]
async def test_modal_bounded_read_does_not_set_sandbox_exec_deadline(
monkeypatch: pytest.MonkeyPatch,
) -> None:
modal = pytest.importorskip("modal")
from modal.container_process import ContainerProcess

from agents.extensions.sandbox.modal.sandbox import (
ModalSandboxSession,
ModalSandboxSessionState,
)
from agents.sandbox import Manifest
from agents.sandbox.snapshot import NoopSnapshot

process = MagicMock(spec=ContainerProcess)
process.stdout.read.aio = AsyncMock(return_value=b"data")
process.stderr.read.aio = AsyncMock(return_value=b"")
process.wait.aio = AsyncMock(return_value=0)
sandbox = MagicMock(spec=modal.Sandbox)
sandbox.object_id = "sb-bounded-read"
sandbox.exec.aio = AsyncMock(return_value=process)
sandbox.terminate.aio = AsyncMock()
state = ModalSandboxSessionState(
manifest=Manifest(root="/workspace"),
snapshot=NoopSnapshot(id="bounded-read"),
app_name="bounded-read-tests",
)
session = ModalSandboxSession(state=state, sandbox=sandbox)
session._ensure_sandbox = AsyncMock()
session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl"))
wait_for = AsyncMock(wraps=asyncio.wait_for)
monkeypatch.setattr(asyncio, "wait_for", wait_for)

assert await session.read_bounded(Path("out.jsonl"), max_bytes=4) == b"data"
assert sandbox.exec.aio.call_args.kwargs["timeout"] is None
wait_for.assert_not_awaited()
sandbox.terminate.aio.assert_not_awaited()
assert session._sandbox is sandbox
assert state.sandbox_id == "sb-bounded-read"


@pytest.mark.asyncio
@pytest.mark.parametrize("exit_code", [44, 45, 1])
async def test_modal_bounded_read_reports_remote_failure(exit_code: int) -> None:
pytest.importorskip("modal")
from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession

session = object.__new__(ModalSandboxSession)
session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl"))
session._ensure_sandbox = AsyncMock()
history = b"x" * (4 * 1024 * 1024)
remaining = history

async def read(size: int) -> bytes:
nonlocal remaining
chunk, remaining = remaining[:size], remaining[size:]
return chunk

stream = SimpleNamespace(
read=SimpleNamespace(aio=AsyncMock(side_effect=read)),
close=SimpleNamespace(aio=AsyncMock()),
session.exec = AsyncMock(
return_value=ExecResult(stdout=b"", stderr=b"private", exit_code=exit_code)
)
session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream)))
assert await session.read_bounded(Path("out.jsonl"), max_bytes=limit) == history
requests = [call.args[0] for call in stream.read.aio.await_args_list]
assert requests == expected_requests
assert all(size <= 100 * 1024 * 1024 for size in requests)
stream.close.aio.assert_awaited_once()
expected = WorkspaceReadNotFoundError if exit_code == 44 else WorkspaceArchiveReadError
with pytest.raises(expected) as caught:
await session.read_bounded(Path("out.jsonl"), max_bytes=5)
assert caught.value.__context__ is None
assert "private" not in str(caught.value)


@pytest.mark.asyncio
Expand Down Expand Up @@ -347,7 +388,6 @@ async def test_bounded_provider_read_preserves_retry_policy(
from agents.sandbox.snapshot import NoopSnapshot

content = _Content(b"synthetic-private-response")
close = AsyncMock()
inner: Any
if provider == "modal":
modal = pytest.importorskip("modal")
Expand All @@ -359,13 +399,7 @@ async def test_bounded_provider_read_preserves_retry_policy(
error_cls = (
modal.exception.InternalError if retryable else modal.exception.PermissionDeniedError
)
stream = SimpleNamespace(
read=SimpleNamespace(
aio=AsyncMock(side_effect=error_cls("synthetic-private-response"))
),
close=SimpleNamespace(aio=close),
)
inner._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream)))
inner.exec = AsyncMock(side_effect=error_cls("synthetic-private-response"))
else:
inner = _session(provider, content, status=503 if retryable else 403)
inner.state = SandboxSessionState(
Expand Down Expand Up @@ -409,8 +443,6 @@ async def test_bounded_provider_read_preserves_retry_policy(
if provider in {"cloudflare", "daytona", "blaxel"}:
assert content.closed
assert content.offset == 0
elif provider == "modal":
close.assert_awaited_once()


@pytest.mark.asyncio
Expand Down Expand Up @@ -513,34 +545,15 @@ async def aclose(self) -> None:


@pytest.mark.asyncio
@pytest.mark.parametrize(
"close_error", [None, RuntimeError("Close failed"), asyncio.TimeoutError()]
)
async def test_modal_bounded_read_cancellation_closes_descriptor(
close_error: Exception | None,
) -> None:
async def test_modal_bounded_read_propagates_cancellation() -> None:
pytest.importorskip("modal")
from agents.extensions.sandbox.modal.sandbox import ModalSandboxSession

entered = asyncio.Event()

async def read(size: int) -> bytes:
entered.set()
await asyncio.Event().wait()
return b"unreachable"

session: Any = object.__new__(ModalSandboxSession)
session = object.__new__(ModalSandboxSession)
session._validate_path_access = AsyncMock(return_value=Path("/workspace/out.jsonl"))
session._ensure_sandbox = AsyncMock()
close = AsyncMock(side_effect=close_error)
stream = SimpleNamespace(read=SimpleNamespace(aio=read), close=SimpleNamespace(aio=close))
session._sandbox = SimpleNamespace(open=SimpleNamespace(aio=AsyncMock(return_value=stream)))
task = asyncio.create_task(session.read_bounded(Path("out.jsonl"), max_bytes=5))
await asyncio.wait_for(entered.wait(), timeout=1)
task.cancel()
session.exec = AsyncMock(side_effect=asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await task
close.assert_awaited_once()
await session.read_bounded(Path("out.jsonl"), max_bytes=5)


@pytest.mark.asyncio
Expand Down
16 changes: 8 additions & 8 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading