diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index d0c2ea28b7..5cc77e2aff 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -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 @@ -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__) @@ -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 @@ -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": @@ -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) @@ -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( @@ -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: @@ -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: diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 67ea2416ed..c8e9c654a1 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -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, @@ -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,