From 14b4307609b6163b19f22062234d0d9e72f9a4e4 Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:19:17 +0800 Subject: [PATCH 1/5] fix(sandbox): allowlist exec environment; require explicit opt-in for unconfined Linux Two gaps between the documented environment isolation and the Unix-local implementation: - Commands inherited the full host environment (os.environ.copy()), so a sandboxed command could read host credentials (OPENAI_API_KEY, AWS_*, GITHUB_TOKEN) with printenv. The exec context now inherits only an allowlist (PATH, locale, TZ, TERM, TMPDIR, CA locations) plus manifest environment entries; inherit_environment=True restores the old behavior for workflows that depend on it. - On Linux, _confined_exec_command returned commands unmodified (the sandbox-exec wrapper is macOS-only), so 'sandboxed' commands ran with no OS-level confinement while the docs recommended UnixLocalSandboxClient as the default local client on macOS or Linux. Creating a session on Linux now raises with guidance toward DockerSandboxClient unless allow_unconfined_linux=True is passed explicitly (with a runtime warning). macOS behavior is unchanged. Docs updated accordingly (clients.md environment-isolation notes, guide.md client selection wording). --- docs/sandbox/clients.md | 8 ++- docs/sandbox/guide.md | 2 +- src/agents/sandbox/errors.py | 1 + src/agents/sandbox/sandboxes/unix_local.py | 65 ++++++++++++++++++- tests/sandbox/test_unix_local.py | 72 ++++++++++++++++++++++ 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index 6f52a8d7b7..c3be8cdf0c 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -26,13 +26,19 @@ For most users, start with one of these two sandbox clients: | Client | Install | Choose it when | Example | | --- | --- | --- | --- | -| `UnixLocalSandboxClient` | none | Fastest local iteration on macOS or Linux. Good default for local development. | [Unix-local starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | +| `UnixLocalSandboxClient` | none | Fastest local iteration on macOS (Seatbelt confinement). On Linux it requires `allow_unconfined_linux=True` because there is no OS-level confinement — prefer `DockerSandboxClient` there. | [Unix-local starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/unix_local_runner.py) | | `DockerSandboxClient` | `openai-agents[docker]` | You want container isolation or a specific image to reproduce a target environment locally. | [Docker starter](https://github.com/openai/openai-agents-python/blob/main/examples/sandbox/docker/docker_runner.py) | Unix-local is the easiest way to start developing against a local filesystem. Move to Docker or a hosted provider when you need stronger environment isolation or production-style parity. +Environment isolation notes: + +- On macOS, commands are wrapped with a `sandbox-exec` profile that confines filesystem access to the workspace. +- On Linux there is no equivalent confinement: creating a session raises by default. Pass `allow_unconfined_linux=True` to explicitly accept unconfined host execution (intended for disposable development machines), or use `DockerSandboxClient`. +- Sandboxed commands inherit only a minimal allowlist of host environment variables (`PATH`, locale, `TZ`, `TERM`, `TMPDIR`, certificate/CA locations) plus anything declared in the manifest environment. This prevents a sandboxed command from reading host credentials such as `OPENAI_API_KEY` via `printenv`. Pass `inherit_environment=True` to restore full inheritance when a workflow depends on it. + `SandboxPathGrant.host_path` is Docker-only and maps a host path to a different POSIX path inside the container. Unix-local supports only same-path grants. See [Manifest path grants](guide.md#manifest) for details. To switch from Unix-local to Docker, keep the agent definition the same and change only the run config: diff --git a/docs/sandbox/guide.md b/docs/sandbox/guide.md index ba236cd62c..766f9d64f5 100644 --- a/docs/sandbox/guide.md +++ b/docs/sandbox/guide.md @@ -70,7 +70,7 @@ If you do not need access to files or a stateful, mutable filesystem, keep using ## Choose a sandbox client -Start with `UnixLocalSandboxClient` for local development on macOS or Linux. On Windows, use `DockerSandboxClient` or a hosted provider instead. On any supported platform, move to `DockerSandboxClient` when you need container isolation or image parity, or to a hosted provider when you need provider-managed execution. +Start with `UnixLocalSandboxClient` for local development on macOS (it confines commands with `sandbox-exec`). On Linux and Windows, use `DockerSandboxClient` or a hosted provider instead — the Unix-local client has no OS-level confinement on Linux and refuses to start there unless `allow_unconfined_linux=True` is set explicitly. On any supported platform, move to `DockerSandboxClient` when you need container isolation or image parity, or to a hosted provider when you need provider-managed execution. In most cases, the `SandboxAgent` definition stays the same while the sandbox client and its options change in [`SandboxRunConfig`][agents.run_config.SandboxRunConfig]. See [Sandbox clients](clients.md) for local, Docker, hosted, and remote-mount options. diff --git a/src/agents/sandbox/errors.py b/src/agents/sandbox/errors.py index 252b2a6f28..9d92e63ae4 100644 --- a/src/agents/sandbox/errors.py +++ b/src/agents/sandbox/errors.py @@ -26,6 +26,7 @@ def __str__(self) -> str: APPLY_PATCH_INVALID_DIFF = "apply_patch_invalid_diff" APPLY_PATCH_FILE_NOT_FOUND = "apply_patch_file_not_found" APPLY_PATCH_DECODE_ERROR = "apply_patch_decode_error" + UNCONFINED_LINUX_NOT_ALLOWED = "unconfined_linux_not_allowed" WORKSPACE_READ_NOT_FOUND = "workspace_read_not_found" WORKSPACE_ARCHIVE_READ_ERROR = "workspace_archive_read_error" diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index d0c2ea28b7..743dbceda8 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -31,6 +31,8 @@ from ...logger import log_tool_action_warning from .._mount_security import redact_mount_error_data from ..errors import ( + ConfigurationError, + ErrorCode, ExecNonZeroError, ExecTimeoutError, ExecTransportError, @@ -92,24 +94,53 @@ def _restore_pty_child_signal_defaults() -> None: signal.signal(signum, signal.SIG_DFL) +# Host environment variables safe to expose to sandboxed commands by default: +# toolchain discovery and locale/encoding only — never credentials. +_ENV_INHERIT_ALLOWLIST = frozenset( + { + "PATH", + "LANG", + "TZ", + "TERM", + "TMPDIR", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "PIP_INDEX_URL", + "UV_PYTHON", + "NO_COLOR", + "FORCE_COLOR", + "CI", + } +) + + class UnixLocalSandboxSessionState(SandboxSessionState): type: Literal["unix_local"] = "unix_local" workspace_root_owned: bool = False + inherit_environment: bool = False class UnixLocalSandboxClientOptions(BaseSandboxClientOptions): type: Literal["unix_local"] = "unix_local" exposed_ports: tuple[int, ...] = () + inherit_environment: bool = False + allow_unconfined_linux: bool = False def __init__( self, exposed_ports: tuple[int, ...] = (), *, type: Literal["unix_local"] = "unix_local", + inherit_environment: bool = False, + allow_unconfined_linux: bool = False, ) -> None: super().__init__( type=type, exposed_ports=exposed_ports, + inherit_environment=inherit_environment, + allow_unconfined_linux=allow_unconfined_linux, ) @@ -440,7 +471,19 @@ 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() + # Sandboxed commands must not see host secrets: by default only a + # minimal allowlist of the host environment is inherited (a + # `printenv` inside the sandbox previously exposed OPENAI_API_KEY, + # AWS_*, GITHUB_TOKEN, ...). Callers that rely on the old behavior + # can pass inherit_environment=True. + if self.state.inherit_environment: + env = dict(os.environ) + else: + env = { + name: value + for name, value in os.environ.items() + if name in _ENV_INHERIT_ALLOWLIST or name.startswith("LC_") + } env.update(await self.state.manifest.environment.resolve()) workspace = Path(self.state.manifest.root) @@ -1115,6 +1158,25 @@ async def create( ) -> SandboxSession: resolved_options = options if options is not None else UnixLocalSandboxClientOptions() manifest = manifest if manifest is not None else Manifest() + if sys.platform != "darwin" and not resolved_options.allow_unconfined_linux: + raise ConfigurationError( + message=( + "UnixLocalSandboxClient provides OS-level confinement (sandbox-exec) on " + "macOS only; on Linux it executes commands directly on the host with no " + "namespace, seccomp or container isolation. Use DockerSandboxClient on " + "Linux, or pass allow_unconfined_linux=True to explicitly accept " + "unconfined host execution." + ), + error_code=ErrorCode.UNCONFINED_LINUX_NOT_ALLOWED, + op="create", + context={"platform": sys.platform, "backend_id": self.backend_id}, + retryable=False, + ) + if sys.platform != "darwin" and resolved_options.allow_unconfined_linux: + logger.warning( + "UnixLocalSandboxClient running with allow_unconfined_linux=True: sandboxed " + "commands execute on the host without OS-level confinement." + ) _assert_unix_local_host_path_grants_unsupported(manifest) self._validate_manifest_for_create(manifest) # For local execution, runner-created sessions should always get an isolated temp root @@ -1134,6 +1196,7 @@ async def create( snapshot=snapshot_instance, workspace_root_owned=workspace_root_owned, exposed_ports=resolved_options.exposed_ports, + inherit_environment=resolved_options.inherit_environment, ) inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 67ea2416ed..7de490b710 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -327,3 +327,75 @@ async def test_rm_as_user_checks_permissions_then_uses_local_fs( assert session.exec_commands[0][4:6] == ("sh", "-lc") assert session.exec_commands[0][-2:] == (str(target), "0") assert not any(part.startswith("rm ") for part in session.exec_commands[0]) + + +class _EnvProbeSession(UnixLocalSandboxSession): + """Captures the resolved exec environment instead of spawning processes.""" + + def __init__(self, root: Path, inherit_environment: bool = False) -> None: + super().__init__( + state=UnixLocalSandboxSessionState( + manifest=Manifest(root=str(root)), + snapshot=NoopSnapshot(id="noop"), + inherit_environment=inherit_environment, + ) + ) + self.captured_env: dict[str, str] = {} + + async def _exec_internal( + self, + *command: str | Path, + timeout: float | None = None, + ) -> ExecResult: + env, _ = await self._resolved_exec_context() + self.captured_env = env + return ExecResult(stdout=b"", stderr=b"", exit_code=0) + + +@pytest.mark.asyncio +async def test_exec_environment_is_allowlisted_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-secret") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret") + monkeypatch.setenv("PATH", "/usr/bin:/bin") + + session = _EnvProbeSession(tmp_path) + await session._exec_internal("true") + + assert "OPENAI_API_KEY" not in session.captured_env + assert "AWS_SECRET_ACCESS_KEY" not in session.captured_env + assert session.captured_env["PATH"] == "/usr/bin:/bin" + assert session.captured_env["HOME"] == str(tmp_path) + + +@pytest.mark.asyncio +async def test_exec_environment_inherit_restores_full_host_env( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-secret") + + session = _EnvProbeSession(tmp_path, inherit_environment=True) + await session._exec_internal("true") + + assert session.captured_env.get("OPENAI_API_KEY") == "sk-test-secret" + + +@pytest.mark.asyncio +async def test_linux_requires_explicit_unconfined_opt_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sys as _sys + + from agents.sandbox.errors import ConfigurationError + from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions + + monkeypatch.setattr(_sys, "platform", "linux") + client = UnixLocalSandboxClient() + + with pytest.raises(ConfigurationError, match="allow_unconfined_linux"): + await client.create(manifest=Manifest(root=str(tmp_path / "ws"))) + + session = await client.create( + manifest=Manifest(root=str(tmp_path / "ws2")), + options=UnixLocalSandboxClientOptions(allow_unconfined_linux=True), + ) + await session.stop() From 1841aefb65bd21592b6d5a777a638a62b74d18c0 Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:35:42 +0800 Subject: [PATCH 2/5] fix(sandbox): trust resume path for env/ Linux opt-in flags; drop PIP_INDEX_URL Review follow-ups: - The inherit_environment flag lived on the serializable session state, so a RunState payload deserialized with inherit_environment=true would silently re-enable full host-environment inheritance on resume. Both flags are now set from trusted client-constructor values on every create()/resume(); the serialized value is overwritten, never trusted. - The Linux unconfined check only ran in create(); resume() now enforces the same opt-in (state.error op=create/resume, same guidance). - PIP_INDEX_URL removed from the default env allowlist: private package indexes commonly embed credentials in the URL (user:pass@host). --- src/agents/sandbox/sandboxes/unix_local.py | 38 +++++++++++++++++++--- tests/sandbox/test_unix_local.py | 32 ++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 743dbceda8..f7c03c9cb5 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -107,7 +107,6 @@ def _restore_pty_child_signal_defaults() -> None: "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS", - "PIP_INDEX_URL", "UV_PYTHON", "NO_COLOR", "FORCE_COLOR", @@ -1142,11 +1141,19 @@ def __init__( *, instrumentation: Instrumentation | None = None, dependencies: Dependencies | None = None, + inherit_environment: bool = False, + allow_unconfined_linux: bool = False, ) -> None: self._instrumentation = ( instrumentation if instrumentation is not None else Instrumentation() ) self._dependencies = dependencies + # Trusted, programmatic configuration: unlike the session-state flag + # (which is part of serialized RunState payloads and therefore + # untrusted on resume), these constructor values are the authority + # for both create() and resume(). + self._inherit_environment = inherit_environment + self._allow_unconfined_linux = allow_unconfined_linux @redact_mount_error_data async def create( @@ -1158,7 +1165,8 @@ async def create( ) -> SandboxSession: resolved_options = options if options is not None else UnixLocalSandboxClientOptions() manifest = manifest if manifest is not None else Manifest() - if sys.platform != "darwin" and not resolved_options.allow_unconfined_linux: + allow_unconfined_linux = self._allow_unconfined_linux or resolved_options.allow_unconfined_linux + if sys.platform != "darwin" and not allow_unconfined_linux: raise ConfigurationError( message=( "UnixLocalSandboxClient provides OS-level confinement (sandbox-exec) on " @@ -1172,7 +1180,7 @@ async def create( context={"platform": sys.platform, "backend_id": self.backend_id}, retryable=False, ) - if sys.platform != "darwin" and resolved_options.allow_unconfined_linux: + if sys.platform != "darwin" and allow_unconfined_linux: logger.warning( "UnixLocalSandboxClient running with allow_unconfined_linux=True: sandboxed " "commands execute on the host without OS-level confinement." @@ -1196,7 +1204,7 @@ async def create( snapshot=snapshot_instance, workspace_root_owned=workspace_root_owned, exposed_ports=resolved_options.exposed_ports, - inherit_environment=resolved_options.inherit_environment, + inherit_environment=self._inherit_environment or resolved_options.inherit_environment, ) inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) @@ -1237,6 +1245,28 @@ async def resume( ) -> SandboxSession: if not isinstance(state, UnixLocalSandboxSessionState): raise TypeError("UnixLocalSandboxClient.resume expects a UnixLocalSandboxSessionState") + if sys.platform != "darwin" and not self._allow_unconfined_linux: + raise ConfigurationError( + message=( + "UnixLocalSandboxClient provides OS-level confinement (sandbox-exec) on " + "macOS only; on Linux it executes commands directly on the host with no " + "namespace, seccomp or container isolation. Use DockerSandboxClient on " + "Linux, or construct the client with allow_unconfined_linux=True to " + "explicitly accept unconfined host execution." + ), + error_code=ErrorCode.UNCONFINED_LINUX_NOT_ALLOWED, + op="resume", + context={"platform": sys.platform, "backend_id": self.backend_id}, + retryable=False, + ) + if sys.platform != "darwin" and self._allow_unconfined_linux: + logger.warning( + "UnixLocalSandboxClient resuming with allow_unconfined_linux=True: sandboxed " + "commands execute on the host without OS-level confinement." + ) + # The serialized state is untrusted: whatever it claims about + # environment inheritance, the client-level constructor value wins. + state.inherit_environment = self._inherit_environment state.assert_path_grants_rebound() _assert_unix_local_host_path_grants_unsupported(state.manifest) inner = UnixLocalSandboxSession.from_state(state) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 7de490b710..6f2f644773 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -399,3 +399,35 @@ async def test_linux_requires_explicit_unconfined_opt_in( options=UnixLocalSandboxClientOptions(allow_unconfined_linux=True), ) await session.stop() + + +@pytest.mark.asyncio +async def test_resume_ignores_untrusted_state_flags_and_enforces_linux_opt_in( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sys as _sys + + from agents.sandbox.errors import ConfigurationError + from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxSessionState, + ) + from agents.sandbox.snapshot import NoopSnapshot as _NoopSnapshot + + monkeypatch.setattr(_sys, "platform", "linux") + + state = UnixLocalSandboxSessionState( + manifest=Manifest(root=str(tmp_path / "ws")), + snapshot=_NoopSnapshot(id="noop"), + # untrusted serialized payload claims both opt-ins + inherit_environment=True, + ) + + with pytest.raises(ConfigurationError, match="allow_unconfined_linux"): + await UnixLocalSandboxClient().resume(state) + + client = UnixLocalSandboxClient(allow_unconfined_linux=True, inherit_environment=False) + session = await client.resume(state) + # serialized claim must not win over the trusted constructor value + assert session.state.inherit_environment is False + await session.stop() From cf95e16db7dae079653634b8d95d69e229c0120e Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:48:02 +0800 Subject: [PATCH 3/5] fix(sandbox): make both unix-local opt-ins constructor-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: create() accepted allow_unconfined_linux via per-call options while resume() only honored the constructor flag, so a workflow opting in through options could create and serialize a session but fail to resume it under the same run config. Both flags (allow_unconfined_linux, inherit_environment) are now constructor arguments on UnixLocalSandboxClient only — the single trusted channel that applies to create() and resume() alike; the options class documents why they are deliberately not per-call options. Regression test resumes with the same opted-in client. --- src/agents/sandbox/sandboxes/unix_local.py | 25 ++++++++++++---------- tests/sandbox/test_unix_local.py | 11 +++++----- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index f7c03c9cb5..8e9fca76db 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -122,24 +122,27 @@ class UnixLocalSandboxSessionState(SandboxSessionState): class UnixLocalSandboxClientOptions(BaseSandboxClientOptions): + """Per-call options for Unix-local sessions. + + ``inherit_environment`` and ``allow_unconfined_linux`` are deliberately + NOT options: per-call options do not flow through the ``resume()`` path, + so accepting them here would let a session be created but fail to resume + under the same run config. Both are constructor arguments on + ``UnixLocalSandboxClient`` — the single trusted channel that applies to + ``create()`` and ``resume()`` alike. + """ + type: Literal["unix_local"] = "unix_local" exposed_ports: tuple[int, ...] = () - inherit_environment: bool = False - allow_unconfined_linux: bool = False - def __init__( self, exposed_ports: tuple[int, ...] = (), *, type: Literal["unix_local"] = "unix_local", - inherit_environment: bool = False, - allow_unconfined_linux: bool = False, ) -> None: super().__init__( type=type, exposed_ports=exposed_ports, - inherit_environment=inherit_environment, - allow_unconfined_linux=allow_unconfined_linux, ) @@ -1165,15 +1168,15 @@ async def create( ) -> SandboxSession: resolved_options = options if options is not None else UnixLocalSandboxClientOptions() manifest = manifest if manifest is not None else Manifest() - allow_unconfined_linux = self._allow_unconfined_linux or resolved_options.allow_unconfined_linux + allow_unconfined_linux = self._allow_unconfined_linux if sys.platform != "darwin" and not allow_unconfined_linux: raise ConfigurationError( message=( "UnixLocalSandboxClient provides OS-level confinement (sandbox-exec) on " "macOS only; on Linux it executes commands directly on the host with no " "namespace, seccomp or container isolation. Use DockerSandboxClient on " - "Linux, or pass allow_unconfined_linux=True to explicitly accept " - "unconfined host execution." + "Linux, or construct the client with allow_unconfined_linux=True " + "to explicitly accept unconfined host execution." ), error_code=ErrorCode.UNCONFINED_LINUX_NOT_ALLOWED, op="create", @@ -1204,7 +1207,7 @@ async def create( snapshot=snapshot_instance, workspace_root_owned=workspace_root_owned, exposed_ports=resolved_options.exposed_ports, - inherit_environment=self._inherit_environment or resolved_options.inherit_environment, + inherit_environment=self._inherit_environment, ) inner = UnixLocalSandboxSession.from_state(state) return self._wrap_session(inner, instrumentation=self._instrumentation) diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 6f2f644773..a3829e1d8e 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -386,7 +386,7 @@ async def test_linux_requires_explicit_unconfined_opt_in( import sys as _sys from agents.sandbox.errors import ConfigurationError - from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions + from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient monkeypatch.setattr(_sys, "platform", "linux") client = UnixLocalSandboxClient() @@ -394,11 +394,12 @@ async def test_linux_requires_explicit_unconfined_opt_in( with pytest.raises(ConfigurationError, match="allow_unconfined_linux"): await client.create(manifest=Manifest(root=str(tmp_path / "ws"))) - session = await client.create( - manifest=Manifest(root=str(tmp_path / "ws2")), - options=UnixLocalSandboxClientOptions(allow_unconfined_linux=True), - ) + opted_in = UnixLocalSandboxClient(allow_unconfined_linux=True) + session = await opted_in.create(manifest=Manifest(root=str(tmp_path / "ws2"))) await session.stop() + # the opt-in must also unlock resume with the SAME client configuration + resumed = await opted_in.resume(session.state) + await resumed.stop() @pytest.mark.asyncio From 3fa17d8c7b2808eaa5f791329debe329166a991b Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:56:38 +0800 Subject: [PATCH 4/5] fix(sandbox): keep env opt-in out of the persisted session state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: inherit_environment lived on the serializable UnixLocalSandboxSessionState, so RunState snapshots carried it under the unbumped 1.17 schema — an older SDK reading the snapshot would ignore the unknown field and resume with the old full-host-environment behavior. The flag is now a runtime-only attribute on the session object, wired from the trusted client constructor in create() and resume(); it never enters the persisted state. Regression test asserts the serialized payload contains no inherit_environment key. --- src/agents/sandbox/sandboxes/unix_local.py | 16 ++++++++------ tests/sandbox/test_unix_local.py | 25 +++++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index 8e9fca76db..27c69e2845 100644 --- a/src/agents/sandbox/sandboxes/unix_local.py +++ b/src/agents/sandbox/sandboxes/unix_local.py @@ -118,7 +118,6 @@ def _restore_pty_child_signal_defaults() -> None: class UnixLocalSandboxSessionState(SandboxSessionState): type: Literal["unix_local"] = "unix_local" workspace_root_owned: bool = False - inherit_environment: bool = False class UnixLocalSandboxClientOptions(BaseSandboxClientOptions): @@ -175,6 +174,11 @@ class UnixLocalSandboxSession(BaseSandboxSession): def __init__(self, *, state: UnixLocalSandboxSessionState) -> None: self.state = state + # Runtime-only opt-in set by the client from its trusted constructor + # value; deliberately NOT on the state so it never persists into + # RunState snapshots (schema readers would ignore it and silently + # resume with full host-environment inheritance). + self._inherit_environment = False self._running = False self._pty_lock = asyncio.Lock() self._pty_processes = {} @@ -478,7 +482,7 @@ async def _resolved_exec_context(self) -> tuple[dict[str, str], str]: # `printenv` inside the sandbox previously exposed OPENAI_API_KEY, # AWS_*, GITHUB_TOKEN, ...). Callers that rely on the old behavior # can pass inherit_environment=True. - if self.state.inherit_environment: + if self._inherit_environment: env = dict(os.environ) else: env = { @@ -1207,9 +1211,9 @@ async def create( snapshot=snapshot_instance, workspace_root_owned=workspace_root_owned, exposed_ports=resolved_options.exposed_ports, - inherit_environment=self._inherit_environment, ) inner = UnixLocalSandboxSession.from_state(state) + inner._inherit_environment = self._inherit_environment return self._wrap_session(inner, instrumentation=self._instrumentation) async def delete(self, session: SandboxSession) -> SandboxSession: @@ -1267,12 +1271,12 @@ async def resume( "UnixLocalSandboxClient resuming with allow_unconfined_linux=True: sandboxed " "commands execute on the host without OS-level confinement." ) - # The serialized state is untrusted: whatever it claims about - # environment inheritance, the client-level constructor value wins. - state.inherit_environment = self._inherit_environment state.assert_path_grants_rebound() _assert_unix_local_host_path_grants_unsupported(state.manifest) inner = UnixLocalSandboxSession.from_state(state) + # Environment inheritance is a client-level, runtime-only opt-in: + # nothing in the (untrusted) serialized state can re-enable it. + inner._inherit_environment = self._inherit_environment 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 a3829e1d8e..299372a20f 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -337,9 +337,10 @@ def __init__(self, root: Path, inherit_environment: bool = False) -> None: state=UnixLocalSandboxSessionState( manifest=Manifest(root=str(root)), snapshot=NoopSnapshot(id="noop"), - inherit_environment=inherit_environment, ) ) + # runtime-only opt-in wired by the client (never persisted on state) + self._inherit_environment = inherit_environment self.captured_env: dict[str, str] = {} async def _exec_internal( @@ -420,8 +421,6 @@ async def test_resume_ignores_untrusted_state_flags_and_enforces_linux_opt_in( state = UnixLocalSandboxSessionState( manifest=Manifest(root=str(tmp_path / "ws")), snapshot=_NoopSnapshot(id="noop"), - # untrusted serialized payload claims both opt-ins - inherit_environment=True, ) with pytest.raises(ConfigurationError, match="allow_unconfined_linux"): @@ -429,6 +428,22 @@ async def test_resume_ignores_untrusted_state_flags_and_enforces_linux_opt_in( client = UnixLocalSandboxClient(allow_unconfined_linux=True, inherit_environment=False) session = await client.resume(state) - # serialized claim must not win over the trusted constructor value - assert session.state.inherit_environment is False + await session.stop() + # the opt-in is runtime-only on the session object: it never serializes + # into the persisted session-state payload (older SDKs reading a + # snapshot cannot be downgraded back to full host-env inheritance) + + +@pytest.mark.asyncio +async def test_inherit_environment_never_persists_in_session_state(tmp_path: Path) -> None: + from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxSessionState, + ) + from agents.sandbox.snapshot import NoopSnapshot as _NoopSnapshot + + client = UnixLocalSandboxClient(allow_unconfined_linux=True, inherit_environment=True) + session = await client.create(manifest=Manifest(root=str(tmp_path / "ws"))) + payload = client.serialize_session_state(session.state) + assert "inherit_environment" not in payload await session.stop() From 4667a571b146b49416cee83598e849a572a82799 Mon Sep 17 00:00:00 2001 From: simpleqt <89645338+simpleqt@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:16:18 +0800 Subject: [PATCH 5/5] fix(sandbox): keep Linux guard errors actionable through mount redaction Add "create"/"resume" to OpName so the unconfined-Linux ConfigurationError survives the mount-redaction boundary as a structured error with its error_code and retryable classification intact; previously it collapsed into the generic redacted RuntimeError for credential-bearing manifests. Route every Unix-local example through a shared unix_local_client() helper that exits with actionable guidance on Linux unless AGENTS_ALLOW_UNCONFINED_LINUX=1 is set (Docker examples recommended there), and note the opt-in in the README/docs snippets. --- README.md | 2 + docs/sandbox/clients.md | 2 +- docs/sandbox_agents.md | 2 + examples/sandbox/docs/coding_task.py | 4 +- .../temporal/local_hello_workflow.py | 5 +- .../temporal/temporal_sandbox_agent.py | 5 +- examples/sandbox/handoffs.py | 5 +- .../sandbox/healthcare_support/workflow.py | 4 +- examples/sandbox/memory.py | 4 +- .../sandbox/memory_multi_agent_multiturn.py | 4 +- examples/sandbox/misc/example_support.py | 27 ++++++++++- .../sandbox/sandbox_agent_capabilities.py | 5 +- .../sandbox_agent_with_remote_snapshot.py | 5 +- examples/sandbox/sandbox_agent_with_tools.py | 9 ++-- examples/sandbox/sandbox_agents_as_tools.py | 11 +++-- examples/sandbox/shared_session_workdirs.py | 9 +++- examples/sandbox/tutorials/misc.py | 4 +- examples/sandbox/unix_local_pty.py | 5 +- examples/sandbox/unix_local_runner.py | 12 +++-- src/agents/sandbox/errors.py | 2 + tests/sandbox/test_unix_local.py | 47 ++++++++++++++++--- 21 files changed, 125 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 53b111f2de..7ec795e3a8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ agent = SandboxAgent( result = Runner.run_sync( agent, "Inspect the repo README and summarize what this project does.", + # macOS: sandbox-exec confined. On Linux this raises unless the client is + # constructed with allow_unconfined_linux=True; prefer Docker there. run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())), ) print(result.final_output) diff --git a/docs/sandbox/clients.md b/docs/sandbox/clients.md index c3be8cdf0c..56b9c6ced9 100644 --- a/docs/sandbox/clients.md +++ b/docs/sandbox/clients.md @@ -36,7 +36,7 @@ Unix-local is the easiest way to start developing against a local filesystem. Mo Environment isolation notes: - On macOS, commands are wrapped with a `sandbox-exec` profile that confines filesystem access to the workspace. -- On Linux there is no equivalent confinement: creating a session raises by default. Pass `allow_unconfined_linux=True` to explicitly accept unconfined host execution (intended for disposable development machines), or use `DockerSandboxClient`. +- On Linux there is no equivalent confinement: creating a session raises by default. Pass `allow_unconfined_linux=True` to explicitly accept unconfined host execution (intended for disposable development machines), or use `DockerSandboxClient`. The sandbox examples gate this opt-in behind `AGENTS_ALLOW_UNCONFINED_LINUX=1` in a shared helper (`examples/sandbox/misc/example_support.py`). - Sandboxed commands inherit only a minimal allowlist of host environment variables (`PATH`, locale, `TZ`, `TERM`, `TMPDIR`, certificate/CA locations) plus anything declared in the manifest environment. This prevents a sandboxed command from reading host credentials such as `OPENAI_API_KEY` via `printenv`. Pass `inherit_environment=True` to restore full inheritance when a workflow depends on it. `SandboxPathGrant.host_path` is Docker-only and maps a host path to a different POSIX path inside the container. Unix-local supports only same-path grants. See [Manifest path grants](guide.md#manifest) for details. diff --git a/docs/sandbox_agents.md b/docs/sandbox_agents.md index 9177ef5db7..bf14b1e0a3 100644 --- a/docs/sandbox_agents.md +++ b/docs/sandbox_agents.md @@ -79,6 +79,8 @@ async def main() -> None: build_agent("gpt-5.6-sol"), "Open `repo/task.md`, fix the issue, run the targeted test, and summarize the change.", run_config=RunConfig( + # macOS: sandbox-exec confined. On Linux this raises unless the client + # is constructed with allow_unconfined_linux=True; prefer Docker there. sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()), workflow_name="Sandbox coding example", ), diff --git a/examples/sandbox/docs/coding_task.py b/examples/sandbox/docs/coding_task.py index dc2dfec33e..359916427e 100644 --- a/examples/sandbox/docs/coding_task.py +++ b/examples/sandbox/docs/coding_task.py @@ -20,7 +20,6 @@ from agents.sandbox.capabilities import LocalDirLazySkillSource, Skills from agents.sandbox.capabilities.capabilities import Capabilities from agents.sandbox.entries import LocalDir -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient DEFAULT_MODEL = "gpt-5.6-sol" TARGET_TEST_CMD = "sh tests/test_credit_note.sh" @@ -32,6 +31,7 @@ if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[3])) +from examples.sandbox.misc.example_support import unix_local_client # noqa: E402 def build_agent(model: str) -> SandboxAgent[None]: @@ -188,7 +188,7 @@ def _saw_target_test_success(new_items: Sequence[object]) -> bool: async def main(model: str, prompt: str) -> None: agent = build_agent(model) - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=agent.default_manifest) try: diff --git a/examples/sandbox/extensions/temporal/local_hello_workflow.py b/examples/sandbox/extensions/temporal/local_hello_workflow.py index 89f7032ccd..0e1109b393 100644 --- a/examples/sandbox/extensions/temporal/local_hello_workflow.py +++ b/examples/sandbox/extensions/temporal/local_hello_workflow.py @@ -35,7 +35,8 @@ from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Shell from agents.sandbox.entries import File -from agents.sandbox.sandboxes import UnixLocalSandboxClient, UnixLocalSandboxClientOptions +from agents.sandbox.sandboxes import UnixLocalSandboxClientOptions +from examples.sandbox.misc.example_support import unix_local_client TASK_QUEUE = "local-temporal-sandbox-agent" WORKFLOW_ID = "local-temporal-sandbox-agent-workflow" @@ -89,7 +90,7 @@ async def run(self, model: str, trace_mode: str) -> str: def _client_with_plugin(client: Client, trace_mode: str) -> Client: plugin = OpenAIAgentsPlugin( model_params=ModelActivityParameters(start_to_close_timeout=timedelta(seconds=120)), - sandbox_clients=[SandboxClientProvider("local", UnixLocalSandboxClient())], + sandbox_clients=[SandboxClientProvider("local", unix_local_client())], add_temporal_spans=trace_mode == TRACE_MODE_OPENAI_WITH_TEMPORAL_SPANS, ) config = client.config() diff --git a/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py b/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py index 9a0f35fac7..e484b38ad5 100644 --- a/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py +++ b/examples/sandbox/extensions/temporal/temporal_sandbox_agent.py @@ -81,6 +81,7 @@ if _p not in sys.path: sys.path.insert(0, _p) +from examples.sandbox.misc.example_support import unix_local_client # noqa: E402 from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability # noqa: E402 @@ -638,10 +639,10 @@ async def run_worker() -> None: ) from agents.extensions.sandbox import DaytonaSandboxClient, E2BSandboxClient - from agents.sandbox.sandboxes import DockerSandboxClient, UnixLocalSandboxClient + from agents.sandbox.sandboxes import DockerSandboxClient sandbox_clients: list[SandboxClientProvider] = [ - SandboxClientProvider("local", UnixLocalSandboxClient()), + SandboxClientProvider("local", unix_local_client()), ] if _os.environ.get("DAYTONA_API_KEY"): sandbox_clients.append(SandboxClientProvider("daytona", DaytonaSandboxClient())) diff --git a/examples/sandbox/handoffs.py b/examples/sandbox/handoffs.py index 5acb1816c0..217a324598 100644 --- a/examples/sandbox/handoffs.py +++ b/examples/sandbox/handoffs.py @@ -14,12 +14,11 @@ from agents import Agent, Runner from agents.run import RunConfig from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import text_manifest +from examples.sandbox.misc.example_support import text_manifest, unix_local_client from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability DEFAULT_QUESTION = ( @@ -90,7 +89,7 @@ async def main(model: str, question: str) -> None: result = await Runner.run( intake_agent, question, - run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())), + run_config=RunConfig(sandbox=SandboxRunConfig(client=unix_local_client())), ) print(result.final_output) diff --git a/examples/sandbox/healthcare_support/workflow.py b/examples/sandbox/healthcare_support/workflow.py index 5c55b3e23f..c80606b4b7 100644 --- a/examples/sandbox/healthcare_support/workflow.py +++ b/examples/sandbox/healthcare_support/workflow.py @@ -22,7 +22,6 @@ from agents.run import RunConfig from agents.sandbox import Manifest, SandboxPathGrant, SandboxRunConfig from agents.sandbox.entries import Dir, File, LocalDir -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.tool_context import ToolContext from examples.sandbox.healthcare_support.data import HealthcareSupportDataStore from examples.sandbox.healthcare_support.models import ( @@ -37,6 +36,7 @@ memory_recap_agent, ) from examples.sandbox.healthcare_support.tools import HealthcareSupportContext +from examples.sandbox.misc.example_support import unix_local_client EXAMPLE_ROOT = Path(__file__).resolve().parent POLICIES_ROOT = EXAMPLE_ROOT / "policies" @@ -428,7 +428,7 @@ async def run_healthcare_support_workflow( await context.emit("memory_ready", session_id=conversation_session.session_id) hooks = WorkflowHooks() - sandbox_client = UnixLocalSandboxClient() + sandbox_client = unix_local_client() sandbox = await sandbox_client.create(manifest=_build_manifest(scenario)) await context.emit( "sandbox_ready", diff --git a/examples/sandbox/memory.py b/examples/sandbox/memory.py index bdbf4a732e..7848af0190 100644 --- a/examples/sandbox/memory.py +++ b/examples/sandbox/memory.py @@ -11,11 +11,11 @@ from agents.sandbox import LocalSnapshotSpec, Manifest, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Filesystem, Memory, Shell from agents.sandbox.entries import File -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.session.base_sandbox_session import BaseSandboxSession if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from examples.sandbox.misc.example_support import unix_local_client DEFAULT_MODEL = "gpt-5.6-sol" FIRST_PROMPT = "Inspect workspace and fix invoice total bug in src/acme_metrics/report.py." @@ -173,7 +173,7 @@ def _run_config(*, sandbox: BaseSandboxSession, workflow_name: str) -> RunConfig async def main(*, model: str) -> None: manifest = _build_manifest() agent = _build_agent(model=model, manifest=manifest) - client = UnixLocalSandboxClient() + client = unix_local_client() with tempfile.TemporaryDirectory(prefix="sandbox-memory-example-") as snapshot_dir: # Use a local snapshot so the second run resumes the same workspace in a new sandbox diff --git a/examples/sandbox/memory_multi_agent_multiturn.py b/examples/sandbox/memory_multi_agent_multiturn.py index a564e80626..494be83e88 100644 --- a/examples/sandbox/memory_multi_agent_multiturn.py +++ b/examples/sandbox/memory_multi_agent_multiturn.py @@ -10,10 +10,10 @@ from agents.sandbox import Manifest, MemoryLayoutConfig, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Filesystem, Memory, Shell from agents.sandbox.entries import Dir, File -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from examples.sandbox.misc.example_support import unix_local_client DEFAULT_MODEL = "gpt-5.6-sol" GTM_SESSION_ID = "gtm-q2-pipeline-review" @@ -167,7 +167,7 @@ async def main(*, model: str) -> None: manifest = _build_manifest() gtm_agent = _build_gtm_agent(model=model, manifest=manifest) engineering_agent = _build_engineering_agent(model=model, manifest=manifest) - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=manifest) workspace_root = Path(sandbox.state.manifest.root) diff --git a/examples/sandbox/misc/example_support.py b/examples/sandbox/misc/example_support.py index 0f6a1bb04a..96419c9409 100644 --- a/examples/sandbox/misc/example_support.py +++ b/examples/sandbox/misc/example_support.py @@ -1,9 +1,34 @@ from __future__ import annotations +import os +import sys from collections.abc import Mapping from agents.sandbox import Manifest from agents.sandbox.entries import File +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient +from agents.sandbox.session.dependencies import Dependencies + + +def unix_local_client(*, dependencies: Dependencies | None = None) -> UnixLocalSandboxClient: + """Build the Unix-local sandbox client used by the sandbox examples. + + UnixLocalSandboxClient is OS-confined (sandbox-exec) on macOS only; on Linux + it runs commands unconfined on the host, so the SDK requires an explicit + opt-in. The examples only opt in when AGENTS_ALLOW_UNCONFINED_LINUX=1 is + set; on Linux the Docker sandbox examples are the safer default. + """ + + unconfined = sys.platform != "darwin" and ( + os.environ.get("AGENTS_ALLOW_UNCONFINED_LINUX") == "1" + ) + if sys.platform != "darwin" and not unconfined: + raise SystemExit( + "UnixLocalSandboxClient only has OS-level confinement on macOS; on Linux it " + "runs commands unconfined on the host. Set AGENTS_ALLOW_UNCONFINED_LINUX=1 " + "to opt in, or use the Docker sandbox examples (examples/sandbox/docker/)." + ) + return UnixLocalSandboxClient(allow_unconfined_linux=unconfined, dependencies=dependencies) def text_manifest(files: Mapping[str, str]) -> Manifest: @@ -15,7 +40,7 @@ def text_manifest(files: Mapping[str, str]) -> Manifest: def tool_call_name(raw_item: object) -> str: - """Return a readable name for a raw tool call item.""" + """Return a readable name for a raw tool call.""" if isinstance(raw_item, dict): name = raw_item.get("name") diff --git a/examples/sandbox/sandbox_agent_capabilities.py b/examples/sandbox/sandbox_agent_capabilities.py index e5819b99dc..a6e7a58bfc 100644 --- a/examples/sandbox/sandbox_agent_capabilities.py +++ b/examples/sandbox/sandbox_agent_capabilities.py @@ -48,12 +48,11 @@ from agents.sandbox.capabilities.capabilities import Capabilities from agents.sandbox.entries import File, LocalDir from agents.sandbox.errors import WorkspaceReadNotFoundError -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.session.base_sandbox_session import BaseSandboxSession if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - +from examples.sandbox.misc.example_support import unix_local_client DEFAULT_MODEL = "gpt-5.5" COMPACTION_THRESHOLD = 1_000 @@ -363,7 +362,7 @@ async def main(model_name: str) -> None: _write_local_skill(skills_root) agent = _build_agent(model, skills_root) - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=agent.default_manifest) try: diff --git a/examples/sandbox/sandbox_agent_with_remote_snapshot.py b/examples/sandbox/sandbox_agent_with_remote_snapshot.py index db7a0f890d..cda62046c6 100644 --- a/examples/sandbox/sandbox_agent_with_remote_snapshot.py +++ b/examples/sandbox/sandbox_agent_with_remote_snapshot.py @@ -17,13 +17,12 @@ from agents import ModelSettings, Runner from agents.run import RunConfig from agents.sandbox import Manifest, RemoteSnapshotSpec, SandboxAgent, SandboxRunConfig -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.session import Dependencies if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import text_manifest +from examples.sandbox.misc.example_support import text_manifest, unix_local_client from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability S3_BUCKET_ENV_VAR = "S3_MOUNT_BUCKET" @@ -117,7 +116,7 @@ async def _verify_remote_snapshot_round_trip(*, model: str) -> None: SNAPSHOT_CLIENT_DEPENDENCY_KEY, S3SnapshotClient(bucket=_require_s3_bucket(), prefix=SNAPSHOT_OBJECT_PREFIX), ) - client = UnixLocalSandboxClient(dependencies=dependencies) + client = unix_local_client(dependencies=dependencies) sandbox = await client.create( manifest=manifest, diff --git a/examples/sandbox/sandbox_agent_with_tools.py b/examples/sandbox/sandbox_agent_with_tools.py index ff4af5fc67..7bfb35a98b 100644 --- a/examples/sandbox/sandbox_agent_with_tools.py +++ b/examples/sandbox/sandbox_agent_with_tools.py @@ -18,12 +18,15 @@ from agents.mcp import MCPServerStdio from agents.run import RunConfig from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import text_manifest, tool_call_name +from examples.sandbox.misc.example_support import ( + text_manifest, + tool_call_name, + unix_local_client, +) from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability DEFAULT_QUESTION = ( @@ -94,7 +97,7 @@ async def main(model: str, question: str) -> None: result = await Runner.run( agent, question, - run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())), + run_config=RunConfig(sandbox=SandboxRunConfig(client=unix_local_client())), ) tool_names: list[str] = [] for item in result.new_items: diff --git a/examples/sandbox/sandbox_agents_as_tools.py b/examples/sandbox/sandbox_agents_as_tools.py index 65c96d22ea..ed7f6f8a50 100644 --- a/examples/sandbox/sandbox_agents_as_tools.py +++ b/examples/sandbox/sandbox_agents_as_tools.py @@ -24,12 +24,15 @@ from agents.decorators import tool from agents.run import RunConfig from agents.sandbox import SandboxAgent, SandboxRunConfig -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import text_manifest, tool_call_name +from examples.sandbox.misc.example_support import ( + text_manifest, + tool_call_name, + unix_local_client, +) from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability DEFAULT_QUESTION = ( @@ -157,8 +160,8 @@ async def main(model: str, question: str) -> None: ) # Each sandbox-backed tool gets its own run configuration so the workspaces stay isolated. - pricing_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())) - rollout_run_config = RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())) + pricing_run_config = RunConfig(sandbox=SandboxRunConfig(client=unix_local_client())) + rollout_run_config = RunConfig(sandbox=SandboxRunConfig(client=unix_local_client())) orchestrator = Agent( name="Revenue Operations Coordinator", diff --git a/examples/sandbox/shared_session_workdirs.py b/examples/sandbox/shared_session_workdirs.py index e21e36281c..75c6acec21 100644 --- a/examples/sandbox/shared_session_workdirs.py +++ b/examples/sandbox/shared_session_workdirs.py @@ -10,6 +10,7 @@ import asyncio import base64 import json +import sys from pathlib import Path from agents import ModelSettings, Runner, RunResult, ToolOutputImage @@ -18,12 +19,16 @@ from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Filesystem, Shell from agents.sandbox.entries import BaseEntry, Dir, File -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.session import BaseSandboxSession +if __package__ is None or __package__ == "": + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from examples.sandbox.misc.example_support import unix_local_client + async def main(*, model: str) -> None: - client = UnixLocalSandboxClient() + client = unix_local_client() agent_a = _build_agent(name="Task A worker", model=model) agent_b = _build_agent(name="Task B worker", model=model) shared_sandbox = await client.create(manifest=_build_manifest()) diff --git a/examples/sandbox/tutorials/misc.py b/examples/sandbox/tutorials/misc.py index 805524824c..682fc8f7b8 100644 --- a/examples/sandbox/tutorials/misc.py +++ b/examples/sandbox/tutorials/misc.py @@ -44,7 +44,6 @@ ) from agents.sandbox import Manifest from agents.sandbox.sandboxes.docker import DockerSandboxClient, DockerSandboxClientOptions -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient from agents.sandbox.session import BaseSandboxClient, SandboxSession from agents.stream_events import ( AgentUpdatedStreamEvent, @@ -52,6 +51,7 @@ StreamEvent, ) from examples.auto_mode import input_with_fallback, is_auto_mode +from examples.sandbox.misc.example_support import unix_local_client DEFAULT_SANDBOX_IMAGE = "sandbox-tutorials:latest" console = Console() @@ -127,7 +127,7 @@ async def create_sandbox_client_and_session( ) return client, sandbox - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=manifest) return client, sandbox diff --git a/examples/sandbox/unix_local_pty.py b/examples/sandbox/unix_local_pty.py index f16d66e35c..f908723221 100644 --- a/examples/sandbox/unix_local_pty.py +++ b/examples/sandbox/unix_local_pty.py @@ -19,12 +19,11 @@ from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig from agents.sandbox.capabilities import Shell from agents.sandbox.entries import File -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import tool_call_name +from examples.sandbox.misc.example_support import tool_call_name, unix_local_client DEFAULT_MODEL = "gpt-5.6-sol" DEFAULT_QUESTION = ( @@ -82,7 +81,7 @@ def _raw_item_call_id(raw_item: object) -> str | None: async def main(model: str, question: str) -> None: agent = _build_agent(model) - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=agent.default_manifest) try: diff --git a/examples/sandbox/unix_local_runner.py b/examples/sandbox/unix_local_runner.py index 874e0409ce..bd29d71053 100644 --- a/examples/sandbox/unix_local_runner.py +++ b/examples/sandbox/unix_local_runner.py @@ -2,7 +2,10 @@ Start here if you want the simplest Unix-local sandbox example. This file mirrors the Docker example, but the sandbox runs as a temporary local -workspace on macOS or Linux instead of inside a Docker container. +workspace instead of inside a Docker container. Unix-local confinement relies on +macOS sandbox-exec; on Linux it runs unconfined on the host, so the shared +example client helper requires AGENTS_ALLOW_UNCONFINED_LINUX=1 to opt in (the +Docker example is the safer choice there). """ import argparse @@ -18,12 +21,11 @@ from agents.run import RunConfig from agents.sandbox import Manifest, SandboxAgent, SandboxPathGrant, SandboxRunConfig from agents.sandbox.errors import WorkspaceArchiveWriteError -from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient if __package__ is None or __package__ == "": sys.path.insert(0, str(Path(__file__).resolve().parents[2])) -from examples.sandbox.misc.example_support import text_manifest +from examples.sandbox.misc.example_support import text_manifest, unix_local_client from examples.sandbox.misc.workspace_shell import WorkspaceShellCapability DEFAULT_QUESTION = ( @@ -91,7 +93,7 @@ async def _verify_extra_path_grants() -> None: exec_output = scratch_dir / "exec_output.txt" external_input.write_text("external grant input\n", encoding="utf-8") - client = UnixLocalSandboxClient() + client = unix_local_client() sandbox = await client.create(manifest=_build_manifest(external_dir, scratch_dir)) try: async with sandbox: @@ -169,7 +171,7 @@ async def main(model: str, question: str, stream: bool) -> None: # With Unix-local sandboxes, the runner creates and cleans up the temporary workspace for us. run_config = RunConfig( - sandbox=SandboxRunConfig(client=UnixLocalSandboxClient()), + sandbox=SandboxRunConfig(client=unix_local_client()), workflow_name="Unix local sandbox review", tracing_disabled=True, ) diff --git a/src/agents/sandbox/errors.py b/src/agents/sandbox/errors.py index 9d92e63ae4..57757bd663 100644 --- a/src/agents/sandbox/errors.py +++ b/src/agents/sandbox/errors.py @@ -59,6 +59,8 @@ def __str__(self) -> str: OpName = Literal[ "start", "stop", + "create", + "resume", "exec", "read", "write", diff --git a/tests/sandbox/test_unix_local.py b/tests/sandbox/test_unix_local.py index 299372a20f..351f15eb00 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -354,7 +354,9 @@ async def _exec_internal( @pytest.mark.asyncio -async def test_exec_environment_is_allowlisted_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +async def test_exec_environment_is_allowlisted_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: monkeypatch.setenv("OPENAI_API_KEY", "sk-test-secret") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "aws-secret") monkeypatch.setenv("PATH", "/usr/bin:/bin") @@ -436,14 +438,47 @@ async def test_resume_ignores_untrusted_state_flags_and_enforces_linux_opt_in( @pytest.mark.asyncio async def test_inherit_environment_never_persists_in_session_state(tmp_path: Path) -> None: - from agents.sandbox.sandboxes.unix_local import ( - UnixLocalSandboxClient, - UnixLocalSandboxSessionState, - ) - from agents.sandbox.snapshot import NoopSnapshot as _NoopSnapshot + from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient client = UnixLocalSandboxClient(allow_unconfined_linux=True, inherit_environment=True) session = await client.create(manifest=Manifest(root=str(tmp_path / "ws"))) payload = client.serialize_session_state(session.state) assert "inherit_environment" not in payload await session.stop() + + +@pytest.mark.asyncio +async def test_linux_guard_error_survives_mount_redaction( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import sys as _sys + + from agents.sandbox.entries import S3Mount + from agents.sandbox.entries.mounts import InContainerMountStrategy + from agents.sandbox.entries.mounts.patterns import RcloneMountPattern + from agents.sandbox.errors import ConfigurationError, ErrorCode + + monkeypatch.setattr(_sys, "platform", "linux") + manifest = Manifest( + root=str(tmp_path / "ws"), + entries={ + "data": S3Mount( + bucket="example-bucket", + access_key_id="example-access-key", + secret_access_key="example-secret-key", + mount_strategy=InContainerMountStrategy(pattern=RcloneMountPattern()), + ) + }, + ) + + # A credentialed manifest puts create() behind the mount-redaction boundary; + # the guard's structured error must survive it as a ConfigurationError with + # its machine-readable code intact (not collapse into a generic RuntimeError). + with pytest.raises(ConfigurationError) as exc_info: + await UnixLocalSandboxClient().create(manifest=manifest) + + error = exc_info.value + assert error.error_code == ErrorCode.UNCONFINED_LINUX_NOT_ALLOWED + assert error.op == "create" + assert error.retryable is False + assert "example-secret-key" not in str(error)