Skip to content
Merged
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
56 changes: 54 additions & 2 deletions src/agents/sandbox/sandboxes/unix_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import time
import uuid
from collections import deque
from collections.abc import Mapping, Sequence
from collections.abc import Collection, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass, field
from functools import partial
Expand Down Expand Up @@ -74,6 +74,30 @@
_PTY_READ_CHUNK_BYTES = 16_384
_PTY_CHILD_SIGNAL_DEFAULTS = (signal.SIGINT, signal.SIGQUIT)
_PTY_FD_CLOSE_GRACE_SECONDS = 0.1
_HOST_ENVIRONMENT_ALLOWLIST = frozenset(
{
"PATH",
"LANG",
"LC_ALL",
"LC_COLLATE",
"LC_CTYPE",
"LC_MESSAGES",
"LC_MONETARY",
"LC_NUMERIC",
"LC_TIME",
"TZ",
"TERM",
"TMPDIR",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"UV_PYTHON",
"NO_COLOR",
"FORCE_COLOR",
"CI",
}
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,6 +163,7 @@ class UnixLocalSandboxSession(BaseSandboxSession):
_pty_processes: dict[int, _UnixPtyProcessEntry]
_reserved_pty_process_ids: set[int]
_fd_close_tasks: set[asyncio.Task[None]]
_host_environment_allowlist: frozenset[str] | None

def __init__(self, *, state: UnixLocalSandboxSessionState) -> None:
self.state = state
Expand All @@ -147,6 +172,7 @@ def __init__(self, *, state: UnixLocalSandboxSessionState) -> None:
self._pty_processes = {}
self._reserved_pty_process_ids = set()
self._fd_close_tasks = set()
self._host_environment_allowlist = None

@classmethod
def from_state(cls, state: UnixLocalSandboxSessionState) -> "UnixLocalSandboxSession":
Expand Down Expand Up @@ -440,7 +466,14 @@ async def pty_terminate_all(self) -> None:
await self._terminate_pty_entry(entry)

async def _resolved_exec_context(self) -> tuple[dict[str, str], str]:
env = os.environ.copy()
if self._host_environment_allowlist is None:
env = dict(os.environ)
else:
env = {
name: value
for name, value in os.environ.items()
if name in self._host_environment_allowlist
}
env.update(await self.state.manifest.environment.resolve())

workspace = Path(self.state.manifest.root)
Expand Down Expand Up @@ -1099,11 +1132,26 @@ def __init__(
*,
instrumentation: Instrumentation | None = None,
dependencies: Dependencies | None = None,
inherit_host_environment: bool = True,
host_environment_allowlist: Collection[str] | None = None,
) -> None:
if inherit_host_environment and host_environment_allowlist is not None:
raise ValueError("host_environment_allowlist requires inherit_host_environment=False")
if isinstance(host_environment_allowlist, str):
raise TypeError("host_environment_allowlist must be a collection of variable names")

self._instrumentation = (
instrumentation if instrumentation is not None else Instrumentation()
)
self._dependencies = dependencies
if inherit_host_environment:
self._host_environment_allowlist = None
else:
self._host_environment_allowlist = frozenset(
_HOST_ENVIRONMENT_ALLOWLIST
if host_environment_allowlist is None
else host_environment_allowlist
)

@redact_mount_error_data
async def create(
Expand Down Expand Up @@ -1136,6 +1184,9 @@ async def create(
exposed_ports=resolved_options.exposed_ports,
)
inner = UnixLocalSandboxSession.from_state(state)
# Keep host inheritance policy under trusted runtime control. Session state and manifests
# must not be able to change it when a session is resumed by another client.
inner._host_environment_allowlist = self._host_environment_allowlist
return self._wrap_session(inner, instrumentation=self._instrumentation)

async def delete(self, session: SandboxSession) -> SandboxSession:
Expand Down Expand Up @@ -1177,6 +1228,7 @@ async def resume(
state.assert_path_grants_rebound()
_assert_unix_local_host_path_grants_unsupported(state.manifest)
inner = UnixLocalSandboxSession.from_state(state)
inner._host_environment_allowlist = self._host_environment_allowlist
return self._wrap_session(inner, instrumentation=self._instrumentation)

def deserialize_session_state(self, payload: dict[str, object]) -> SandboxSessionState:
Expand Down
139 changes: 138 additions & 1 deletion tests/sandbox/test_unix_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

from agents.sandbox import SandboxPathGrant
from agents.sandbox.errors import PtySessionNotFoundError
from agents.sandbox.manifest import Manifest
from agents.sandbox.manifest import Environment, Manifest
from agents.sandbox.sandboxes import unix_local as unix_local_module
from agents.sandbox.sandboxes.unix_local import (
UnixLocalSandboxClient,
UnixLocalSandboxSession,
Expand Down Expand Up @@ -41,6 +42,142 @@ async def _exec_internal(
return ExecResult(stdout=b"", stderr=b"", exit_code=0)


@pytest.mark.asyncio
async def test_unix_local_inherits_host_environment_by_default(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(unix_local_module.sys, "platform", "linux")
monkeypatch.setenv("OPENAI_API_KEY", "host-secret")
monkeypatch.setenv("LC_MESSAGES", "C")
monkeypatch.setenv("LC_PRIVATE_TOKEN", "locale-secret")
workspace = tmp_path / "workspace"
manifest = Manifest(
root=str(workspace),
environment=Environment(
value={
"HOME": "/manifest-home",
"LC_CTYPE": "POSIX",
"MANIFEST_ONLY": "configured",
}
),
)

async with await UnixLocalSandboxClient().create(
manifest=manifest, snapshot=None, options=None
) as session:
result = await session.exec(
"sh",
"-c",
"printf '%s|%s|%s|%s|%s|%s|%s' "
'"${OPENAI_API_KEY-unset}" "$MANIFEST_ONLY" "$HOME" '
'"${PATH:+set}" "$LC_MESSAGES" "$LC_CTYPE" '
'"${LC_PRIVATE_TOKEN-unset}"',
shell=False,
)

assert result.exit_code == 0
assert result.stdout.decode() == (
f"host-secret|configured|{workspace}|set|C|POSIX|locale-secret"
)


@pytest.mark.asyncio
async def test_unix_local_uses_default_allowlist_when_inheritance_is_disabled(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(unix_local_module.sys, "platform", "linux")
monkeypatch.setenv("HOST_ONLY_VALUE", "host-value")
monkeypatch.setenv("LC_MESSAGES", "C")
monkeypatch.setenv("LC_PRIVATE_TOKEN", "locale-secret")
manifest = Manifest(root=str(tmp_path / "workspace"))
isolated_client = UnixLocalSandboxClient(inherit_host_environment=False)

async with await isolated_client.create(
manifest=manifest, snapshot=None, options=None
) as session:
created = await session.exec(
"sh",
"-c",
"printf '%s|%s|%s' "
'"${HOST_ONLY_VALUE-unset}" "$LC_MESSAGES" '
'"${LC_PRIVATE_TOKEN-unset}"',
shell=False,
)
state = session.state

payload = isolated_client.serialize_session_state(state)
assert "inherit_host_environment" not in payload
assert "host_environment_allowlist" not in payload
assert created.stdout == b"unset|C|unset"

async with await isolated_client.resume(state) as resumed:
isolated_after_resume = await resumed.exec(
"sh", "-c", 'printf "%s" "${HOST_ONLY_VALUE-unset}"', shell=False
)
assert isolated_after_resume.stdout == b"unset"

async with await UnixLocalSandboxClient().resume(state) as resumed_with_default:
inherited_after_resume = await resumed_with_default.exec(
"sh", "-c", 'printf "%s" "${HOST_ONLY_VALUE-unset}"', shell=False
)
assert inherited_after_resume.stdout == b"host-value"


@pytest.mark.asyncio
async def test_unix_local_uses_custom_host_environment_allowlist(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(unix_local_module.sys, "platform", "linux")
monkeypatch.setenv("CUSTOM_ALLOWED", "allowed-value")
monkeypatch.setenv("HOST_ONLY_VALUE", "host-value")
manifest = Manifest(root=str(tmp_path / "workspace"))
client = UnixLocalSandboxClient(
inherit_host_environment=False,
host_environment_allowlist={"PATH", "CUSTOM_ALLOWED"},
)

async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
result = await session.exec(
"sh",
"-c",
'printf \'%s|%s\' "$CUSTOM_ALLOWED" "${HOST_ONLY_VALUE-unset}"',
shell=False,
)
state = session.state

assert result.stdout == b"allowed-value|unset"

async with await client.resume(state) as resumed:
resumed_result = await resumed.exec(
"sh",
"-c",
'printf \'%s|%s\' "$CUSTOM_ALLOWED" "${HOST_ONLY_VALUE-unset}"',
shell=False,
)

assert resumed_result.stdout == b"allowed-value|unset"


def test_unix_local_rejects_invalid_host_environment_allowlist_configuration() -> None:
with pytest.raises(
ValueError,
match="host_environment_allowlist requires inherit_host_environment=False",
):
UnixLocalSandboxClient(host_environment_allowlist={"PATH"})

with pytest.raises(
TypeError,
match="host_environment_allowlist must be a collection of variable names",
):
UnixLocalSandboxClient(
inherit_host_environment=False,
host_environment_allowlist="PATH",
)


@pytest.mark.asyncio
async def test_unix_local_rejects_host_path_before_creating_workspace(
tmp_path: Path,
Expand Down