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 6f52a8d7b7..56b9c6ced9 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`. 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. 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/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 252b2a6f28..57757bd663 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" @@ -58,6 +59,8 @@ def __str__(self) -> str: OpName = Literal[ "start", "stop", + "create", + "resume", "exec", "read", "write", diff --git a/src/agents/sandbox/sandboxes/unix_local.py b/src/agents/sandbox/sandboxes/unix_local.py index d0c2ea28b7..27c69e2845 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,15 +94,45 @@ 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", + "UV_PYTHON", + "NO_COLOR", + "FORCE_COLOR", + "CI", + } +) + + class UnixLocalSandboxSessionState(SandboxSessionState): type: Literal["unix_local"] = "unix_local" workspace_root_owned: bool = False 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, ...] = () - def __init__( self, exposed_ports: tuple[int, ...] = (), @@ -142,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 = {} @@ -440,7 +477,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._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) @@ -1099,11 +1148,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( @@ -1115,6 +1172,26 @@ 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 + 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 construct the client with 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 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 @@ -1136,6 +1213,7 @@ async def create( exposed_ports=resolved_options.exposed_ports, ) 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: @@ -1174,9 +1252,31 @@ 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." + ) 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 67ea2416ed..351f15eb00 100644 --- a/tests/sandbox/test_unix_local.py +++ b/tests/sandbox/test_unix_local.py @@ -327,3 +327,158 @@ 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"), + ) + ) + # 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( + 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 + + 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"))) + + 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 +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"), + ) + + 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) + 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 + + 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)